Compare commits

..
Author SHA1 Message Date
Vito Sansevero f473b1600c Merge remote-tracking branch 'origin/main' into fix/resolver-parity-live 2026-10-23 04:59:35 -07:00
Vito Sansevero e110b987fe fix(resolver): read NaN as null in the scan's legacy-parser fallback
ComfyUI stamps "is_changed": [NaN] on nodes that always re-run, the
PromptManager node among them. The legacy gallery and admin pages replaced
NaN with null before JSON.parse, so they traced these files' prompts. The
scan's fallback parsed records strictly, so every NaN-stamped file the
resolver could not decide (hires-fix and refiner chains, custom latent
nodes) fell to "Needs review" although legacy linked its prompt.

parse_page_json parses as the legacy pages did, but only the NaN number
reads as null: the word inside prompt text is kept. Infinity still makes
the record unreadable, as it did in the pages.
2026-10-23 04:04:13 -07:00
81 changed files with 382 additions and 8200 deletions
+16 -289
View File
@@ -121,13 +121,6 @@ npm run --prefix tools/vendor test:comfyui
CI performs this setup automatically. `PM_COMFY_URL` can select another
`http://127.0.0.1:PORT/` address (default port 8189). This is an owned managed
server: a running host is never reused. Do not point it at your live checkout.
One port serves one run. The launcher refuses a port that is already in use and
reports ready only when `/system_stats` names its own base directory, so two runs
started together on 8189 fail at startup ("already in use" or "answered by a
ComfyUI host this run did not start") instead of one run testing the other's
host and losing it mid-test (`ECONNREFUSED`, `ERR_CONNECTION_REFUSED`, Network
Error). Every test checks the answering host again. Agents sharing a machine
give each run its own `PM_COMFY_URL` port.
The launcher verifies the upstream SHA and clean tracked files, snapshots only
tracked PromptManager files, and creates temporary user/input/output/database/
configuration/log directories. Personal untracked files are excluded. It runs
@@ -392,8 +385,7 @@ with source `zeroed-conditioning`. `easy ifElse` with a literal boolean follows
branch only; with a linked boolean both branches are traced, equal texts resolve and
different texts make the sampler `ambiguous` with one candidate per choice (at most
32, else `work-limit`). ConditioningCombine/Concat/Average resolve only when every
part has the same text, otherwise `unresolved` with `composite` naming the node
(but see the added-conditioning floor below for a later stage).
part has the same text, otherwise `unresolved` with `composite` naming the node.
Results that depend on a forced choice are never memoized. A `FaceDetailer`
(`detailer-stage`) is a sampler stage: the samplers reached through its `image`
ancestry are compared with it. Equal texts resolve with the detailer as the sampler;
@@ -404,65 +396,9 @@ A sampler's latent ancestry is walked through every link input of non-sampler
nodes within the same visit and depth budgets: with no earlier sampler it is a
single stage whatever the origin (VAEEncode, EmptyLatentBatch, EmptyFlux2LatentImage,
EmptyHunyuanLatentVideo, SetLatentNoiseMask, InpaintModelConditioning or
WanImageToVideo slot 2). Pass-through lineage (#304 follow-up, maintainer option B,
2026-10-23): the table's `lineage` section lists the outputs whose image or latent
continues one input's (`VAEDecode`/`VAEDecodeTiled` `samples`, `VAEEncode`/
`VAEEncodeTiled` `pixels`, `ImageUpscaleWithModel`, `ImageScale`, `ImageScaleBy`,
`ImageScaleToTotalPixels` `image`, `LatentUpscale`/`LatentUpscaleBy` `samples`,
cg-image-filter `Image Filter` slot 0 `images` and slot 1 `latents`; a selection of
the same batch), plus Reroutes. Lineage describes no prompt, so its classes are not
claimed by a prompt category. When the latent walk finds exactly one earlier sampler
and the latent descends from that sampler's output slot 0 only through lineage
nodes (a hires-fix or refiner chain, img2img from an in-graph decode, a direct
latent link), the two stages are compared like a detailer and its upstream stage:
equal role texts resolve with the later sampler named, different texts are
`ambiguous` `multistage-differs` with one candidate per stage, and an unreadable
earlier stage makes the chain `unresolved` with that stage's reason. Unanchored, the
earlier sampler is judged inside the chain when its output reaches no other file
output. A size or resolution node reading the same image beside the lineage (a
`ResolutionCalculator` feeding `ImageScale` width and height) is allowed, since the
walk then still finds only that sampler. Any other earlier sampler, or one reached
only through an unlisted image node, stays `unresolved` "Multistage sampler ancestry
is ambiguous". Added-conditioning floor: a later stage whose own positive fails only
as `composite` keeps its earlier stages' shared pair ("A later stage adds
conditioning to the base prompt", naming the earlier stage's sampler) when that
positive reaches, through Reroutes and conditioning pass-throughs, a composite the
table marks `additive` (ConditioningCombine, ConditioningConcat; never
ConditioningAverage, which can weight a part away), one of its parts is exactly the
earlier stages' positive text, and its negative text equals theirs. This is the
three-stage Flux run whose UltimateSDUpscale combines the PromptManager prompt with
a style-trigger encoder: legacy linked the PromptManager text and every stage is
conditioned on it. The positive is always labelled (maintainer ruling): its source is
`added-conditioning` (D126; with a D125 omission too,
`runtime-parts-omitted-and-added-conditioning`) and its provenance names the
composite (`added_by`), whatever the added text is; no length heuristic. A combine without the base text, a different negative, or
different earlier stages stay as they were (`composite` or `multistage-differs`).
An image-edit batch (several samplers with distinct prompts, one shared saver
prefix) stays `ambiguous`, and text generated at run time stays untraceable
(`tests/test_resolver_pass_through.py` on synthetic shapes from
`tests/passthrough_graphs.py`; the browser port is compared on the named
`select-*` chain cases and `passthrough_fuzz_cases`). Ingestion keeps the default
profile's historical latent rule (D1), so the admin scan still reaches these files
through its legacy-parser fallback. Run-time PromptManager parts (maintainer option
A, 2026-10-23): LoraManager's `TriggerWord Toggle` is a `runtime` class (its output is
the LoRA's trigger words as filtered at run time). A PromptManager (or
PromptManagerPair) `prepend_text` or `append_text` linked directly to a `runtime`
node is left out: the text is the node's own, combined with any other literal or
traced part exactly as before, the side's source is `runtime-parts-omitted`, and a
resolved pair's reason is "Text added at run time, such as trigger words, is not
included". The prompt block shows that line under the text (D125; the evidence
badge stays "Workflow inferred"). The label follows the text through encoders,
concatenations and chained PromptManagers: a side is labelled when any node on its
traced path left a part out, and its provenance lists those PromptManager nodes
(`omitted_parts`). Comparisons include what a text leaves out, so a prompt that left
out a part never equals a complete one (a branch with the toggle and one without stay
ambiguous; such stages are `multistage-differs`). Only classes in the table's
`runtime` list are left out, never another `runtime-value` source (a JoinStringMulti
with a linked list flag stays `runtime-value`). The toggle's output is never computed. A run-time node further upstream (inside a
concatenation), a run-time main `text`, or an unknown feeder keep their outcome
(`runtime-value` or `unsupported-node`). This is what legacy linked for the June
hires-fix runs (`TestRunTimePromptManagerParts`, the `select-runtime-*` parity
cases); an unlisted node with sampler-shaped inputs (positive, negative, guider,
WanImageToVideo slot 2). An earlier sampler (img2img from a decode, hires fix) stays
`unresolved` "Multistage sampler ancestry is ambiguous" until stage texts are
compared; an unlisted node with sampler-shaped inputs (positive, negative, guider,
latent_image, a pipe, video embeds, custom-sampler parts, or a `model` link together
with conditioning, neg_conditioning, samples or latent) is `unsupported-node`
naming it, here and in a detailer's image ancestry; a latent that is not a link or
@@ -1898,86 +1834,8 @@ browser fields, then the host fields (`POST /prompt_manager/settings` with
highlights their controls; `settings-changed` is a conflict and keeps the draft. System
reads the host values on mount and again after a conflict; until a read returned a
revision, a host save fails without a request, so it never sends a blind
`expected_revision: null` (which S1 merges into the file on disk). Save stays disabled
until a section has an unsaved change.
Appearance (`system-appearance.mjs`, S3) is the concept's "Browsing & display" surface
on real values. Its browser rows are this browser's display defaults in the legacy
gallery's own `gallerySettings` object (`web/js/workspace/preferences.mjs`, Q3): Default
layout and Thumbnail size (`workspaceLayout` grid/list/masonry, `workspaceDensity`
comfortable/large/compact, new keys), Load metadata on selection (`autoLoadMetadata`),
Cache metadata in this browser (`cacheMetadata`), Image loading quality (`imageQuality`:
Optimized→`medium`, Original→`high`, Fast→`low`; the thumbnail dialog's preselected quality
once S5 lands), the three video rows (`videoAutoplay`, `videoMute`, `videoLoop`) and Show
file paths (`showFilePaths`). A write re-reads the stored object and writes back only the
changed keys, every other key (legacy paging, thumbnail state, unknown keys) as stored;
the legacy gallery's Save also keeps the keys it does not edit, so the two pages stay in
sync. The workspace never reads the legacy paging keys (`defaultLimit`, `gridColumns`,
`infiniteScroll`; README continuous-browsing ruling). A browser that refuses storage
(denied, or a full quota that leaves the stored bytes as they were) keeps the change for
this tab only and the status says "Saved for this tab only; browser storage is
unavailable.", never "Saved in this browser.". Another tab's change (a `storage` event)
repaints the clean controls; a draft it overtook stays, highlighted, with the conflict
status. Open from ComfyUI (New tab→`newtab`, Popup window→`popup`) and Node search
auto-hide (seconds) (a whole number 0–300; anything else is highlighted and never sent)
are the server's `webui_display_mode` and `result_timeout`, saved through
`POST /prompt_manager/settings` with the revision. The consumers read the defaults on
their next visit, never the current view: a new Images visit starts from the layout and
size (the last visit keeps its own, a saved view opens at Grid and Comfortable as
before); with metadata off a selection reads nothing until "Load metadata"; with the
cache off selecting the shown file again re-reads it (on, the one current record is
reused); paths off leaves out only the Path row of File & provenance; the viewer reads
the video defaults.
Sources & scans (`system-sources.mjs`, S4) is the concept's "sources" body on the
server's sources. `GET /prompt_manager/settings` reports them (public paths only):
`sources.configured` (each `gallery_root_paths` entry with `valid`, `code` and the
server's `reason`), `sources.effective` (what is scanned and served, with its
`root_index` and `watch`: `watching` only when the image monitor's observer is alive,
scheduled that folder and monitoring is on, `stopping` when it still watches it but
monitoring was switched off, `manual` when monitoring is off or no monitor runs, else
`restart_pending`), `sources.auto_detected`, `monitor` and `settings.monitoring_enabled`.
`POST /prompt_manager/settings/validate-source {path}` checks one folder and writes
nothing. Every caller (validate-source, the settings save, config.json at load) refuses
a source through the same checks (`sources.source_problem`): its form first, then
containment decided as text (`normcase(normpath)` against the allowed folders' own
spellings), before anything resolves, stats or opens the path, so a UNC share, the
`\\?\` and `\\.\` namespaces, a device or any folder outside the allowed ones never
reaches the filesystem or the network (no SMB request, no existence or timing oracle).
Only then is the path resolved and contained again, which catches a symlink out. The
filesystem root and the home directory answer `outside-allowed` with the same reason as
any outside path. ComfyUI folders outside its base directory (`--output-directory`)
are path anchors too, so their sources keep a relative public path
(`renders/portraits`) that round-trips and never collides by folder name. A save whose
list holds a source the server cannot use is a 400 `invalid-source` naming its public
path (`source`, as text: a refused path is never resolved to name it), its `index` and
`reason`, and saves nothing. A save that changes the sources (compared as folders, not
spellings) holds the registry as `records` ("Source change"), so it is refused with
409 `busy` while a scan runs. `POST /prompt_manager/jobs/scan {scope, roots, paths}`
scans only the given effective `root_index`es (strict ints, at most 64), each with the
public path the page read for it; an index past the list or one that now names another
folder is 409 `sources-changed` and starts nothing (the page toasts the server's
reason and reads the sources again); the legacy `/scan` is unchanged. The section
lists one row per source ("<path> · watching | watching until restart | manual scan |
restart pending" with "Scan changes" for a watched source, else "Scan folder"), which
opens the shared "Review metadata scan" for that one source ("1 output folder") and
starts the session job over it; the job opens in Activity, and the toast only says it
started. Enrich
existing metadata and the notice's Review scan review every source. The notice shows
until this browser dismissed the 3.2.0 update notice (`pm_update_notice_dismissed`,
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
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
and marks that row. After a save the source list is read again; a monitoring change
waits for a restart (the running observer keeps watching until then, and the status
says "Restart ComfyUI to apply: Watch for new outputs."). Thumbnail generation's
Review options opens the legacy gallery in a new tab (D94) until its review dialog (S5).
A list that cannot be read shows "Sources could not be loaded. Your saved data has not
changed." and Retry, never an empty list.
`expected_revision: null` (which S1 merges into the file on disk). No delivered section
edits a setting yet, so Save stays disabled until Appearance (S3).
Overview (`system-overview.mjs`) reads `GET /prompt_manager/system/overview` once per
visit through `latestOnly()` on the section scope; a late answer after the section left
@@ -2006,7 +1864,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–D66 and D81 renumbered D92–D98 and D113; D121–D128 assigned):
Recorded deviations (plan D60–D64 renumbered D92–D96; D121, D122 and D123 assigned):
- **D92** (shell): no "Sample configuration" chip. The Save status is truthful: "No
unsaved changes.", "Unsaved changes.", "Saved in this browser." / "Saved to the server
@@ -2064,92 +1922,6 @@ Recorded deviations (plan D60–D66 and D81 renumbered D92–D98 and D113; D121
the rest of Maintenance, above its D94 hand-off, as the native place that runs the
health check. A press shows "Checking…" (`aria-disabled`) and opens the report; a
failed check toasts "The health check could not finish. Nothing was changed."
- **D97** (Appearance): added rows for the legacy gallery settings the concept leaves
out, after its own rows: "Show image details on cards" (`showImageInfo`; off hides a
card's title and file line, Info and Tag stay at the end of the row), "Collapse viewer
details by default" (`sidebarCollapsedByDefault`; the viewer opens with
`#viewer-details` hidden and a "Show details"/"Hide details" toggle with
`aria-expanded`, the stage taking the whole width), and the operation "Reset display
defaults" ("Restore this browser’s display defaults, then save. Other settings are
kept.", button "Reset"), which drafts the gallery's defaults for the browser rows only.
The node search timeout shows the server's real value, not the sample 30. CSS under D97.
- **D98** (Sources): each configured source's row has a plain Remove after its scan
button; nothing changes until Save. A source added this session reads "<path> · not
saved yet" with Remove only until Save; a configured source the server cannot use
reads "<path> · <server reason>" with Remove only. A line under the list says when the
list is not the whole story: "No source is configured. After saving, ComfyUI’s output
folder is used." (the last one removed), "No source is configured; ComfyUI’s output
folder is used." and "No configured source is available; ComfyUI’s output folder is
used." (auto-detected rows, listed without Remove). "restart pending" is the third
watch state. The operation "Refresh files" ("Read every source again to find files
added or removed outside ComfyUI.", button "Refresh") closes the surface: it calls the
legacy gallery's `/gallery/rescan` and toasts "Files refreshed. N files in M output
folders." or "Files could not be refreshed. Try again.". The concept's "sample path"
note is prototype copy and is dropped.
- **D113** (Sources): the Worker threads range ends at the server's `cpu_count` (the
concept's fixed `max=16`) and shows the server's real `worker_threads` (the concept's
sample 4).
- **D127** (Sources): the concept's `add-source` `simpleInput` with production
copy: the field is "Folder path" (the concept's "(sample only)" dropped), it starts
empty (not the demo "/images/new-collection"), and the action is "Add" (not "Apply in
prototype"); Enter in the field adds too. `add source dialog matches the concept`
(system-concept.spec) compares the open dialog with these three mapped, and `add
source comparison detects mutations` proves the mapping is the only difference. A
folder the server refuses keeps the dialog open with the field
`aria-invalid` and the server's reason in a `p.small[role=alert]` above the actions;
a folder the server names as one already listed says "This folder is already a
source.", and a check that cannot finish says "The folder could not be checked. Try
again."; neither adds anything.
The concept dialog does no check, so `invalid source shows the server reason`
(workspace-system-sources.spec) pins it.
- **D128** (Sources): states the concept's sample rows never show. A source the
running monitor still watches after "Watch for new outputs" was saved off reads
"<path> · watching until restart" (still "Scan changes"), and the save's status adds
"Restart ComfyUI to apply: Watch for new outputs." from `restart_required_fields`. A
configured source the effective list holds under another spelling reads "<path> ·
same folder as another source" with only Remove. A save refused for one source
(`invalid-source`) marks that row (`aria-invalid`, outlined like a D92 setting) and
the status is the server's "<path> can't be used: <reason>. Remove it or reconnect it,
then save." ("Remove it, then save." when reconnecting cannot help). Pinned by
`Watch for new outputs saves monitoring_enabled; a restart applies it` and `a save
refused for one unusable source names it and marks its row`
(workspace-system-sources.spec) and the system-state and system-sources node tests.
- **D124** (inspector): with "Load metadata on selection" off, the Images panel and
the viewer's details show "Metadata loads when you ask for it." and a "Load metadata"
button instead of reading the file. The concept has no such state, so no concept
comparison renders it: `metadata off shows an explicit load and loads once on request`
(workspace-system.spec) pins it instead.
- **D125** (prompt blocks, maintainer option A): a resolved prompt whose source is
`runtime-parts-omitted` (a PromptManager `prepend_text` or `append_text` fed directly
by a listed run-time node, such as LoraManager's TriggerWord Toggle, was left out)
shows the muted line "Text added at run time, such as trigger words, is not
included." under its text. The evidence badge stays "Workflow inferred"; the line is
never sent or copied. The concept has no such state (`a prompt that left out run-time
trigger words says so under its text (D125)` in `tests/js/prompt-blocks.test.js`).
- **D126** (prompt blocks, maintainer ruling on the added-conditioning floor): a
resolved positive whose source is `added-conditioning` (a later stage combined more
conditioning with the base prompt, as UltimateSDUpscale's ConditioningCombine of the
PromptManager and a style trigger) shows "Conditioning added at a later stage is not
included." the same way. A prompt with both shows both lines, D125 first (source
`runtime-parts-omitted-and-added-conditioning`). Always labelled, with no length or
"short trigger" heuristic (`a prompt a later stage added conditioning to says so, and
both lines show together (D126)`).
Appearance scope and states. The Default layout and Thumbnail size defaults start the
Images page only (a new visit, not the current view or a saved view); the embedded
galleries (a prompt history's Shared gallery and its All images / Repeat run dialogs)
have no toolbar, so they keep Grid/Comfortable, while the metadata, cache and card
defaults apply there too. A host row whose `/settings` read failed stays disabled with
"Server settings could not be read."; a rejected timeout says "Some changes were not
saved. Review the highlighted settings." A display default another page saved under one
of this tab's drafts flags that draft with the conflict status, whether it arrived while
System was mounted (a `storage` event) or while it was not (checked again on mount, and
before a write); the draft is never written over the other page's value without that
status first. A change kept for this tab only is stored by the next write storage accepts.
Open question (saved-views follow-up, M8): a saved view stores no layout or size and
always opens at Grid/Comfortable, even when the Default layout is Masonry. Should saved
views store a layout, or follow the default?
`tests/browser/system-concept.spec.mjs` compares the shell and Overview with the concept
(dark and light, 1600×1100 and 390×844), mapping only the deviations above; unmapped,
@@ -2162,50 +1934,21 @@ source when there are several, replaces only the chips with the fixture's expect
results, and asserts the full unmapped path list; it maps D96 and compares the
Maintenance notice unchanged. `system comparison detects mutations` removes a row,
renames a metric label, swaps the `--line` token, recolours a chip, renames a check,
removes the totals table and renames a caption. `appearance matches the concept` maps
only D97 and the real timeout (unmapped, exactly those paths differ), and `appearance
comparison detects mutations` removes a row, renames a label and an option and swaps
the `--line` token. `sources matches the concept with|without the notice` (both
themes and sizes; the fixture's monitor watches the first source, as the concept's
first sample is watched, and the concept's Later removes its notice) maps the real
sources for the two samples, D98's Remove and Refresh files, and D113; unmapped,
exactly those paths differ. `sources comparison detects mutations` removes the notice
and a source row, renames a label and the Add source button and swaps the `--line`
token. Paired captures (`{concept,production}-system-{dark,light}-{1600,390}.png`,
`{concept,production}-{statistics,health}-{dark,light}.png`,
`{concept,production}-appearance-{dark,light}-{1600,390}.png`,
`{concept,production}-sources-{with,without}-notice-{dark,light}-{1600,390}.png`) are test artifacts; there
removes the totals table and renames a caption. Paired captures (`{concept,production}-system-{dark,light}-{1600,390}.png`,
`{concept,production}-{statistics,health}-{dark,light}.png`) are test artifacts; there
are no PNG baselines. `tests/browser/workspace-system.spec.mjs` covers the real counts,
the health check and metric 4, a failed check, the thumbnail row against the status
endpoint (painted after the rest when its read is held, unavailable when it fails), the review row (Q6), the section links, the Statistics dialog against the
route, a load failure with Retry and late answers after leaving, and Appearance: new
visits versus the current view and a saved view, the embedded gallery keeping Grid,
metadata off and on request, the cache (counted up to a request barrier), hidden paths,
the video defaults, the host fields with their revision, a stale one and a failed read,
reload and a second tab, a second tab's save while System was closed, storage refused,
the D97 rows and reset. `tests/browser/workspace-system-sources.spec.mjs` covers the
notice's Later and Review scan, a scoped scan from one source into Activity, Add source
with its server check and Save, a refused folder, Remove down to auto-detect, an absent
configured source, the three watch states, the monitoring and worker rows, Refresh
files, the thumbnail hand-off, a save refused by a running scan and a failed read. Its hooks are
route, a load failure with Retry and late answers after leaving. Its hooks are
`tests/browser/workspace_system_fixture.py` (`/__test__/system/*`: reset, injected
failures, an absent source, a deleted linked file, unthumbnailed files, a fake image
monitor) on the
concept-shaped fixture, with delays through the shared request holds; reset also restores
the server's host settings and its `config.json`, which must be the one inside the server's
temp root (`tests/test_workspace_system_fixture.py`).
failures, an absent source, a deleted linked file, unthumbnailed files) on the
concept-shaped fixture, with delays through the shared request holds.
```sh
python3 -m unittest tests.test_database_health tests.test_system_api tests.test_library \
tests.test_workspace_system_fixture -v
python3 -m unittest tests.test_database_health tests.test_system_api tests.test_library -v
node --test tests/js/workspace-system-state.test.js tests/js/workspace-system-controls.test.js \
tests/js/workspace-system-overview.test.js tests/js/workspace-state.test.js \
tests/js/workspace-system-appearance.test.js tests/js/workspace-preferences.test.js \
tests/js/workspace-inspection-state.test.js tests/js/workspace-images-state.test.js \
tests/js/workspace-system-sources.test.js
python3 -m unittest tests.test_settings_sources tests.test_scan_scope -v
tests/js/workspace-system-overview.test.js tests/js/workspace-state.test.js
npm run --prefix tools/vendor test:workflows -- system-concept.spec.mjs workspace-system.spec.mjs \
workspace-system-sources.spec.mjs \
--project chromium --project touch --project firefox --project webkit
```
@@ -2250,24 +1993,8 @@ and skip the parse.
integers past 2**53 as their exact digits, so the card and the inspector read
the same digits. The parse runs after the file handle closes. A record over
2 MiB (UTF-8), a parser error, a recursion error, a placeholder, or a
non-integral, boolean or non-finite seed gives null. When the prompt graph's
sampler stages read different seed inputs and the inspector's resolution names
one sampler for a resolved pair (a detailer or pass-through chain, whose facts
are the named stage's, D19), the card compares that stage's seed (read by the
same parser, the other samplers hidden from it) with its own and shows no seed
only when they differ. A seed that agrees, or one the named stage does not make
known, is kept, as on main (a saved sampler beside a preview-only one keeps its
seed; an ambiguous chain, whose facts merge and fill from the parser, keeps it).
Only such graphs are resolved for the card, with `INSPECTION_PROFILE`
(`test_a_chain_the_inspector_names_one_stage_of_shows_no_differing_seed`,
`test_a_seed_that_agrees_with_the_named_stage_is_kept`,
`test_a_named_ambiguous_chain_keeps_the_parser_seed`; a literal named seed needs no
second parse, and stages sharing one linked seed skip the check. Measured on a
200-card page of about 54 KB graphs, half multi-stage with per-stage seeds: 618 ms
cold against main's 387 ms, about 1.2 ms more per such card, paid once per file
through the header-facts cache below;
before this rule a KSampler then FaceDetailer chain with two seeds broke the
contract below). A card never shows a seed that differs from the inspector's
non-integral, boolean or non-finite seed gives null. A card never shows a seed
that differs from the inspector's
(`test_card_seed_equals_inspector_seed_when_both_present`, which `JSON.parse`s
the listing in Node and compares `String(seed)` with the inspector's seed,
including one past 2**53). The card shows no seed for A1111 `parameters` files
+5 -8
View File
@@ -4,7 +4,6 @@ Database operations for KikoTextEncode prompt storage and retrieval.
import sqlite3
import json
import math
import datetime
import os
from typing import Optional, List, Dict, Any, Set, Tuple
@@ -27,7 +26,6 @@ from .health import HealthOperations, iter_consistency_issues
try:
from ..utils.logging_config import get_logger
from ..utils.hashing import generate_prompt_hash
from ..utils.json_strict import dumps_strict
except ImportError:
import sys
@@ -35,7 +33,6 @@ except ImportError:
sys.path.insert(0, current_dir)
from utils.logging_config import get_logger
from utils.hashing import generate_prompt_hash
from utils.json_strict import dumps_strict
# Subquery to fetch tags from junction table, embedded in SELECT statements
@@ -1700,9 +1697,9 @@ class PromptDatabase(
width,
height,
image_format,
dumps_strict(metadata.get("workflow", {})),
dumps_strict(metadata.get("prompt", {})),
dumps_strict(metadata.get("parameters", {})),
json.dumps(metadata.get("workflow", {})),
json.dumps(metadata.get("prompt", {})),
json.dumps(metadata.get("parameters", {})),
),
)
conn.commit()
@@ -1907,13 +1904,13 @@ class PromptDatabase(
obj: The object to clean (dict, list, or scalar)
Returns:
Cleaned object with NaN and Infinity values replaced by None
Cleaned object with NaN values replaced by None
"""
if isinstance(obj, dict):
return {key: self._clean_nan_values(value) for key, value in obj.items()}
elif isinstance(obj, list):
return [self._clean_nan_values(item) for item in obj]
elif isinstance(obj, float) and not math.isfinite(obj):
elif isinstance(obj, float) and str(obj) == "nan":
return None
else:
return obj
+5 -5
View File
@@ -1,5 +1,6 @@
"""Atomic metadata enrichment for a single known generation context."""
import json
import os
from typing import Any, Dict
@@ -8,10 +9,8 @@ from .operations import _coerce_file_info
try:
from ..utils.hashing import generate_prompt_hash
from ..utils.json_strict import dumps_strict
except ImportError:
from utils.hashing import generate_prompt_hash
from utils.json_strict import dumps_strict
def ingest_resolved_pair(
@@ -25,7 +24,9 @@ def ingest_resolved_pair(
negative_text = negative.get("storage_text", negative["text"])
normalized = normalize_image_path(image_path)
provenance = {"resolver_version": 1, "positive": positive, "negative": negative}
encoded = dumps_strict(provenance, sort_keys=True, separators=(",", ":"))
encoded = json.dumps(
provenance, sort_keys=True, separators=(",", ":"), allow_nan=False
)
with db._pair_transaction() as conn:
images = conn.execute(
"SELECT gi.*,p.hash AS positive_hash FROM generated_images gi "
@@ -66,9 +67,8 @@ def ingest_resolved_pair(
).fetchone()
pairs_added = int(prior_pair is None)
file_size, width, height, image_format = _coerce_file_info(metadata)
# ComfyUI's NaN/Infinity are stored as null: the snapshot is strict JSON.
snapshots = {
column: dumps_strict(metadata[key], sort_keys=True)
column: json.dumps(metadata[key], sort_keys=True, allow_nan=False)
for column, key in (
("workflow_data", "workflow"),
("prompt_metadata", "prompt"),
+2 -3
View File
@@ -16,7 +16,6 @@ import datetime
import functools
import gzip as gzip_module
import json
import math
import ntpath
import os
import re
@@ -610,12 +609,12 @@ class PromptManagerAPI(
return prompts
def _clean_nan_recursive(self, obj):
"""Recursively replace NaN and Infinity in nested data with None."""
"""Recursively clean NaN values from nested data structures."""
if isinstance(obj, dict):
return {key: self._clean_nan_recursive(value) for key, value in obj.items()}
elif isinstance(obj, list):
return [self._clean_nan_recursive(item) for item in obj]
elif isinstance(obj, float) and not math.isfinite(obj):
elif isinstance(obj, float) and str(obj) == "nan":
return None
else:
return obj
+18 -37
View File
@@ -4,7 +4,6 @@ import asyncio
import contextlib
from contextlib import closing
import datetime
import functools
import hashlib
import json
import math
@@ -805,9 +804,6 @@ class AdminRoutesMixin:
result = await self._run_in_executor(
self._delete_duplicate_images_sync, image_paths, output_dir
)
if result["deleted_count"]:
# The cached listing would keep (and count) the deleted files.
self.invalidate_gallery_cache()
response_data = {
"success": True,
@@ -1345,34 +1341,28 @@ class AdminRoutesMixin:
}
)
def _start_scan_job(self, files=DEFAULT_SCAN_SCOPE, roots=None, output_dirs=None):
def _start_scan_job(self, files=DEFAULT_SCAN_SCOPE):
"""Register the scan job synchronously; its runner starts on the loop.
``files`` is the scope (``SCAN_SCOPES``). ``roots`` selects sources by
their index in ``output_dirs`` (the effective sources the caller
resolved); None scans every source, resolved when the job starts.
Raises JobBusy while another record-changing job runs.
``files`` is the scope (``SCAN_SCOPES``). Raises JobBusy while another
record-changing job runs.
"""
if files not in SCAN_SCOPES:
raise ValueError(f"Unknown scan scope: {files!r}")
if roots is not None and (
output_dirs is None or not roots or max(roots) >= len(output_dirs)
):
raise ValueError("A scoped scan needs the sources its roots index")
job = self._jobs.start(
SCAN_JOB_KIND,
SCAN_JOB_TITLE,
record_changing=True,
scope={"files": files, "roots": None if roots is None else list(roots)},
runner=functools.partial(self._run_scan_job, output_dirs=output_dirs),
scope={"files": files},
runner=self._run_scan_job,
groups=SCAN_GROUP_NAMES,
)
self._scan_job = job
return job
async def _run_scan_job(self, job, output_dirs=None):
async def _run_scan_job(self, job):
final = None
async for payload in self._scan_images_events(job, output_dirs):
async for payload in self._scan_images_events(job):
job.publish(payload)
final = payload.get("type")
state = SCAN_FINAL_STATES.get(final, "finished")
@@ -1407,25 +1397,16 @@ class AdminRoutesMixin:
)
return response
async def _scan_images_events(self, job, output_dirs=None):
"""Yield progress payloads while scanning the output directories.
async def _scan_images_events(self, job):
"""Yield progress payloads while scanning every configured output directory.
Every effective source is walked, or only those ``job.scope["roots"]``
selects (indexes into ``output_dirs``). The payloads are the legacy SSE
events, unchanged; ``job`` also gets its status line, totals and one
outcome per processed file. Rows keep each source's listing index.
The payloads are the legacy SSE events, unchanged; ``job`` also gets
its status line, totals and one outcome per processed file.
"""
try:
self.logger.info("Starting image scan operation")
if output_dirs is None:
output_dirs = self._get_all_output_dirs()
selected = job.scope.get("roots")
walked = (
output_dirs
if selected is None
else [output_dirs[index] for index in selected]
)
if not walked:
output_dirs = self._get_all_output_dirs()
if not output_dirs:
self.logger.error("No output directories found")
message = (
"No output directories found. "
@@ -1435,18 +1416,18 @@ class AdminRoutesMixin:
yield {"type": "error", "message": message}
return
dir_names = [self._public_path(d) for d in walked]
folders = _plural(len(walked), "output folder")
job.scope["output_folders"] = len(walked)
dir_names = [self._public_path(d) for d in output_dirs]
folders = _plural(len(output_dirs), "output folder")
job.scope["output_folders"] = len(output_dirs)
scope = SCAN_SCOPE_LOG[job.scope.get("files", "all")]
job.log_line(f"Scan started · {folders} · {scope}")
job.update(status_line=f"Looking for media files in {folders}")
yield self._scan_progress(
0, f"Scanning {len(walked)} directory(ies) for media files..."
0, f"Scanning {len(output_dirs)} directory(ies) for media files..."
)
media_files = await self._run_in_executor(
self._collect_output_media_sync, walked
self._collect_output_media_sync, output_dirs
)
counts = dict.fromkeys(SCAN_COUNT_KEYS, 0)
job.update(
+21 -9
View File
@@ -3,7 +3,6 @@
import asyncio
import base64
import binascii
import functools
import hashlib
import hmac
import json
@@ -38,7 +37,6 @@ try:
from ...database.models import normalize_image_path
from ...utils.card_seed import card_seed, header_params, parsed_positive
from ...utils.parallel import map_parallel
from ...utils.json_strict import dumps_strict
from ...utils.inspection_response import (
encode_inspection_response,
library_metadata,
@@ -58,7 +56,6 @@ except ImportError:
from database.models import normalize_image_path
from utils.card_seed import card_seed, header_params, parsed_positive
from utils.parallel import map_parallel
from utils.json_strict import dumps_strict
from utils.inspection_response import (
encode_inspection_response,
library_metadata,
@@ -985,12 +982,27 @@ class ImageRoutesMixin:
# Add url and thumbnail_url so the frontend can serve them
cleaned_images = self._present_images(cleaned_images)
# Rows an older version stored may still hold NaN/Infinity: answer
# strict JSON, and leave prompt text that spells "NaN" untouched.
return web.json_response(
{"success": True, "images": cleaned_images},
dumps=functools.partial(dumps_strict, default=str),
)
# Additional fallback: convert to JSON string and clean NaN values manually
try:
response_data = {"success": True, "images": cleaned_images}
# Convert to JSON string
json_str = json.dumps(response_data, default=str)
# Clean any remaining NaN values with regex
json_str = re.sub(r":\s*NaN", ": null", json_str)
json_str = re.sub(r"\[\s*NaN\s*\]", "[null]", json_str)
json_str = re.sub(r",\s*NaN\s*,", ", null,", json_str)
json_str = re.sub(r",\s*NaN\s*\]", ", null]", json_str)
json_str = re.sub(r"\[\s*NaN\s*,", "[null,", json_str)
# Parse back to verify it's valid JSON
cleaned_data = json.loads(json_str)
return web.json_response(cleaned_data)
except Exception as json_error:
self.logger.error(f"JSON cleaning error: {json_error}")
# Fallback to original response
return web.json_response({"success": True, "images": cleaned_images})
except Exception as e:
self.logger.error(f"Get prompt images error: {e}")
+17 -93
View File
@@ -18,74 +18,29 @@ def _not_found():
return web.json_response({"success": False, "error": "Job not found"}, status=404)
MAX_SCAN_ROOTS = 64
_SCOPE_ERROR = 'The scan scope must be "new" or "all"'
_ROOTS_ERROR = (
f"roots must be a non-empty list of up to {MAX_SCAN_ROOTS} source indexes, "
"with paths naming the public path of each"
)
SOURCES_CHANGED_ERROR = "The sources changed since they were loaded. Reload them."
def _bad_request(message):
return web.json_response({"success": False, "error": message}, status=400)
def _bad_request(message, code=None):
body = {"success": False, "error": message}
if code is not None:
body["code"] = code
return web.json_response(body, status=400)
async def _scan_scope(request):
"""The requested scan scope ("new" or "all"), or None when the body is invalid.
def _scan_roots(body):
"""``(roots, error)``: ``{index: public path}`` of the selected sources, or None.
Absent ``roots`` means every source. When present it is a non-empty list
of at most MAX_SCAN_ROOTS non-negative ints (never bools or floats), and
``paths`` the same-length list of the public path the caller read for
each (GET /settings), so a stale index never scans another folder.
An empty body (also a chunked one with no bytes), or one without
``scope``, asks for the default.
"""
if "roots" not in body:
return None, None
raw = body["roots"]
paths = body.get("paths")
if not isinstance(raw, list) or not raw or len(raw) > MAX_SCAN_ROOTS:
return None, _ROOTS_ERROR
if not isinstance(paths, list) or len(paths) != len(raw):
return None, _ROOTS_ERROR
selected = {}
for value, path in zip(raw, paths):
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
return None, _ROOTS_ERROR
if not isinstance(path, str) or selected.get(value, path) != path:
return None, _ROOTS_ERROR
selected[value] = path
return dict(sorted(selected.items())), None
async def _scan_request(request):
"""``({files, roots}, None)`` for a scan start, or ``(None, message)``.
``files`` is the scope ("new" or "all"); ``roots`` the selected source
indexes or None for every source. An empty body (also a chunked one with
no bytes), or one without ``scope``, asks for the default scope.
"""
default = {"files": DEFAULT_SCAN_SCOPE, "roots": None}
if not request.can_read_body:
return default, None
return DEFAULT_SCAN_SCOPE
text = await request.text()
if not text.strip(): # e.g. a chunked POST that sent no bytes
return default, None
return DEFAULT_SCAN_SCOPE
try:
body = json.loads(text)
except ValueError:
return None, _SCOPE_ERROR
return None
if not isinstance(body, dict):
return None, _SCOPE_ERROR
files = body.get("scope", DEFAULT_SCAN_SCOPE)
if files not in SCAN_SCOPES:
return None, _SCOPE_ERROR
roots, error = _scan_roots(body)
if error:
return None, error
return {"files": files, "roots": roots}, None
return None
scope = body.get("scope", DEFAULT_SCAN_SCOPE)
return scope if scope in SCAN_SCOPES else None
def busy_response(busy: JobBusy):
@@ -109,13 +64,6 @@ def _summary_event(job):
class JobRoutesMixin:
"""Mixin providing the /prompt_manager/jobs endpoints."""
def _roots_still_listed(self, selected, output_dirs):
"""Whether each ``{index: public path}`` still names that source."""
return all(
index < len(output_dirs) and self._public_path(output_dirs[index]) == path
for index, path in selected.items()
)
def _register_job_routes(self, routes):
@routes.get("/prompt_manager/jobs")
async def list_jobs_route(request):
@@ -184,37 +132,13 @@ class JobRoutesMixin:
"""Start the metadata scan as a job (202), or 409 naming the busy job.
The body's ``scope`` is "new" (the default: new and unresolved files)
or "all". ``roots``, when given, selects sources by their index in
the effective sources (``_get_all_output_dirs``), and ``paths`` names
the public path read for each. An index past them, or one whose
source now has another public path (the list changed since it was
read), is a 409 ``sources-changed`` and starts nothing. The job walks
the sources resolved here, so a later source change cannot move it.
or "all"; anything else is a 400.
"""
scan, error = await _scan_request(request)
if error:
return _bad_request(error)
output_dirs = None
roots = None
if scan["roots"] is not None:
output_dirs = await self._run_in_executor(self._get_all_output_dirs)
listed = await self._run_in_executor(
self._roots_still_listed, scan["roots"], output_dirs
)
if not listed:
return web.json_response(
{
"success": False,
"code": "sources-changed",
"error": SOURCES_CHANGED_ERROR,
},
status=409,
)
roots = list(scan["roots"])
files = await _scan_scope(request)
if files is None:
return _bad_request('The scan scope must be "new" or "all"')
try:
job = self._start_scan_job(
scan["files"], roots=roots, output_dirs=output_dirs
)
job = self._start_scan_job(files)
except JobBusy as busy:
return busy_response(busy)
return web.json_response({"success": True, "job": job.summary()}, status=202)
+31 -246
View File
@@ -4,15 +4,8 @@ A save validates the payload, commits it to config.json through the config
store and publishes it at runtime inside that commit; a save that cannot be
written changes nothing at runtime and says so. ``_config_commit_failure``
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``
checks one path and writes nothing.
"""
import contextlib
import os
from aiohttp import web
@@ -22,10 +15,7 @@ from ..config_store import (
RevisionConflict,
get_config_store,
)
from . import sources
from .admin import _current_image_monitor
from .job_routes import busy_response
from .jobs import JobBusy
try:
from ...utils.validators import validate_result_timeout, validate_worker_threads
@@ -33,75 +23,13 @@ except ImportError:
from utils.validators import validate_result_timeout, validate_worker_threads
# Settings owned by POST /settings: config attribute -> its keys in config.json.
# Settings owned by POST /settings: config attribute -> (section, key).
_SETTINGS_FILE_KEYS = {
"RESULT_TIMEOUT": ("web_ui", "result_timeout"),
"WEBUI_DISPLAY_MODE": ("web_ui", "webui_display_mode"),
"INFINITE_SCROLL": ("web_ui", "infinite_scroll"),
"WORKER_THREADS": ("performance", "worker_threads"),
"MONITORING_ENABLED": ("gallery", "monitoring", "enabled"),
}
# Attributes that live on GalleryConfig; the others on PromptManagerConfig.
_GALLERY_SETTINGS = frozenset({"MONITORING_ENABLED"})
SOURCE_CHANGE_JOB_KIND = "source_change"
SOURCE_CHANGE_JOB_TITLE = "Source change"
def _source_identity(path):
"""The case-folded spelling two roots are the same directory by (Windows)."""
return os.path.normcase(path)
def _stat_identity(path):
"""``(st_dev, st_ino)`` of an existing directory, or None.
Catches case variants on a case-insensitive filesystem whose spelling
normcase keeps (macOS).
"""
try:
stat = os.stat(path)
except (OSError, ValueError):
return None
return (stat.st_dev, stat.st_ino) if stat.st_ino else None
# Refusals that a reconnected drive or folder can lift.
_RECONNECTABLE = frozenset({"not-found", "not-directory"})
def _root_key(root):
"""The directory a configured root names (resolved), else its spelling."""
from ..config import GalleryConfig
try:
return GalleryConfig.resolve_gallery_root(root)
except (OSError, TypeError, ValueError, AttributeError):
return root
def _request_error(message):
return {"code": "invalid-request", "error": message}
def _invalid_source(index, entry, code, reason):
"""The 400 body for the source at ``index`` that cannot be used.
Names it by its public spelling (computed as text: a refused path is
never resolved), so the page can mark that row and say why.
"""
source = sources.public_spelling(entry)
advice = (
"Remove it or reconnect it, then save."
if code in _RECONNECTABLE
else "Remove it, then save."
)
return {
"code": "invalid-source",
"source": source,
"index": index,
"reason": reason,
"error": f"{source} can't be used: {reason}. {advice}",
}
def _section(config, name):
@@ -119,8 +47,10 @@ def _settings_mutation(updates, new_roots):
def mutate(config):
for name, value in updates.items():
*sections, key = _SETTINGS_FILE_KEYS[name]
_set_nested(config, sections, key, value)
section, key = _SETTINGS_FILE_KEYS[name]
merged = _section(config, section)
merged[key] = value
config[section] = merged
if new_roots is not None:
gallery = _section(config, "gallery")
monitoring = _section(gallery, "monitoring")
@@ -132,16 +62,6 @@ def _settings_mutation(updates, new_roots):
return mutate
def _set_nested(config, sections, key, value):
"""``config[s1][s2]...[key] = value``, each section a fresh dict."""
if not sections:
config[key] = value
return
merged = _section(config, sections[0])
_set_nested(merged, sections[1:], key, value)
config[sections[0]] = merged
async def _read_json(request):
"""Parse a JSON object body through the package's capped reader."""
from . import _read_json_body
@@ -161,10 +81,6 @@ class SettingsRoutesMixin:
async def save_settings_route(request):
return await self.save_settings(request)
@routes.post("/prompt_manager/settings/validate-source")
async def validate_source_route(request):
return await self.validate_source(request)
async def get_settings(self, request):
"""Get current settings."""
try:
@@ -174,15 +90,11 @@ class SettingsRoutesMixin:
self._public_path(d) for d in GalleryConfig.MONITORING_DIRECTORIES
]
revision = await self._run_in_executor(self._config_revision)
source_status, monitor = await self._run_in_executor(self._source_status)
return web.json_response(
{
"success": True,
**revision,
"sources": source_status,
"monitor": monitor,
"settings": {
"monitoring_enabled": GalleryConfig.MONITORING_ENABLED,
"result_timeout": PromptManagerConfig.RESULT_TIMEOUT,
"webui_display_mode": PromptManagerConfig.WEBUI_DISPLAY_MODE,
"infinite_scroll": PromptManagerConfig.INFINITE_SCROLL,
@@ -203,61 +115,6 @@ class SettingsRoutesMixin:
status=500,
)
def _source_status(self):
"""``(sources, monitor)`` of GET /settings (blocking)."""
from ..config import GalleryConfig
output_dirs, auto_detected = self._output_dirs_with_origin()
return sources.describe_sources(
configured=list(GalleryConfig.MONITORING_DIRECTORIES),
output_dirs=output_dirs,
auto_detected=auto_detected,
monitor=_current_image_monitor(),
enabled=GalleryConfig.MONITORING_ENABLED,
)
async def validate_source(self, request):
"""Check one source path ``{path}``; writes nothing.
200 ``{success, valid, label, path, code, reason}``: a valid source
answers its label and public path, an invalid one its code and reason
only. A body without a string ``path`` is a 400 ``invalid-request``.
"""
data, error_response = await _read_json(request)
if error_response is not None:
too_large = error_response.status == 413
return web.json_response(
{
"success": False,
"code": "too-large" if too_large else "invalid-request",
"error": (
"Request body too large"
if too_large
else "Request body must be a JSON object"
),
},
status=error_response.status,
)
path = data.get("path")
if not isinstance(path, str):
return web.json_response(
{
"success": False,
"code": "invalid-request",
"error": "path must be a string",
},
status=400,
)
try:
result = await self._run_in_executor(sources.validate_source, path)
except Exception:
self.logger.exception("Source validation failed")
return web.json_response(
{"success": False, "code": None, "error": "Failed to check the source"},
status=500,
)
return web.json_response({"success": True, **result})
def _monitored_directories(self):
"""Directories the running image monitor watches, else the configured roots."""
from ..config import GalleryConfig
@@ -294,15 +151,9 @@ class SettingsRoutesMixin:
{"success": False, "error": str(ve)}, status=400
)
# Off the loop: an accepted root is resolved (filesystem access).
new_roots, error = await self._run_in_executor(
self._parse_gallery_roots, data
)
new_roots, error = self._parse_gallery_roots(data)
if error:
return web.json_response({"success": False, **error}, status=400)
roots_change = new_roots is not None and await self._run_in_executor(
self._roots_change, new_roots
)
return web.json_response({"success": False, "error": error}, status=400)
published = {}
@@ -311,30 +162,15 @@ class SettingsRoutesMixin:
# they were written, so the runtime ends on the last one.
published.update(self._publish_settings(updates, new_roots))
async with contextlib.AsyncExitStack() as stack:
hold = None
if roots_change:
try:
hold = await stack.enter_async_context(
self._jobs.hold(
SOURCE_CHANGE_JOB_KIND,
SOURCE_CHANGE_JOB_TITLE,
admission="records",
)
)
except JobBusy as busy:
return busy_response(busy)
try:
_saved, revision = await self._run_in_executor(
get_config_store().commit,
_settings_mutation(updates, new_roots),
expected_revision=expected_revision,
publish=publish,
)
except (ConfigUnreadable, RevisionConflict, OSError) as exc:
if hold is not None:
hold.finish("failed")
return self._config_commit_failure(exc)
try:
_saved, revision = await self._run_in_executor(
get_config_store().commit,
_settings_mutation(updates, new_roots),
expected_revision=expected_revision,
publish=publish,
)
except (ConfigUnreadable, RevisionConflict, OSError) as exc:
return self._config_commit_failure(exc)
return web.json_response(
{
@@ -360,11 +196,10 @@ class SettingsRoutesMixin:
as ``runtime_applied: false`` with the fields that need a restart;
nothing is retried or rolled back. Never raises.
"""
from ..config import GalleryConfig, PromptManagerConfig
from ..config import PromptManagerConfig
for name, value in updates.items():
owner = GalleryConfig if name in _GALLERY_SETTINGS else PromptManagerConfig
setattr(owner, name, value)
setattr(PromptManagerConfig, name, value)
restart_fields = []
runtime_applied = True
if new_roots is not None:
@@ -377,40 +212,11 @@ class SettingsRoutesMixin:
)
runtime_applied = False
restart_fields.append("gallery_root_paths")
if "MONITORING_ENABLED" in updates:
# The monitor starts with the server: a restart applies the setting
# unless the observer already matches it.
if updates["MONITORING_ENABLED"] != self._observer_alive():
restart_fields.append("monitoring_enabled")
return {
"runtime_applied": runtime_applied,
"restart_required_fields": restart_fields,
}
@staticmethod
def _roots_change(new_roots):
"""Whether a save changes the sources (blocking: it resolves them).
Compared as directories, not spellings: the legacy page resends the
roots resolved, while config.json may hold them as hand-written
(relative, another case), and an unchanged resend is no change.
"""
from ..config import GalleryConfig
if new_roots is None:
return False
return [_root_key(root) for root in new_roots] != [
_root_key(root) for root in GalleryConfig.MONITORING_DIRECTORIES
]
@staticmethod
def _observer_alive():
"""Whether the image monitor's observer is running now. Never raises."""
try:
return sources.monitor_state(_current_image_monitor())[1]
except Exception:
return False
def _config_commit_failure(self, exc):
"""The response for a config commit that wrote nothing."""
not_saved = {"success": False, "persisted": False, "runtime_applied": False}
@@ -477,10 +283,6 @@ class SettingsRoutesMixin:
if not isinstance(data["infinite_scroll"], bool):
raise ValueError("infinite_scroll must be true or false")
updates["INFINITE_SCROLL"] = data["infinite_scroll"]
if "monitoring_enabled" in data:
if not isinstance(data["monitoring_enabled"], bool):
raise ValueError("monitoring_enabled must be true or false")
updates["MONITORING_ENABLED"] = data["monitoring_enabled"]
if "worker_threads" in data:
updates["WORKER_THREADS"] = validate_worker_threads(
data["worker_threads"], PromptManagerConfig.max_worker_threads()
@@ -493,21 +295,15 @@ class SettingsRoutesMixin:
Accepts ``gallery_root_paths`` (list, preferred) or the legacy single
``gallery_root_path`` string. Blank entries are dropped.
Each entry passes the same checks as validate-source
(``sources.source_problem``): the form, then containment as text,
before anything is resolved. Blocking (an accepted root is resolved).
Returns:
(roots, error): ``roots`` is the validated list, or ``None`` when
the payload carries no gallery root key; ``error`` is the coded
body of the 400 when validation failed (``roots`` is then None).
An unusable source is ``invalid-source`` naming its public path
and its position in the list.
the payload carries no gallery root key; ``error`` is a message
when validation failed (and ``roots`` is then ``None``).
"""
if "gallery_root_paths" in data:
raw = data["gallery_root_paths"]
if not isinstance(raw, list):
return None, _request_error("gallery_root_paths must be a list")
return None, "gallery_root_paths must be a list"
elif "gallery_root_path" in data:
raw = [data["gallery_root_path"]]
else:
@@ -516,38 +312,27 @@ class SettingsRoutesMixin:
from ..config import GalleryConfig
roots = []
seen = set()
for index, entry in enumerate(raw):
for entry in raw:
if not isinstance(entry, str):
return None, _request_error("Gallery root paths must be strings")
return None, "Gallery root paths must be strings"
entry = entry.strip()
if not entry:
continue
code, reason = sources.source_problem(entry)
if code is not None:
return None, _invalid_source(index, entry, code, reason)
root = GalleryConfig.resolve_gallery_root(entry)
keys = {("path", _source_identity(root)), ("file", _stat_identity(root))}
keys.discard(("file", None))
if keys & seen:
continue # another spelling of a root already listed
seen |= keys
roots.append(root)
ok, reason = GalleryConfig.validate_gallery_root(entry)
if not ok:
return (
None,
f"Invalid gallery root '{os.path.basename(entry)}': {reason}",
)
roots.append(GalleryConfig.resolve_gallery_root(entry))
return roots, None
def _apply_gallery_roots(self, roots):
"""Install validated gallery roots; returns True when they changed.
Another spelling of the same directories installs the resolved
spelling and is no change (nothing to restart for).
"""
"""Install validated gallery roots; returns True when they changed."""
from ..config import GalleryConfig
if roots == list(GalleryConfig.MONITORING_DIRECTORIES):
return False
if not self._roots_change(roots):
GalleryConfig.MONITORING_DIRECTORIES = roots
return False
GalleryConfig.MONITORING_DIRECTORIES = roots
self._cached_output_dir = None
self.reset_gallery_roots_cache()
-280
View File
@@ -1,280 +0,0 @@
"""Image sources as System › Sources & scans shows them (#249 S4).
- ``source_form_error`` is the lexical check a submitted source path passes
before any filesystem access: no control characters, no ``..`` part, and
only the native path forms (on Windows a drive or a UNC share, never the
``\\\\?\\`` and ``\\\\.\\`` namespaces, a drive-relative path, an alternate
data stream, a device name or a part Windows would silently trim; on POSIX
no Windows drive or backslash form).
- ``source_problem`` is the one refusal every caller answers (validate-source
and the settings save): the form check, then
``GalleryConfig.gallery_root_problem``, which decides containment as text
before it touches the filesystem.
- ``describe_sources`` (blocking: it resolves paths) reports the configured
sources with their validity, the effective sources with their listing
index and watch state, and the image monitor's state. Every path in it is
a public path (``_public_path``), never an absolute one.
A source's watch state is ``watching`` when the monitor's observer is alive,
scheduled that directory and monitoring is on; ``stopping`` when it still
watches it but monitoring was switched off (it stops at the next restart);
``manual`` when monitoring is off or this process has no monitor; else
``restart_pending`` (a source added since the monitor started, monitoring
switched on, or an observer that stopped).
"""
import logging
import ntpath
import os
import re
logger = logging.getLogger("prompt_manager.api")
MAX_SOURCE_PATH = 4096 # characters
FORM_REASONS = {
"empty": "Gallery root must be a non-empty path",
"invalid-form": "Gallery root is not a folder path this server can use",
"traversal": "Gallery root must not contain '..'",
}
_DRIVE = re.compile(r"[A-Za-z]:")
_POSIX_FOREIGN = re.compile(r"^[A-Za-z]:")
# Reserved device names: Windows opens the device for any folder of that name.
_DEVICE_NAME = re.compile(r"^(con|prn|aux|nul|com[0-9¹²³]|lpt[0-9¹²³])(\..*)?$", re.I)
# folder_paths getters and the label of the directory each one reports.
_COMFYUI_LABELS = (
("get_output_directory", "ComfyUI output"),
("get_input_directory", "ComfyUI input"),
("get_temp_directory", "ComfyUI temp"),
("get_user_directory", "ComfyUI user"),
)
WATCH_STATES = ("watching", "stopping", "manual", "restart_pending")
def source_form_error(text, *, windows=None):
"""The form code that refuses ``text`` as a source path, or None.
``windows`` selects the path rules (default: this OS). No filesystem
access happens here.
"""
if windows is None:
windows = os.name == "nt"
if not isinstance(text, str) or len(text) > MAX_SOURCE_PATH:
return "invalid-form"
if any(ord(char) < 32 or ord(char) == 127 for char in text):
return "invalid-form"
text = text.strip()
if not text:
return "empty"
return _windows_form_error(text) if windows else _posix_form_error(text)
def _posix_form_error(text):
if "\\" in text or _POSIX_FOREIGN.match(text):
return "invalid-form" # a Windows path names nothing on this server
return "traversal" if ".." in text.split("/") else None
def _windows_form_error(text):
portable = text.replace("/", "\\")
if portable.startswith(("\\\\?\\", "\\\\.\\")):
return "invalid-form" # verbatim and device namespaces
if portable.startswith("\\\\"):
drive, rest = ntpath.splitdrive(portable)
share = drive[2:].split("\\")
if len(share) != 2 or any(part in ("", ".", "..") for part in share):
return "invalid-form"
else:
drive, rest = ntpath.splitdrive(portable)
if drive and (not _DRIVE.fullmatch(drive) or not rest.startswith("\\")):
return "invalid-form" # "C:folder" is relative to that drive's cwd
if ":" in rest:
return "invalid-form" # an alternate data stream
parts = rest.split("\\")
if ".." in parts:
return "traversal"
for part in parts:
if part in ("", "."):
continue
if part[-1] in ". " or _DEVICE_NAME.match(part):
return "invalid-form"
return None
def source_problem(text):
"""``(code, reason)`` that refuses ``text`` as a source, else ``(None, "")``.
The form check runs first and touches nothing; then containment is
decided as text before any filesystem access (see
``GalleryConfig.gallery_root_problem``). Every caller answers alike.
"""
from ..config import GalleryConfig
code = source_form_error(text)
if code is not None:
return code, FORM_REASONS[code]
return GalleryConfig.gallery_root_problem(text)
def public_spelling(text):
"""How a refused source is named in a response, computed as text only.
Relative to the path anchor it lies under (its public path), else its
last part: the caller sent the path, so naming it reveals nothing, and a
refused path is never resolved to name it.
"""
from ..config import GalleryConfig, _lexical_path, _lexically_inside
text = str(text).strip()
try:
lexical = os.path.normpath(text)
key = _lexical_path(text) # the same length as ``lexical``
if not os.path.isabs(text):
return lexical.replace("\\", "/")
for anchor in GalleryConfig.path_anchors():
if _lexically_inside(key, [anchor]):
rest = lexical[len(anchor.rstrip(os.sep)) :].lstrip(os.sep)
return rest.replace(os.sep, "/") or "."
return os.path.basename(lexical) or lexical
except (TypeError, ValueError):
return ""
def identity(path):
"""The comparison key of a directory: resolved and case-folded (Windows)."""
return os.path.normcase(os.path.realpath(str(path)))
def _comfyui_labels():
"""``{identity: label}`` of the directories ComfyUI reports."""
try:
import folder_paths
except ImportError:
return {}
labels = {}
for getter_name, label in _COMFYUI_LABELS:
getter = getattr(folder_paths, getter_name, None)
if getter is None:
continue
try:
directory = getter()
except Exception:
continue
if isinstance(directory, str) and directory:
labels.setdefault(identity(directory), label)
return labels
def source_label(path, labels=None):
""" "ComfyUI output" for ComfyUI's own directories, else the folder name."""
if labels is None:
labels = _comfyui_labels()
label = labels.get(identity(path))
if label:
return label
return os.path.basename(os.path.normpath(str(path))) or str(path)
def _configured(entry, labels, public_path):
from ..config import GalleryConfig
code, reason = GalleryConfig.gallery_root_problem(entry)
try:
resolved = GalleryConfig.resolve_gallery_root(entry)
except (OSError, TypeError, ValueError):
resolved = str(entry)
return {
"label": source_label(resolved, labels),
"path": public_path(resolved),
"valid": code is None,
"code": code,
"reason": reason or None,
}
def monitor_state(monitor):
"""``(present, observer_alive, scheduled directories)`` of the image monitor."""
if monitor is None:
return False, False, []
status = monitor.get_status()
scheduled = [str(d) for d in status.get("monitored_directories") or []]
return True, bool(status.get("observer_alive")), scheduled
def watch_state(path, *, present, alive, scheduled_keys, enabled):
"""One of ``WATCH_STATES`` (see the module docstring)."""
if alive and identity(path) in scheduled_keys:
return "watching" if enabled else "stopping"
if not present or not enabled:
return "manual"
return "restart_pending"
def describe_sources(*, configured, output_dirs, auto_detected, monitor, enabled):
"""``(sources, monitor)`` for GET /settings (blocking; public paths only).
``configured`` is the configured roots, ``output_dirs`` and
``auto_detected`` what ``_output_dirs_with_origin`` returned, ``monitor``
the running ImageMonitor or None, ``enabled`` the monitoring setting.
"""
from . import _public_path
labels = _comfyui_labels()
present, alive, scheduled = monitor_state(monitor)
scheduled_keys = {identity(path) for path in scheduled}
effective = [
{
"label": source_label(path, labels),
"path": _public_path(path),
"root_index": index,
"watch": watch_state(
path,
present=present,
alive=alive,
scheduled_keys=scheduled_keys,
enabled=enabled,
),
}
for index, path in enumerate(output_dirs)
]
return (
{
"configured": [
_configured(entry, labels, _public_path) for entry in configured
],
"effective": effective,
"auto_detected": bool(auto_detected),
},
{
"present": present,
"observer_alive": alive,
"scheduled": [_public_path(path) for path in scheduled],
},
)
def validate_source(text):
"""``{valid, label, path, code, reason}`` for one submitted path (blocking).
Writes nothing. An invalid path answers its code and reason with no
label or path, so a response never echoes what was typed.
"""
from ..config import GalleryConfig
from . import _public_path
code, reason = source_problem(text)
if code is not None:
return {
"valid": False,
"label": None,
"path": None,
"code": code,
"reason": reason,
}
resolved = GalleryConfig.resolve_gallery_root(text)
return {
"valid": True,
"label": source_label(resolved),
"path": _public_path(resolved),
"code": None,
"reason": None,
}
+2 -15
View File
@@ -40,19 +40,6 @@ _HEALTH_STATUS_LINES = {
_StoredReport = namedtuple("_StoredReport", "generation report")
async def _gather_settled(*reads):
"""Await every read, then raise the first failure.
Unlike a plain gather, a failure never answers while the other executor
reads still run against the database.
"""
results = await asyncio.gather(*reads, return_exceptions=True)
for result in results:
if isinstance(result, BaseException):
raise result
return results
def _failure(message, status=500, code=None):
return web.json_response(
{"success": False, "code": code, "error": message}, status=status
@@ -167,7 +154,7 @@ class SystemRoutesMixin:
from ..config import GalleryConfig
try:
(output_dirs, auto_detected), counts = await _gather_settled(
(output_dirs, auto_detected), counts = await asyncio.gather(
self._run_in_executor(self._output_dirs_with_origin),
self._run_in_executor(self.db.library_counts),
)
@@ -201,7 +188,7 @@ class SystemRoutesMixin:
async def get_system_statistics(self, request):
try:
by_category, totals, counts = await _gather_settled(
by_category, totals, counts = await asyncio.gather(
self._run_in_executor(self.db.usage_by_category),
self._run_in_executor(self.db.get_statistics),
self._run_in_executor(self.db.library_counts),
+25 -121
View File
@@ -31,6 +31,7 @@ routes = server_instance.routes
extension_uri = None # Will be set in __init__.py
import os # noqa: E402
from pathlib import Path # noqa: E402
from typing import Any, Dict, List, Optional, Tuple # noqa: E402
# Import logging system
@@ -50,14 +51,6 @@ config_logger = get_logger("prompt_manager.config")
# may be used as gallery roots in addition to ComfyUI's own directories.
EXTRA_GALLERY_ROOTS_ENV = "PROMPT_MANAGER_EXTRA_GALLERY_ROOTS"
# The one answer for every path outside the allowed folders. The filesystem
# root and the home directory answer it too, so no caller learns the server's
# layout (home path, user name) from a refusal (#249 S4).
OUTSIDE_ALLOWED_REASON = (
"Gallery root must be inside a ComfyUI directory (output, input, temp, user) "
f"or one listed in {EXTRA_GALLERY_ROOTS_ENV}"
)
# Environment variable overriding where config.json is read from and saved to.
CONFIG_PATH_ENV = "PROMPT_MANAGER_CONFIG_PATH"
@@ -96,29 +89,6 @@ def write_private_json(path: str, data: Any) -> None:
os.chmod(path, 0o600)
def _lexical_path(path: str) -> str:
"""``path`` normalised for comparison without touching the filesystem.
normpath collapses ``.`` and ``..`` as text and normcase folds case on
Windows; neither opens, stats nor resolves anything (no SMB request for a
UNC path). A symlink is only seen by :func:`_canonical_path` later.
"""
return os.path.normcase(os.path.normpath(path))
def _lexically_inside(path: str, parents: List[str]) -> bool:
"""Whether the normalised ``path`` is one of ``parents`` or below one.
Pure text on normalised spellings (:func:`_lexical_path` or
:func:`_canonical_path`): no filesystem access.
"""
sep = os.path.sep
return any(
path == parent or path.startswith(parent.rstrip(sep) + sep)
for parent in parents
)
def _is_filesystem_root(path: str) -> bool:
"""True for '/' on POSIX and drive roots such as 'C:\\' on Windows."""
return os.path.dirname(path) == path
@@ -298,12 +268,7 @@ class GalleryConfig:
"""
monitoring = new_config.get("monitoring", {})
if "enabled" in monitoring:
if isinstance(monitoring["enabled"], bool):
cls.MONITORING_ENABLED = monitoring["enabled"]
else: # a hand-edited "false" would otherwise switch it on
config_logger.warning(
"Ignoring gallery monitoring enabled: expected true or false"
)
cls.MONITORING_ENABLED = monitoring["enabled"]
if "directories" in monitoring:
directories = cls._validated_directories(monitoring["directories"])
if directories is not None:
@@ -372,56 +337,31 @@ class GalleryConfig:
Filesystem roots are never allowed as parents, even when listed in
the environment, because that would re-open the whole disk.
"""
return [canonical for canonical, _spelling in cls._allowed_parent_pairs()]
@classmethod
def _allowed_parent_pairs(cls) -> List[Tuple[str, str]]:
"""``(canonical, lexical spelling)`` of each allowed parent.
Only trusted directories (ComfyUI's own and the environment's) are
resolved here; the spelling is how ComfyUI or the environment wrote
the directory, so a submitted path can be compared without resolving it.
"""
pairs = []
seen = set()
parents = []
for candidate in _comfyui_directories() + _extra_gallery_roots():
try:
canonical = _canonical_path(candidate)
spelling = _lexical_path(os.path.abspath(candidate))
except (OSError, ValueError):
continue
if _is_filesystem_root(canonical) or not os.path.isdir(canonical):
continue
if canonical not in seen:
seen.add(canonical)
pairs.append((canonical, spelling))
return pairs
if canonical not in parents:
parents.append(canonical)
return parents
@classmethod
def path_anchors(cls) -> List[str]:
"""Canonical directories that relative gallery paths are resolved against.
The ComfyUI base directory comes first, followed by the parent of each
ComfyUI directory outside it (``--output-directory D:\\Renders``) and of
each directory listed in PROMPT_MANAGER_EXTRA_GALLERY_ROOTS, so such a
directory is addressed by its own name (``Renders/sub``) rather than by
an absolute path, and two folders of one name stay apart. The same
anchors drive the public (relative) form of paths in API responses.
directory listed in PROMPT_MANAGER_EXTRA_GALLERY_ROOTS, so an extra
root is addressed by its own name (``gallery/sub``) rather than by an
absolute path. The same anchors drive the public (relative) form of
paths in API responses.
"""
anchors = []
base = _comfyui_base_directory()
candidates = [base] if base else []
try:
base_key = _canonical_path(base) if base else None
except (OSError, ValueError):
base_key = None
for directory in _comfyui_directories():
try:
canonical = _canonical_path(directory)
except (OSError, ValueError):
continue
if base_key is None or not _lexically_inside(canonical, [base_key]):
candidates.append(os.path.dirname(canonical))
candidates.extend(os.path.dirname(root) for root in _extra_gallery_roots())
for candidate in candidates:
try:
@@ -459,69 +399,33 @@ class GalleryConfig:
(True, "") when the directory lies inside one of
:meth:`allowed_gallery_parents`; otherwise (False, reason).
"""
code, reason = cls.gallery_root_problem(path)
return code is None, reason
@classmethod
def gallery_root_problem(cls, path: Any) -> Tuple[Optional[str], str]:
"""``(code, reason)`` why ``path`` may not be a gallery root, else (None, "").
Codes: ``empty``, ``unresolvable``, ``outside-allowed``, ``not-found``
and ``not-directory``. No reason names the path.
Containment is decided twice. First lexically, with no filesystem
access at all: a path (UNC, ``\\\\?\\``, a device, anything) whose
normalised spelling lies outside every allowed parent answers
``outside-allowed`` before it is resolved, so the server never opens
or stats it (no SMB connection, no existence or timing oracle). Only
then is it resolved and checked again, which catches a symlink out.
The filesystem root and the home directory answer ``outside-allowed``
too: one answer for every caller (validate-source, the settings save,
config.json), and the detail goes to the log only.
"""
if not isinstance(path, str) or not path.strip():
return "empty", "Gallery root must be a non-empty path"
pairs = cls._allowed_parent_pairs()
spellings = [spelling for pair in pairs for spelling in pair]
try:
lexical = cls._lexical_candidates(path)
except (TypeError, ValueError):
return "unresolvable", "Gallery root could not be resolved"
if not any(_lexically_inside(candidate, spellings) for candidate in lexical):
return "outside-allowed", OUTSIDE_ALLOWED_REASON
return False, "Gallery root must be a non-empty path"
try:
canonical = cls.resolve_gallery_root(path)
except (OSError, ValueError):
return "unresolvable", "Gallery root could not be resolved"
return False, "Gallery root could not be resolved"
if _is_filesystem_root(canonical):
config_logger.info("Gallery root refused: a filesystem root")
return "outside-allowed", OUTSIDE_ALLOWED_REASON
return False, "Gallery root cannot be a filesystem root"
if canonical == _canonical_path(os.path.expanduser("~")):
config_logger.info("Gallery root refused: the home directory")
return "outside-allowed", OUTSIDE_ALLOWED_REASON
if not _lexically_inside(canonical, [canonical for canonical, _ in pairs]):
return "outside-allowed", OUTSIDE_ALLOWED_REASON
return False, "Gallery root cannot be the home directory"
if not os.path.exists(canonical):
return "not-found", "Gallery root does not exist"
return False, "Gallery root does not exist"
if not os.path.isdir(canonical):
return "not-directory", "Gallery root is not a directory"
return None, ""
return False, "Gallery root is not a directory"
@classmethod
def _lexical_candidates(cls, path: str) -> List[str]:
"""Every spelling ``path`` may resolve to, normalised as text (no IO).
candidate = Path(canonical)
for parent in cls.allowed_gallery_parents():
if candidate.is_relative_to(Path(parent)):
return True, ""
An absolute path is itself; a relative one is joined to each
:meth:`path_anchors` entry, as :meth:`resolve_gallery_root` tries them.
"""
path = path.strip()
if os.path.isabs(path):
return [_lexical_path(path)]
anchors = cls.path_anchors()
return [_lexical_path(os.path.join(anchor, path)) for anchor in anchors]
return (
False,
"Gallery root must be inside a ComfyUI directory "
f"(output, input, temp, user) or one listed in {EXTRA_GALLERY_ROOTS_ENV}",
)
class IntegrationConfig:
-368
View File
@@ -408,371 +408,3 @@ for (const theme of THEMES) test(`statistics and health dialogs match the concep
await page.evaluate(() => { document.querySelector('#modal-body caption').textContent = 'Runs per category'; });
expect(diffInventory(expectedStatistics, await page.evaluate(measureDialog)).map(item => item.path)).toEqual(['tables.0.caption']);
});
/** Runs in the page: Appearance's surface, every row's text and control, and the concept's row geometry and tokens. */
function measureAppearance(mainSelector) {
const layout = document.querySelector(`${mainSelector} .settings-layout`);
const css = (node, ...keys) => node ? Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]])) : null;
const name = node => [node.localName, ...[...node.classList].sort()].join('.');
const text = node => node?.textContent.trim() ?? null;
const surface = layout.querySelector(':scope > section.surface');
const control = row => {
const select = row.querySelector(':scope > select');
if (select) return { kind: 'select', id: select.id, options: [...select.options].map(option => option.textContent),
selected: select.selectedOptions[0]?.textContent ?? null };
const input = row.querySelector(':scope > input');
if (input) return { kind: `input.${input.type}`, id: input.id, value: input.value };
const toggle = row.querySelector(':scope > * input[type="checkbox"]');
if (toggle) return { kind: 'switch', id: toggle.id, wrapper: name(toggle.parentElement), checked: toggle.checked,
...css(toggle.parentElement, 'width', 'height') };
const chip = row.querySelector(':scope > .chip');
return chip ? { kind: name(chip), text: text(chip), ...css(chip, 'color', 'backgroundColor', 'fontSize') } : null;
};
const rows = [...surface.querySelectorAll(':scope > .setting-row')];
const operations = [...surface.querySelectorAll(':scope > .operation')];
const firstRow = rows[0];
return {
layout: [...layout.children].map(name),
surface: { tree: [...surface.children].map(name), heading: text(surface.querySelector(':scope > h2')),
intro: text(surface.querySelector(':scope > p')),
...css(surface, 'paddingTop', 'paddingLeft', 'backgroundColor', 'borderTopWidth', 'borderTopColor', 'borderTopLeftRadius') },
rows: rows.map(row => ({ tree: [...row.children].map(name), text: [...row.firstElementChild.children].map(node => [node.localName, text(node),
node.localName === 'label' ? node.htmlFor : null]), control: control(row) })),
operations: operations.map(row => ({ tree: [...row.children].map(name), title: text(row.querySelector('h3')),
description: text(row.querySelector('p')), action: text(row.querySelector('.row-actions > button')) })),
row: css(firstRow, 'display', 'alignItems', 'justifyContent', 'columnGap', 'paddingTop', 'paddingBottom', 'borderTopWidth',
'borderTopColor', 'marginTop'),
label: css(firstRow.querySelector('label'), 'fontSize', 'fontWeight', 'color'),
description: css(firstRow.querySelector('p'), 'fontSize', 'color', 'marginTop', 'maxWidth'),
select: css(firstRow.querySelector('select'), 'fontSize', 'minWidth', 'maxWidth', 'backgroundColor', 'color', 'borderTopColor'),
heading3: css(surface.querySelector('.setting-row h3'), 'fontSize', 'fontWeight', 'color'),
number: css(surface.querySelector('.setting-row input[type="number"]'), 'fontSize', 'minWidth', 'maxWidth', 'backgroundColor'),
pageScroll: document.documentElement.scrollWidth > document.documentElement.clientWidth,
};
}
// D97: the rows Appearance adds for the legacy gallery settings, after the concept's own, as production renders them.
const ADDED_APPEARANCE_ROWS = [
['card-details', 'Show image details on cards', 'Show each card’s title and file details under the preview.', true],
['viewer-details', 'Collapse viewer details by default', 'Open the image viewer with its details panel hidden.', false],
];
/** The concept's Appearance with the recorded deviations applied, given the server's real settings. */
function mapAppearanceConcept(reference, settings) {
const toggleRow = reference.rows.find(row => row.control?.kind === 'switch');
const added = ADDED_APPEARANCE_ROWS.map(([id, label, description, checked]) => ({
tree: toggleRow.tree, text: [['label', label, id], ['p', description, null]],
control: { ...toggleRow.control, id, checked } }));
return {
...reference,
// D97: two added toggles and the "Reset display defaults" operation close the surface.
surface: { ...reference.surface, tree: [...reference.surface.tree, 'div.setting-row', 'div.setting-row', 'div.operation'] },
rows: [...reference.rows.map(row => row.control?.id === 'timeout'
// The node search timeout is the server's real `result_timeout`, not the concept's sample 30.
? { ...row, control: { ...row.control, value: String(settings.result_timeout) } } : row), ...added],
operations: [{ tree: ['div', 'div.row-actions'], title: 'Reset display defaults',
description: 'Restore this browser’s display defaults, then save. Other settings are kept.', action: 'Reset' }],
};
}
async function conceptAppearance(page, theme) {
await openConcept(page, 'system', theme);
await page.locator('#main .section-nav').getByRole('button', { name: 'Appearance', exact: true }).click();
await expect(page.locator('#main .settings-layout > section.surface > h2')).toHaveText('Browsing & display');
await page.mouse.move(0, 0);
return page.evaluate(measureAppearance, '#main');
}
async function productionAppearance(page, theme) {
await openProduction(page, 'system/appearance', theme);
await expect(page.locator('#workspace-main #timeout')).toBeEnabled();
await page.mouse.move(0, 0);
return page.evaluate(measureAppearance, '#workspace-main');
}
for (const theme of THEMES) for (const [width, height] of SIZES) {
test(`appearance matches the concept ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => {
await page.setViewportSize({ width, height });
const { settings } = await serverJson(request, '/prompt_manager/settings');
const concept = await conceptAppearance(page, theme);
await page.screenshot({ path: testInfo.outputPath(`concept-appearance-${theme}-${width}.png`), fullPage: true });
const production = await productionAppearance(page, theme);
await page.screenshot({ path: testInfo.outputPath(`production-appearance-${theme}-${width}.png`), fullPage: true });
expect(diffInventory(mapAppearanceConcept(concept, settings), production)).toEqual([]);
expect(production.pageScroll).toBe(false);
});
}
test('appearance comparison detects mutations', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const { settings } = await serverJson(request, '/prompt_manager/settings');
const concept = await conceptAppearance(page, 'dark');
const expected = mapAppearanceConcept(concept, settings);
const production = await productionAppearance(page, 'dark');
expect(diffInventory(expected, production)).toEqual([]);
// Unmapped, the concept differs only where D97 and the real timeout are recorded.
expect(diffInventory(concept, production).map(item => item.path.split('.').slice(0, 2).join('.'))
.filter((path, index, all) => all.indexOf(path) === index).sort())
.toEqual(['operations.0', 'operations.length', 'rows.11', 'rows.12', 'rows.13', 'rows.length', 'surface.tree'].sort());
const measure = () => page.evaluate(measureAppearance, '#workspace-main');
const paths = async () => diffInventory(expected, await measure()).map(item => item.path);
// A removed row.
const undoRow = await mutate(page, { remove: ['#workspace-main .setting-row:nth-of-type(3)'] });
expect(await paths()).toEqual(expect.arrayContaining(['rows.length', 'surface.tree.length']));
await undoRow();
// A changed label and a changed option.
await page.evaluate(() => { document.querySelector('#workspace-main label[for="density"]').textContent = 'Thumbnail sizes'; });
expect(await paths()).toEqual(['rows.1.text.0.1']);
await page.evaluate(() => { document.querySelector('#workspace-main label[for="density"]').textContent = 'Thumbnail size'; });
await page.evaluate(() => { document.querySelector('#workspace-main #image-quality option').textContent = 'Medium'; });
expect(await paths()).toEqual(expect.arrayContaining(['rows.5.control.options.0']));
await page.evaluate(() => { document.querySelector('#workspace-main #image-quality option').textContent = 'Optimized'; });
// A swapped token.
const undoToken = await mutate(page, { css: '#workspace-main .settings-layout { --line: rgb(255, 0, 0); }' });
expect(await paths()).toEqual(expect.arrayContaining(['row.borderTopColor']));
await undoToken();
expect(diffInventory(expected, await measure())).toEqual([]);
});
/**
* Runs in the page: Sources & scans (app.js 679–681): the optional notice, then the "Image sources" surface with its
* panel heading, the source list, the two setting rows and the operations, with the concept's geometry and tokens.
*/
function measureSources(mainSelector) {
const layout = document.querySelector(`${mainSelector} .settings-layout`);
const css = (node, ...keys) => node ? Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]])) : null;
const name = node => [node.localName, ...[...node.classList].sort()].join('.');
const text = node => node?.textContent.trim() ?? null;
const button = node => ({ text: text(node), className: node.className,
...css(node, 'backgroundColor', 'color', 'fontSize', 'paddingTop', 'paddingLeft', 'borderTopColor') });
const operation = row => ({ tree: [...row.children].map(name), title: text(row.querySelector('h3')),
description: text(row.querySelector('p')), actions: [...row.querySelectorAll('.row-actions > button')].map(button) });
const notice = layout.querySelector(':scope > .notice');
const surface = layout.querySelector(':scope > section.surface');
const heading = surface.querySelector(':scope > .panel-heading');
const list = surface.querySelector(':scope > #source-list');
const control = row => {
const toggle = row.querySelector(':scope > * input[type="checkbox"]');
if (toggle) return { kind: 'switch', id: toggle.id, wrapper: name(toggle.parentElement), checked: toggle.checked,
...css(toggle.parentElement, 'width', 'height') };
const input = row.querySelector(':scope > input');
return { kind: `input.${input.type}`, id: input.id, min: input.min, max: input.max, value: input.value,
...css(input, 'width', 'accentColor') };
};
const rows = [...surface.querySelectorAll(':scope > .setting-row')];
return {
layout: [...layout.children].map(name),
notice: notice && { tree: [...notice.children].map(name),
text: [...notice.querySelectorAll(':scope > div > *')].map(node => [node.localName, text(node)]),
actions: [...notice.querySelectorAll(':scope > .row-actions > button')].map(button),
...css(notice, 'display', 'alignItems', 'columnGap', 'borderTopColor', 'backgroundColor', 'paddingTop', 'marginBottom') },
surface: { tree: [...surface.children].map(name), intro: text(surface.querySelector(':scope > p')),
...css(surface, 'paddingTop', 'paddingLeft', 'backgroundColor', 'borderTopColor', 'borderTopLeftRadius') },
heading: { tree: [...heading.children].map(name), title: text(heading.querySelector('h2')),
action: button(heading.querySelector('button')), ...css(heading, 'display', 'alignItems', 'justifyContent', 'marginBottom'),
h2: css(heading.querySelector('h2'), 'fontSize', 'fontWeight', 'color') },
sources: [...list.children].map(operation),
rows: rows.map(row => ({ tree: [...row.children].map(name), text: [...row.firstElementChild.children].map(node => [node.localName, text(node),
node.localName === 'label' ? node.htmlFor : null]), control: control(row) })),
operations: [...surface.querySelectorAll(':scope > .operation')].map(operation),
operation: css(list.querySelector('.operation'), 'display', 'alignItems', 'justifyContent', 'columnGap', 'paddingTop',
'paddingBottom', 'borderTopWidth', 'borderTopColor'),
h3: css(list.querySelector('.operation h3'), 'fontSize', 'fontWeight', 'color'),
p: css(list.querySelector('.operation p'), 'fontSize', 'color', 'marginTop'),
row: css(rows[0], 'display', 'alignItems', 'justifyContent', 'columnGap', 'paddingTop', 'paddingBottom', 'borderTopColor'),
pageScroll: document.documentElement.scrollWidth > document.documentElement.clientWidth,
};
}
// D128: "watching until restart" after monitoring was switched off while the observer still runs.
const WATCH_TEXT = { watching: 'watching', stopping: 'watching until restart', manual: 'manual scan', restart_pending: 'restart pending' };
const WATCHED = new Set(['watching', 'stopping']);
// D98: the operation Sources adds after the concept's own, as production renders it.
const REFRESH_FILES = { title: 'Refresh files', description: 'Read every source again to find files added or removed outside ComfyUI.',
label: 'Refresh' };
/** 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];
return {
...reference,
// The real sources replace the two samples (without the "sample path" note). D98: each configured one has Remove,
// a plain button after its scan.
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 } };
// 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) } };
}),
// D98: "Refresh files" closes the surface.
surface: { ...reference.surface, tree: [...reference.surface.tree, '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 }] }],
};
}
async function conceptSources(page, theme, { notice }) {
await openConcept(page, 'system', theme);
await page.locator('#main .section-nav').getByRole('button', { name: 'Sources & scans', exact: true }).click();
await expect(page.locator('#main .panel-heading > h2')).toHaveText('Image sources');
if (!notice) await page.locator('#main .notice').getByRole('button', { name: 'Later', exact: true }).click();
await expect(page.locator('#main .settings-layout > .notice')).toHaveCount(notice ? 1 : 0);
await page.mouse.move(0, 0);
return page.evaluate(measureSources, '#main');
}
async function productionSources(page, theme, { notice }) {
// about:blank has no storage; the workspace page reads the dismissal.
if (!notice) await page.addInitScript(() => { if (location.protocol.startsWith('http')) localStorage.setItem('pm_update_notice_dismissed', '3.2.0'); });
await openProduction(page, 'system/sources', theme);
await expect(page.locator('#workspace-main #source-list .operation')).toHaveCount(2);
await expect(page.locator('#workspace-main #workers')).toBeEnabled();
await expect(page.locator('#workspace-main .settings-layout > .notice')).toHaveCount(notice ? 1 : 0);
await page.mouse.move(0, 0);
return page.evaluate(measureSources, '#workspace-main');
}
/** The fixture's sources as the concept shows its samples: the first one watched, the second not. */
async function watchedSources(request) {
await expect(await request.post('/__test__/system/monitor', { data: { watching: ['output'] } })).toBeOK();
return serverJson(request, '/prompt_manager/settings');
}
for (const theme of THEMES) for (const [width, height] of SIZES) for (const notice of [true, false]) {
const variant = notice ? 'with' : 'without';
test(`sources matches the concept ${variant} the notice ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => {
await page.setViewportSize({ width, height });
const server = await watchedSources(request);
const concept = await conceptSources(page, theme, { notice });
await page.screenshot({ path: testInfo.outputPath(`concept-sources-${variant}-notice-${theme}-${width}.png`), fullPage: true });
const production = await productionSources(page, theme, { notice });
await page.screenshot({ path: testInfo.outputPath(`production-sources-${variant}-notice-${theme}-${width}.png`), fullPage: true });
expect(diffInventory(mapSourcesConcept(concept, server), production)).toEqual([]);
expect(production.pageScroll).toBe(false);
});
}
test('sources comparison detects mutations', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const server = await watchedSources(request);
const concept = await conceptSources(page, 'dark', { notice: true });
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.
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());
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.
const undoNotice = await mutate(page, { remove: ['#workspace-main .settings-layout > .notice'] });
expect(await paths()).toEqual(expect.arrayContaining(['layout.length', 'notice.tree.length']));
await undoNotice();
const undoRow = await mutate(page, { remove: ['#workspace-main #source-list > .operation:nth-child(2)'] });
expect(await paths()).toEqual(expect.arrayContaining(['sources.length']));
await undoRow();
// A changed label and a changed button.
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'; });
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'; });
// A swapped token.
const undoToken = await mutate(page, { css: '#workspace-main .settings-layout { --line: rgb(255, 0, 0); }' });
expect(await paths()).toEqual(expect.arrayContaining(['operation.borderTopColor']));
await undoToken();
expect(diffInventory(expected, await measure())).toEqual([]);
});
/**
* Runs in the page: the open "Add image source" dialog (the concept's `simpleInput`, app.js 1138 and 1497): its title,
* the body's tree, the field's label and input, and the actions, with the concept's geometry and tokens.
*/
function measureAddSource() {
const dialog = document.querySelector('dialog#modal[open]');
const css = (node, ...keys) => node ? Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]])) : null;
const name = node => [node.localName, ...[...node.classList].sort()].join('.');
const text = node => node?.textContent.trim() ?? null;
const body = dialog.querySelector('#modal-body');
const field = body.querySelector(':scope > .field');
const label = field?.querySelector(':scope > label');
const input = field?.querySelector(':scope > input');
const actions = body.querySelector(':scope > .dialog-actions');
const rect = dialog.getBoundingClientRect();
return {
title: text(dialog.querySelector('#modal-title')),
tree: [...body.children].map(name),
field: field && { tree: [...field.children].map(name), ...css(field, 'display', 'flexDirection', 'rowGap', 'marginBottom') },
label: label && { text: text(label), for: label.htmlFor, ...css(label, 'fontSize', 'fontWeight', 'color') },
input: input && { id: input.id, type: input.type, value: input.value,
...css(input, 'height', 'paddingLeft', 'fontSize', 'color', 'backgroundColor', 'borderTopColor', 'borderTopLeftRadius') },
actions: { tree: [...actions.children].map(name), ...css(actions, 'display', 'justifyContent', 'columnGap', 'marginTop'),
buttons: [...actions.children].map(button => ({ text: text(button), className: button.className,
...css(button, 'backgroundColor', 'color', 'fontSize', 'paddingTop', 'paddingLeft', 'borderTopColor') })) },
width: Math.round(rect.width),
};
}
/** D127: the field is "Folder path", starts empty (no sample folder) and its action is "Add". */
function mapAddSourceConcept(reference) {
const [cancel, apply] = reference.actions.buttons;
return { ...reference, label: { ...reference.label, text: 'Folder path' }, input: { ...reference.input, value: '' },
actions: { ...reference.actions, buttons: [cancel, { ...apply, text: 'Add' }] } };
}
async function conceptAddSource(page, theme) {
await conceptSources(page, theme, { notice: false });
await page.locator('#main .panel-heading').getByRole('button', { name: '+ Add source', exact: true }).click();
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Add image source');
await page.mouse.move(0, 0);
return page.evaluate(measureAddSource);
}
async function productionAddSource(page, theme) {
await productionSources(page, theme, { notice: false });
await page.locator('#workspace-main .panel-heading').getByRole('button', { name: '+ Add source', exact: true }).click();
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Add image source');
await page.mouse.move(0, 0);
return page.evaluate(measureAddSource);
}
for (const theme of THEMES) for (const [width, height] of SIZES) {
test(`add source dialog matches the concept ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => {
await page.setViewportSize({ width, height });
await watchedSources(request);
const concept = await conceptAddSource(page, theme);
await page.screenshot({ path: testInfo.outputPath(`concept-add-source-${theme}-${width}.png`) });
const production = await productionAddSource(page, theme);
await page.screenshot({ path: testInfo.outputPath(`production-add-source-${theme}-${width}.png`) });
expect(diffInventory(mapAddSourceConcept(concept), production)).toEqual([]);
});
}
test('add source comparison detects mutations', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
await watchedSources(request);
const concept = await conceptAddSource(page, 'dark');
const expected = mapAddSourceConcept(concept);
const production = await productionAddSource(page, 'dark');
expect(diffInventory(expected, production)).toEqual([]);
// Unmapped, the concept differs only where D127 is recorded.
expect(diffInventory(concept, production).map(item => item.path).sort())
.toEqual(['actions.buttons.1.text', 'input.value', 'label.text']);
const paths = async () => diffInventory(expected, await page.evaluate(measureAddSource)).map(item => item.path);
// A changed label, a swapped action order and a changed style.
await page.evaluate(() => { document.querySelector('dialog#modal[open] label[for="action-input"]').textContent = 'Folder'; });
expect(await paths()).toEqual(['label.text']);
await page.evaluate(() => { document.querySelector('dialog#modal[open] label[for="action-input"]').textContent = 'Folder path'; });
await page.evaluate(() => { const actions = document.querySelector('dialog#modal[open] .dialog-actions'); actions.prepend(actions.lastElementChild); });
expect(await paths()).toEqual(expect.arrayContaining(['actions.buttons.0.text', 'actions.buttons.1.text']));
await page.evaluate(() => { const actions = document.querySelector('dialog#modal[open] .dialog-actions'); actions.prepend(actions.lastElementChild); });
const undoStyle = await mutate(page, { css: 'dialog#modal #action-input { border-color: rgb(255, 0, 0); } dialog#modal .dialog-actions { gap: 31px; }' });
expect(await paths()).toEqual(expect.arrayContaining(['input.borderTopColor', 'actions.columnGap']));
await undoStyle();
expect(diffInventory(expected, await page.evaluate(measureAddSource))).toEqual([]);
});
+24 -76
View File
@@ -354,48 +354,39 @@ test('prompt deletion requires confirmation and can retry after a network failur
}
});
// The duplicate scan reads the output root only, but the fixture's input root holds a different
// shared/same.png. A bare /images/serve/<path> URL tries every gallery root in turn, so once the
// output copy is deleted it answers 200 with the input copy: check each file in its own root.
const serveIn = (root, path) => `/prompt_manager/images/serve/${path.split(/[\\/]/).map(encodeURIComponent).join('/')}?root=${root}`;
const rootOf = url => new URL(url, 'http://gallery.test').searchParams.get('root');
// Which copy the scan keeps depends on file times: the fixture stamps shared/same.png with the
// wall clock, and the kernel can stamp its bulk copies a fraction of a millisecond earlier.
// listOrder rewrites the finished scan's groups so a test can pick the order it needs.
async function openDuplicateScan(page, request, listOrder = null) {
test('duplicate cleanup cancels safely and deletes only the selected file after a retry', async ({ page, request }) => {
await expect(await request.post('/__test__/reset', { data: { extra_images: 2 } })).toBeOK();
if (listOrder) {
await page.route('**/prompt_manager/scan_duplicates/stream', async route => {
const response = await route.fetch();
const body = (await response.text()).replace(/^data: (.*)$/gm, (line, json) => {
const event = JSON.parse(json);
if (event.type !== 'complete') return line;
for (const group of event.duplicates) group.images = listOrder(group.images);
return `data: ${JSON.stringify(event)}`;
});
await route.fulfill({ response, body });
});
}
await page.goto('/prompt_manager/gallery');
await expect(page.locator('#galleryGrid .image-item')).toHaveCount(4);
const originals = await page.locator('#galleryGrid img[data-original]').evaluateAll(images => images.map(image => image.dataset.original));
const output = rootOf(originals.find(url => url.includes('/bulk/')));
const input = rootOf(originals.find(url => url.includes('/shared/same.png') && rootOf(url) !== output));
expect(output).not.toBeNull();
expect(input).not.toBeNull();
await page.locator('#settingsBtn').click();
await page.locator('#scanDuplicatesBtn').click();
await expect(page.locator('#duplicatesCount')).toHaveText('1');
const candidates = page.locator('.duplicate-checkbox');
await expect(candidates).toHaveCount(2);
return { originals, output, input, candidates };
}
async function expectOnlySelectedGone(page, request, { originals, output, input }, selected, retained) {
expect((await request.get(serveIn(output, selected))).status()).toBe(404);
await expect(await request.get(serveIn(output, retained))).toBeOK();
await expect(await request.get(serveIn(input, 'shared/same.png'))).toBeOK();
const selected = await candidates.first().getAttribute('data-image-path');
const retained = await candidates.last().getAttribute('data-image-path');
const serve = path => `/prompt_manager/images/serve/${path.split(/[\\/]/).map(encodeURIComponent).join('/')}`;
await candidates.first().check();
let deletes = 0;
page.on('request', request => { if (request.url().endsWith('/delete_duplicate_images')) deletes++; });
confirmNextDialog(page, false, 'permanently delete 1 duplicate image files');
await page.locator('#removeDuplicatesBtn').click();
expect(deletes).toBe(0);
await expect(await request.get(serve(selected))).toBeOK();
await page.route('**/prompt_manager/delete_duplicate_images', route => route.abort('failed'), { times: 1 });
confirmNextDialog(page, true, 'permanently delete 1 duplicate image files');
await page.locator('#removeDuplicatesBtn').click();
await expect(page.getByText('Failed to delete duplicate images', { exact: true })).toBeVisible();
await expect(page.locator('#removeDuplicatesBtn')).toBeEnabled();
await expect(await request.get(serve(selected))).toBeOK();
confirmNextDialog(page, true, 'permanently delete 1 duplicate image files');
const deleted = page.waitForResponse(response => response.url().endsWith('/delete_duplicate_images'));
await page.locator('#removeDuplicatesBtn').click();
expect((await (await deleted).json())).toMatchObject({ success: true, deleted_count: 1, failed_count: 0 });
await expect(page.locator('#duplicatesModal')).toBeHidden();
expect((await request.get(serve(selected))).status()).toBe(404);
await expect(await request.get(serve(retained))).toBeOK();
await page.reload();
await expect(page.locator('#galleryGrid .image-item')).toHaveCount(3);
const remaining = await page.locator('#galleryGrid img[data-original]').evaluateAll(images => images.map(image => image.dataset.original));
@@ -404,47 +395,4 @@ async function expectOnlySelectedGone(page, request, { originals, output, input
expect(originals).toContain(url);
await expect(await request.get(url)).toBeOK();
}
}
test('duplicate cleanup cancels safely and deletes only the selected file after a retry', async ({ page, request }) => {
const scan = await openDuplicateScan(page, request);
const { output, candidates } = scan;
const selected = await candidates.first().getAttribute('data-image-path');
const retained = await candidates.last().getAttribute('data-image-path');
await candidates.first().check();
let deletes = 0;
page.on('request', request => { if (request.url().endsWith('/delete_duplicate_images')) deletes++; });
confirmNextDialog(page, false, 'permanently delete 1 duplicate image files');
await page.locator('#removeDuplicatesBtn').click();
expect(deletes).toBe(0);
await expect(await request.get(serveIn(output, selected))).toBeOK();
await page.route('**/prompt_manager/delete_duplicate_images', route => route.abort('failed'), { times: 1 });
confirmNextDialog(page, true, 'permanently delete 1 duplicate image files');
await page.locator('#removeDuplicatesBtn').click();
await expect(page.getByText('Failed to delete duplicate images', { exact: true })).toBeVisible();
await expect(page.locator('#removeDuplicatesBtn')).toBeEnabled();
await expect(await request.get(serveIn(output, selected))).toBeOK();
confirmNextDialog(page, true, 'permanently delete 1 duplicate image files');
const deleted = page.waitForResponse(response => response.url().endsWith('/delete_duplicate_images'));
await page.locator('#removeDuplicatesBtn').click();
expect((await (await deleted).json())).toMatchObject({ success: true, deleted_count: 1, failed_count: 0 });
await expect(page.locator('#duplicatesModal')).toBeHidden();
await expectOnlySelectedGone(page, request, scan, selected, retained);
});
test('deleting a duplicate whose path another gallery root also holds removes only the scanned copy', async ({ page, request }) => {
// The order CI saw: a bulk copy stamped first, so shared/same.png is the first Delete candidate.
const ciOrder = ['bulk/0000.png', 'shared/same.png', 'bulk/0001.png'];
const rank = image => ciOrder.indexOf(image.path.replace(/\\/g, '/'));
const scan = await openDuplicateScan(page, request, images => [...images].sort((a, b) => rank(a) - rank(b)));
const selected = await scan.candidates.first().getAttribute('data-image-path');
const retained = await scan.candidates.last().getAttribute('data-image-path');
expect(selected.replace(/\\/g, '/')).toBe('shared/same.png');
await scan.candidates.first().check();
confirmNextDialog(page, true, 'permanently delete 1 duplicate image files');
const deleted = page.waitForResponse(response => response.url().endsWith('/delete_duplicate_images'));
await page.locator('#removeDuplicatesBtn').click();
expect((await (await deleted).json())).toMatchObject({ success: true, deleted_count: 1, failed_count: 0 });
await expect(page.locator('#duplicatesModal')).toBeHidden();
await expectOnlySelectedGone(page, request, scan, selected, retained);
});
-49
View File
@@ -284,19 +284,6 @@ test('reload during a scan reattaches to the running job', async ({ page, reques
await expect(pill(page)).toBeHidden();
});
test('an empty Activity says no job has run, and a list that cannot be read says so', async ({ page }) => {
// The empty and failed states were only reached in passing by other tests, so their coverage depended on timing.
const empty = main(page).locator('section.surface > p.empty-state');
await page.goto(`${workspace}#/activity`);
await expect(empty).toHaveText('No jobs have run since ComfyUI started. Scans and other long operations appear here.');
await expect(empty).not.toHaveAttribute('role');
await expect(items(page)).toHaveCount(0);
await page.route(url => url.pathname === '/prompt_manager/jobs', route => route.abort());
await page.reload();
await expect(empty).toHaveText('Activity could not be read. It is tried again shortly.');
await expect(empty).toHaveAttribute('role', 'alert');
});
test('lost server clears the pill and marks jobs ended', async ({ page, request }) => {
// A poll interval after the restart, then failed re-reads backing off for 1 + 2 + 4 s.
test.setTimeout(90_000);
@@ -737,42 +724,6 @@ test('a held legacy job shows no Cancel in the list or its view', async ({ page,
await expect(heading(page).locator('.heading-actions > button')).toHaveText(['Download report']);
});
// Since #289 an item dropped its Cancel job only when it was already in the page, so the first render of jobs the
// store already knew (Activity opened from another page) kept a Cancel job on a job that had ended, and moved a
// running job's progress after its actions. The read Activity starts on opening is held, so that first render is
// what the page shows (the read's own render would otherwise repair it a moment later).
test('jobs that ended before Activity opened show no Cancel job, a running one keeps its progress first', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const finished = await startJob(request, { title: 'Finished scan' });
await finish(request, finished.id);
const cancelled = await startJob(request, { title: 'Cancelled scan' });
await finish(request, cancelled.id, 'cancelled');
await startJob(request, { title: 'Running scan' });
// The page's first list read has all three jobs once the running one shows in the pill.
await page.goto(`${workspace}#/images`);
await expect(pill(page)).toBeVisible();
let hold;
const held = new Promise(resolve => { hold = resolve; });
// Only the first read is held; any later one goes through.
await page.route(url => url.pathname === '/prompt_manager/jobs', route => {
if (!hold) return route.continue();
hold(route);
hold = null;
});
await activityButton(page).click();
const route = await held;
await expect(items(page)).toHaveCount(3);
const item = title => items(page).filter({ has: page.getByRole('link', { name: new RegExp(`${title}$`) }) });
for (const ended of ['Finished scan', 'Cancelled scan']) {
await expect(item(ended).locator('progress, .row-actions')).toHaveCount(0);
await expect(item(ended).getByRole('button', { name: 'Cancel job', exact: true })).toHaveCount(0);
}
await expect(item('Running scan').getByRole('button', { name: 'Cancel job', exact: true })).toHaveCount(1);
expect(await item('Running scan').evaluate(node => [...node.children].map(child => child.localName + (child.className ? `.${child.className}` : ''))))
.toEqual(['div.panel-heading', 'p', 'progress', 'div.row-actions']);
await route.continue();
});
test('busy start names the running job', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
// The page's timers are Playwright's, so the toast's lifetime below is run out rather than waited for.
-44
View File
@@ -463,50 +463,6 @@ test('collection viewer ignores delayed media callbacks and keeps inspection ret
await page.evaluate(()=>{location.hash='/overview';});await expect(page.locator('.viewer-container,#viewer-dialog')).toHaveCount(0);
});
test('collection viewer leaving an image Viewer.js has not built yet releases it at once',async({page})=>{
await page.goto(workspace);await expect(cards(page)).toHaveCount(2);
let release,held;const waiting=new Promise(resolve=>{held=resolve;});const pending=new Promise(resolve=>{release=resolve;});
await page.route('**/images/serve/shared/same.png?*',async route=>{
const url=new URL(route.request().url());
if(url.searchParams.get('root')==='0'&&url.searchParams.has('_pm_viewer')) {held();await pending;}
await route.continue().catch(()=>{});
});
await cards(page).first().getByRole('button',{name:'Open same.png',exact:true}).click();await waiting;
const preview=page.locator('dialog#viewer-dialog');
await page.evaluate(()=>{window.unbuiltImage=document.querySelector('#viewer-stage > img');});
await preview.getByRole('button',{name:'Next media',exact:true}).click();
await expect(preview.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
// The left image is off the stage. Viewer.js has not built it, so the viewer is destroyed and the src released at
// once: its late load must never build Viewer.js into an image without a parent (getComputedStyle(null)).
release();
await expect.poll(()=>page.evaluate(()=>[window.unbuiltImage.isConnected,window.unbuiltImage.hasAttribute('src'),!!window.unbuiltImage.viewer])).toEqual([false,false,false]);
await page.evaluate(()=>{location.hash='/overview';});await expect(page.locator('.viewer-container,#viewer-dialog')).toHaveCount(0);
});
test('collection viewer closed while its image finishes loading never builds Viewer.js afterwards',async({page})=>{
await page.goto(workspace);await expect(cards(page)).toHaveCount(2);
let held;const waiting=new Promise(resolve=>{held=resolve;});
// The image is held for the whole test: its late load is delivered below, at the moment Viewer.js cannot cancel.
await page.route('**/images/serve/shared/same.png?*',async route=>{
const url=new URL(route.request().url());
if(url.searchParams.get('root')==='0'&&url.searchParams.has('_pm_viewer')) {held();return;}
await route.continue().catch(()=>{});
});
await cards(page).first().getByRole('button',{name:'Open same.png',exact:true}).click();await waiting;
const preview=page.locator('dialog#viewer-dialog');
// Viewer.js's own cancel removes its image listeners only while `image.complete` is false. An image that has
// finished downloading but whose load event is still queued is already complete: close in that state, then deliver it.
await page.evaluate(()=>{window.finishingImage=document.querySelector('#viewer-stage > img');Object.defineProperty(window.finishingImage,'complete',{get:()=>true});});
await page.keyboard.press('Escape');await expect(preview).toHaveCount(0);
const after=await page.evaluate(async()=>{
const image=window.finishingImage;image.dispatchEvent(new Event('load'));
// Viewer.js builds an inline viewer in a zero-delay timeout after its image loads; this runs after it.
await new Promise(resolve=>setTimeout(resolve));
return {src:image.hasAttribute('src'),built:!!image.parentNode?.querySelector('.viewer-container')};
});
expect(after).toEqual({src:false,built:false});
});
test('collection viewer describes video transforms and releases native playback on navigation',async({page,request})=>{
await expect(await request.post('/__test__/reset',{data:{include_video:true}})).toBeOK();await page.goto(workspace);
await page.locator('article[data-image-key="0:clip.webm"] .image-open').click();
@@ -1,401 +0,0 @@
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
import { seedConcept } from './concept-inventory.mjs';
// System › Sources & scans (#249 System S4 Task 11) on the concept-shaped real fixture, whose two sources are this
// server's `output` and `input` folders: the source list, monitoring and worker rows read GET /settings (S4 Task 9),
// Add/Remove edit the host draft behind the one Save row, and every scan starts the session job through the shared
// "Review metadata scan" (D55), scoped to one source from its row (S4 Task 10). The hooks are workspace_system_fixture.py's.
const workspace = '/prompt_manager/workspace';
const SETTINGS_URL = '/prompt_manager/settings';
const NOTICE_KEY = 'pm_update_notice_dismissed';
const OUTSIDE_REASON = 'Gallery root must be inside a ComfyUI directory (output, input, temp, user) or one listed in PROMPT_MANAGER_EXTRA_GALLERY_ROOTS';
test.beforeEach(async ({ page, request, browserName }) => {
await startCoverage(page, browserName);
await seedConcept(request);
await expect(await request.post('/__test__/system/reset')).toBeOK();
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
page.systemErrors = [];
page.on('pageerror', error => page.systemErrors.push(error.message));
});
test.afterEach(async ({ page, request, browserName }) => {
await saveCoverage(page, browserName);
await request.post('/__test__/release');
await request.post('/__test__/jobs/restart');
await request.post('/__test__/system/reset');
expect(page.systemErrors).toEqual([]);
});
const toast = page => page.locator('#workspace-toast');
const layoutOf = page => page.locator('#workspace-main .settings-layout');
const sourceList = page => layoutOf(page).locator('#source-list');
const statusOf = page => layoutOf(page).locator('#settings-status');
const saveButton = page => layoutOf(page).getByRole('button', { name: 'Save preferences', exact: true });
const dialog = page => page.locator('dialog#modal[open]');
async function serverJson(request, url) {
const response = await request.get(url);
await expect(response).toBeOK();
return response.json();
}
/** Open Sources with the update notice dismissed (or shown, `notice: true`) once the source list has loaded. */
async function openSources(page, { notice = false } = {}) {
if (!notice) await page.addInitScript(key => { if (location.protocol.startsWith('http')) localStorage.setItem(key, '3.2.0'); }, NOTICE_KEY);
await page.goto(`${workspace}#/system/sources`);
await expect(sourceList(page).locator('.operation').first()).toBeVisible();
await expect(layoutOf(page).locator('#workers')).toBeEnabled();
return layoutOf(page);
}
/** The source list's rows: `[h3, p, [buttons]]`, then its note line when there is one. */
function rowsOf(page) {
return sourceList(page).evaluate(list => [...list.children].map(row => row.matches('.operation')
? [row.querySelector('h3').textContent, row.querySelector('p').textContent,
[...row.querySelectorAll('.row-actions > button')].map(button => button.textContent)]
: [row.localName, row.className, row.textContent]));
}
const sourceRow = (page, title) => sourceList(page).locator('.operation').filter({ has: page.getByRole('heading', { name: title, exact: true }) });
test('notice Later persists the 3.2.0 dismissal (pm_update_notice_dismissed) without scanning', async ({ page, request }) => {
const writes = [];
page.on('request', item => { if (!['GET', 'HEAD', 'OPTIONS'].includes(item.method())) writes.push(item.url()); });
const layout = await openSources(page, { notice: true });
const notice = layout.locator(':scope > .notice');
await expect(notice.locator('h2')).toHaveText('Bring older images into your prompt history');
await expect(notice.locator('p')).toHaveText('The schema upgrades automatically. An optional metadata scan links existing positives and negatives without regenerating all thumbnails.');
await expect(notice.locator('.row-actions > button')).toHaveText(['Later', 'Review scan']);
await notice.getByRole('button', { name: 'Later', exact: true }).click();
await expect(layout.locator(':scope > .notice')).toHaveCount(0);
expect(await page.evaluate(key => localStorage.getItem(key), NOTICE_KEY)).toBe('3.2.0');
await expect(dialog(page)).toHaveCount(0);
// The dismissal is the legacy admin page's own, so neither page shows the notice again.
await page.reload();
await expect(sourceList(page).locator('.operation').first()).toBeVisible();
await expect(layout.locator(':scope > .notice')).toHaveCount(0);
expect(writes).toEqual([]);
expect((await serverJson(request, '/prompt_manager/jobs')).jobs).toEqual([]);
});
test('Review scan opens the shared Review metadata scan with all sources', async ({ page, request }) => {
const layout = await openSources(page, { notice: true });
// The notice's Review scan dismisses it, as the legacy page's does, and reviews every source.
await layout.locator(':scope > .notice').getByRole('button', { name: 'Review scan', exact: true }).click();
await expect(dialog(page).locator('#modal-title')).toHaveText('Review metadata scan');
await expect(dialog(page).locator('dl.meta-grid > div > dd')).toHaveText(['2 output folders', 'New and unresolved files']);
expect(await page.evaluate(key => localStorage.getItem(key), NOTICE_KEY)).toBe('3.2.0');
await dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(layout.locator(':scope > .notice')).toHaveCount(0);
// Enrich existing metadata is the same review.
const enrich = layout.locator('.operation').filter({ hasText: 'Enrich existing metadata' });
await expect(enrich.locator('p')).toHaveText('Resolve both prompts, deduplicate recipes, preserve uncertain links.');
await enrich.getByRole('button', { name: 'Review scan', exact: true }).click();
await expect(dialog(page).locator('dl.meta-grid > div > dd')).toHaveText(['2 output folders', 'New and unresolved files']);
await dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
expect((await serverJson(request, '/prompt_manager/jobs')).jobs).toEqual([]);
});
test('Scan changes opens it with that one source ("1 output folder") and starts a scoped job that opens in Activity', async ({ page, request }) => {
await expect(await request.post('/__test__/system/monitor', { data: { watching: ['input'] } })).toBeOK();
const bodies = [];
page.on('request', item => { if (item.method() === 'POST' && item.url().endsWith('/prompt_manager/jobs/scan')) bodies.push(item.postDataJSON()); });
await openSources(page);
const input = sourceRow(page, 'ComfyUI input');
await expect(input.locator('p')).toHaveText('input · watching');
await input.getByRole('button', { name: 'Scan changes', exact: true }).click();
await expect(dialog(page).locator('#modal-title')).toHaveText('Review metadata scan');
await expect(dialog(page).locator('dl.meta-grid > div > dd')).toHaveText(['1 output folder', 'New and unresolved files']);
await dialog(page).getByRole('button', { name: 'Start scan', exact: true }).click();
await expect(page).toHaveURL(/#\/activity\/[0-9a-f]{32}$/);
// The toast says it started (or, for a scan this quick, that it already finished); Activity shows its progress.
await expect(toast(page)).toHaveText(/^Scan (started\.|finished · )/);
expect(bodies).toEqual([{ scope: 'new', roots: [1], paths: ['input'] }]);
const id = page.url().split('/').pop();
// The job walks only that source (the count is recorded once the walk has listed it).
await expect.poll(async () => (await serverJson(request, `/prompt_manager/jobs/${id}`)).job.scope)
.toEqual({ files: 'new', roots: [1], output_folders: 1 });
await expect(page.locator('#workspace-main h1')).toHaveText('Scan for metadata');
});
test('Add source validates on the server, then marks the host draft dirty; Save persists', async ({ page, request }) => {
// A folder inside ComfyUI's output folder that is not a source yet.
await expect(await request.post('/__test__/system/unthumbnailed', { data: { count: 1 } })).toBeOK();
const layout = await openSources(page);
expect(await rowsOf(page)).toEqual([
['ComfyUI output', 'output · manual scan', ['Scan folder', 'Remove']],
['ComfyUI input', 'input · manual scan', ['Scan folder', 'Remove']],
]);
await expect(layout.locator('.panel-heading > h2')).toHaveText('Image sources');
const add = layout.locator('.panel-heading').getByRole('button', { name: '+ Add source', exact: true });
await add.click();
await expect(dialog(page).locator('#modal-title')).toHaveText('Add image source');
await expect(dialog(page).locator('label[for="action-input"]')).toHaveText('Folder path');
await expect(dialog(page).locator('#action-input')).toHaveValue('');
await expect(dialog(page).locator('.dialog-actions > button')).toHaveText(['Cancel', 'Add']);
await dialog(page).locator('#action-input').fill('output/unthumbnailed');
const validated = page.waitForResponse(response => response.url().endsWith('/prompt_manager/settings/validate-source'));
await dialog(page).getByRole('button', { name: 'Add', exact: true }).click();
expect(await (await validated).json()).toEqual({ success: true, valid: true, label: 'unthumbnailed', path: 'output/unthumbnailed', code: null, reason: null });
await expect(dialog(page)).toHaveCount(0);
await expect(add).toBeFocused();
// Validation wrote nothing: the draft is dirty and the server still has two sources.
expect(await rowsOf(page)).toEqual([
['ComfyUI output', 'output · manual scan', ['Scan folder', 'Remove']],
['ComfyUI input', 'input · manual scan', ['Scan folder', 'Remove']],
['unthumbnailed', 'output/unthumbnailed · not saved yet', ['Remove']],
]);
await expect(statusOf(page)).toHaveText('Unsaved changes.');
expect((await serverJson(request, SETTINGS_URL)).sources.effective).toHaveLength(2);
const saved = page.waitForRequest(item => item.method() === 'POST' && item.url().endsWith(SETTINGS_URL));
await saveButton(page).click();
const body = (await saved).postDataJSON();
expect(body.gallery_root_paths).toEqual(['output', 'input', 'output/unthumbnailed']);
expect(typeof body.expected_revision).toBe('string');
await expect(statusOf(page)).toHaveText('Saved to the server configuration.');
// The list is the server's again: three effective sources, each scannable.
await expect(sourceRow(page, 'unthumbnailed').locator('p')).toHaveText('output/unthumbnailed · manual scan');
await expect(sourceRow(page, 'unthumbnailed').getByRole('button', { name: 'Scan folder', exact: true })).toBeVisible();
expect((await serverJson(request, SETTINGS_URL)).sources.effective.map(source => source.path)).toEqual(['output', 'input', 'output/unthumbnailed']);
});
test('invalid source shows the server reason', async ({ page, request }) => {
const layout = await openSources(page);
await layout.getByRole('button', { name: '+ Add source', exact: true }).click();
const field = dialog(page).locator('#action-input');
for (const [path, reason, submit] of [
['output/not-there', 'Gallery root does not exist', 'click'],
// Enter in the field adds too (M6).
['/etc', OUTSIDE_REASON, 'Enter'],
]) {
await field.fill(path);
if (submit === 'Enter') await field.press('Enter');
else await dialog(page).getByRole('button', { name: 'Add', exact: true }).click();
await expect(dialog(page).locator('p.small[role="alert"]')).toHaveText(reason);
await expect(field).toHaveAttribute('aria-invalid', 'true');
await expect(field).toHaveValue(path);
}
// A folder already listed (the server's answer names it) is not added twice.
await field.fill('input');
await dialog(page).getByRole('button', { name: 'Add', exact: true }).click();
await expect(dialog(page).locator('p.small[role="alert"]')).toHaveText('This folder is already a source.');
// A check that cannot finish says so and adds nothing.
await page.route('**/prompt_manager/settings/validate-source', route => route.fulfill({ status: 500,
json: { success: false, code: null, error: 'Failed to check the source' } }));
await field.fill('output/not-checked');
await dialog(page).getByRole('button', { name: 'Add', exact: true }).click();
await expect(dialog(page).locator('p.small[role="alert"]')).toHaveText('The folder could not be checked. Try again.');
await page.unroute('**/prompt_manager/settings/validate-source');
// Typing again clears the reason.
await field.fill('output/unthumbnailed');
await expect(dialog(page).locator('p.small[role="alert"]')).toHaveCount(0);
await expect(field).not.toHaveAttribute('aria-invalid', 'true');
await dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(statusOf(page)).toHaveText('No unsaved changes.');
expect((await serverJson(request, SETTINGS_URL)).sources.configured).toHaveLength(2);
});
test('Remove marks the draft; removing the last entry states auto-detect', async ({ page, request }) => {
await openSources(page);
await sourceRow(page, 'ComfyUI input').getByRole('button', { name: 'Remove', exact: true }).click();
expect(await rowsOf(page)).toEqual([['ComfyUI output', 'output · manual scan', ['Scan folder', 'Remove']]]);
await expect(statusOf(page)).toHaveText('Unsaved changes.');
await sourceRow(page, 'ComfyUI output').getByRole('button', { name: 'Remove', exact: true }).click();
expect(await rowsOf(page)).toEqual([['p', 'small', 'No source is configured. After saving, ComfyUI’s output folder is used.']]);
await saveButton(page).click();
await expect(statusOf(page)).toHaveText('Saved to the server configuration.');
// Saved: ComfyUI's output folder is detected and listed without Remove.
await expect.poll(rowsOf.bind(null, page)).toEqual([
['ComfyUI output', 'output · manual scan', ['Scan folder']],
['p', 'small', 'No source is configured; ComfyUI’s output folder is used.'],
]);
const { sources } = await serverJson(request, SETTINGS_URL);
expect([sources.configured, sources.auto_detected]).toEqual([[], true]);
});
test('a configured source that does not exist says why and can be removed', async ({ page, request }) => {
await expect(await request.post('/__test__/system/missing-source')).toBeOK();
await openSources(page);
// The fixture's unmounted drive is not inside a ComfyUI folder, so the server says that (it never probes outside).
expect((await rowsOf(page)).at(-1)).toEqual(['unmounted-drive', `unmounted-drive · ${OUTSIDE_REASON}`, ['Remove']]);
await sourceRow(page, 'unmounted-drive').getByRole('button', { name: 'Remove', exact: true }).click();
await expect(sourceList(page).locator('.operation')).toHaveCount(2);
await saveButton(page).click();
await expect(statusOf(page)).toHaveText('Saved to the server configuration.');
expect((await serverJson(request, SETTINGS_URL)).sources.configured.map(source => source.path)).toEqual(['output', 'input']);
});
test('watching only with a live observer; restart pending otherwise', async ({ page, request }) => {
// No image monitor in this process: every source is scanned by hand.
await openSources(page);
await expect(sourceList(page).locator('.operation p')).toHaveText(['output · manual scan', 'input · manual scan']);
// A live observer that scheduled output: output is watched; input was added since it started.
await expect(await request.post('/__test__/system/monitor', { data: { watching: ['output'] } })).toBeOK();
await page.reload();
await expect(sourceList(page).locator('.operation p')).toHaveText(['output · watching', 'input · restart pending']);
await expect(sourceList(page).locator('.operation .row-actions > button:first-child')).toHaveText(['Scan changes', 'Scan folder']);
// An observer that died watches nothing.
await expect(await request.post('/__test__/system/monitor', { data: { watching: ['output', 'input'], alive: false } })).toBeOK();
await page.reload();
await expect(sourceList(page).locator('.operation p')).toHaveText(['output · restart pending', 'input · restart pending']);
});
test('Watch for new outputs saves monitoring_enabled; a restart applies it', async ({ page, request }) => {
await expect(await request.post('/__test__/system/monitor', { data: { watching: ['output', 'input'] } })).toBeOK();
const layout = await openSources(page);
const monitoring = layout.getByRole('switch', { name: 'Watch for new outputs', exact: true });
await expect(monitoring).toBeChecked();
await expect(layout.locator('.setting-row').filter({ has: page.locator('#monitoring') }).locator('p'))
.toHaveText('New files enter the same library. Existing scans do not count as new runs.');
await monitoring.uncheck();
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 answer = await (await saved).json();
expect(answer.restart_required_fields).toEqual(['monitoring_enabled']);
// D128: saved for the next start, and the status says a restart applies it.
await expect(statusOf(page)).toHaveText('Saved to the server configuration. Restart ComfyUI to apply: Watch for new outputs.');
// The running observer keeps watching until ComfyUI restarts, and the list says so.
const { settings, monitor } = await serverJson(request, SETTINGS_URL);
expect([settings.monitoring_enabled, monitor.observer_alive]).toEqual([false, true]);
await expect(sourceList(page).locator('.operation p')).toHaveText(['output · watching until restart', 'input · watching until restart']);
await expect(sourceList(page).locator('.operation .row-actions > button:first-child')).toHaveText(['Scan changes', 'Scan changes']);
// Switched back on before the restart: the observer still runs, so nothing waits for one.
await monitoring.check();
await saveButton(page).click();
await expect(statusOf(page)).toHaveText('Saved to the server configuration.');
await expect(sourceList(page).locator('.operation p')).toHaveText(['output · watching', 'input · watching']);
});
test('worker range max is cpu_count', async ({ page, request }) => {
const { settings } = await serverJson(request, SETTINGS_URL);
const layout = await openSources(page);
const workers = layout.locator('#workers');
await expect(layout.locator('label[for="workers"]')).toHaveText('Worker threads');
await expect(workers).toHaveAttribute('type', 'range');
await expect(workers).toHaveAttribute('min', '1');
await expect(workers).toHaveAttribute('max', String(settings.cpu_count));
await expect(workers).toHaveValue(String(settings.worker_threads));
const next = settings.worker_threads === 1 ? settings.cpu_count : 1;
await workers.fill(String(next));
await expect(statusOf(page)).toHaveText(next === settings.worker_threads ? 'No unsaved changes.' : 'Unsaved changes.');
test.skip(next === settings.worker_threads, 'One core: the range has a single value');
const saved = page.waitForRequest(item => item.method() === 'POST' && item.url().endsWith(SETTINGS_URL));
await saveButton(page).click();
expect((await saved).postDataJSON().worker_threads).toBe(next);
await expect(statusOf(page)).toHaveText('Saved to the server configuration.');
expect((await serverJson(request, SETTINGS_URL)).settings.worker_threads).toBe(next);
});
test('Refresh files calls /gallery/rescan and toasts its counts', async ({ page }) => {
const layout = await openSources(page);
const refresh = layout.locator('.operation').filter({ has: page.getByRole('heading', { name: 'Refresh files', exact: true }) });
await expect(refresh.locator('p')).toHaveText('Read every source again to find files added or removed outside ComfyUI.');
const answered = page.waitForResponse(response => response.url().endsWith('/prompt_manager/gallery/rescan'));
await refresh.getByRole('button', { name: 'Refresh', exact: true }).click();
const { total, roots } = await (await answered).json();
expect(roots).toBe(2);
await expect(toast(page)).toHaveText(`Files refreshed. ${total.toLocaleString('en-US')} files in 2 output folders.`);
// A failed refresh says so.
await page.route('**/prompt_manager/gallery/rescan', route => route.fulfill({ status: 500, json: { success: false, error: 'busy disk' } }));
await refresh.getByRole('button', { name: 'Refresh', exact: true }).click();
await expect(toast(page)).toHaveText('Files could not be refreshed. Try again.');
});
test('Thumbnail generation hands off to the existing gallery until its review dialog', async ({ page }) => {
const layout = await openSources(page);
const thumbnails = layout.locator('.operation').filter({ has: page.getByRole('heading', { name: 'Thumbnail generation', exact: true }) });
await expect(thumbnails.locator('p')).toHaveText('Generate missing previews; rebuilding all is a separate explicit option.');
const popup = page.waitForEvent('popup');
await thumbnails.getByRole('button', { name: 'Review options', exact: true }).click();
const gallery = await popup;
expect(new URL(gallery.url()).pathname).toBe('/prompt_manager/gallery');
await gallery.close();
await expect(statusOf(page)).toHaveText('No unsaved changes.');
});
test('a save refused by a running scan shows the busy link toast', async ({ page, request }) => {
const running = await (await request.post('/__test__/jobs/start', { data: { title: 'Scan for metadata' } })).json();
await openSources(page);
await sourceRow(page, 'ComfyUI input').getByRole('button', { name: 'Remove', exact: true }).click();
await saveButton(page).click();
await expect(toast(page)).toHaveText('Another job is running: Scan for metadata.');
await expect(toast(page).getByRole('link', { name: 'Scan for metadata', exact: true })).toHaveAttribute('href', `#/activity/${running.job.id}`);
await expect(statusOf(page)).toHaveText('Some changes were not saved. Review the highlighted settings.');
// The draft is kept for the next Save, and the server still has both sources.
await expect(sourceList(page).locator('.operation')).toHaveCount(1);
expect((await serverJson(request, SETTINGS_URL)).sources.configured).toHaveLength(2);
// An answer that does not name its job (an older server): the toast names the running one from the job list.
await page.route(`**${SETTINGS_URL}`, route => (route.request().method() === 'POST'
? route.fulfill({ status: 409, json: { success: false, code: 'busy', error: 'Scan for metadata already running' } })
: route.continue()));
await page.locator('#workspace-toast').evaluate(node => { node.textContent = ''; });
await saveButton(page).click();
await expect(toast(page)).toHaveText('Another job is running: Scan for metadata.');
await expect(toast(page).getByRole('link', { name: 'Scan for metadata', exact: true })).toHaveAttribute('href', `#/activity/${running.job.id}`);
await page.unroute(`**${SETTINGS_URL}`);
await request.post(`/__test__/jobs/${running.job.id}/finish`, { data: { state: 'finished' } });
});
test('a source list that cannot be read says so and retries', async ({ page }) => {
await page.addInitScript(key => { if (location.protocol.startsWith('http')) localStorage.setItem(key, '3.2.0'); }, NOTICE_KEY);
let failing = true;
await page.route(`**${SETTINGS_URL}`, route => (failing && route.request().method() === 'GET'
? route.fulfill({ status: 500, json: { success: false, error: 'Injected failure' } }) : route.continue()));
await page.goto(`${workspace}#/system/sources`);
await expect(sourceList(page).getByRole('alert')).toHaveText('Sources could not be loaded. Your saved data has not changed.');
await expect(sourceList(page).locator('.operation')).toHaveCount(0);
// The server settings rows say so too, and stay disabled.
await expect(layoutOf(page).locator('#workers')).toBeDisabled();
failing = false;
await sourceList(page).getByRole('button', { name: 'Retry', exact: true }).click();
await expect(sourceList(page).locator('.operation')).toHaveCount(2);
});
test('a scan from a list that changed since it was read starts nothing, says why and reads the list again', async ({ page, request }) => {
await openSources(page);
// The legacy page (or another tab) removes ComfyUI output: the page's "input" is index 1, now past the end.
await expect(await request.post(SETTINGS_URL, { data: { gallery_root_paths: ['input'] } })).toBeOK();
const refused = page.waitForResponse(response => response.url().endsWith('/prompt_manager/jobs/scan'));
await sourceRow(page, 'ComfyUI input').getByRole('button', { name: 'Scan folder', exact: true }).click();
await dialog(page).getByRole('button', { name: 'Start scan', exact: true }).click();
const answer = await refused;
expect([answer.status(), (await answer.json()).code]).toEqual([409, 'sources-changed']);
await expect(toast(page)).toHaveText('The sources changed since they were loaded. Reload them.');
// No scan started (the other save ran as its own source-change hold).
expect((await serverJson(request, '/prompt_manager/jobs')).jobs.map(job => job.kind)).toEqual(['source_change']);
// Read again: the one source is index 0 now, and its scan starts.
await expect.poll(rowsOf.bind(null, page)).toEqual([['ComfyUI input', 'input · manual scan', ['Scan folder', 'Remove']]]);
await expect(statusOf(page)).toHaveText('No unsaved changes.');
const started = page.waitForRequest(item => item.method() === 'POST' && item.url().endsWith('/prompt_manager/jobs/scan'));
await sourceRow(page, 'ComfyUI input').getByRole('button', { name: 'Scan folder', exact: true }).click();
await dialog(page).getByRole('button', { name: 'Start scan', exact: true }).click();
expect((await started).postDataJSON()).toEqual({ scope: 'new', roots: [0], paths: ['input'] });
await expect(page).toHaveURL(/#\/activity\/[0-9a-f]{32}$/);
});
test('a save refused for one unusable source names it and marks its row', async ({ page, request }) => {
await expect(await request.post('/__test__/system/missing-source', { data: { inside: true } })).toBeOK();
await openSources(page);
await expect(sourceRow(page, 'unplugged').locator('p')).toHaveText('output/unplugged · manual scan');
// The drive is unplugged after the page read the list; removing another source resends the whole list.
await expect(await request.post('/__test__/system/unplug')).toBeOK();
await sourceRow(page, 'ComfyUI input').getByRole('button', { name: 'Remove', exact: true }).click();
const saved = page.waitForResponse(response => response.request().method() === 'POST' && response.url().endsWith(SETTINGS_URL));
await saveButton(page).click();
const answer = await saved;
expect(answer.status()).toBe(400);
expect(await answer.json()).toMatchObject({ code: 'invalid-source', source: 'output/unplugged', index: 1 });
await expect(statusOf(page)).toHaveText("output/unplugged can't be used: Gallery root does not exist. Remove it or reconnect it, then save.");
await expect(sourceRow(page, 'unplugged')).toHaveAttribute('aria-invalid', 'true');
await expect(sourceList(page).locator('.operation[aria-invalid="true"]')).toHaveCount(1);
expect((await serverJson(request, SETTINGS_URL)).sources.configured).toHaveLength(3);
// Removed, the list saves.
await sourceRow(page, 'unplugged').getByRole('button', { name: 'Remove', exact: true }).click();
await expect(sourceList(page).locator('.operation[aria-invalid="true"]')).toHaveCount(0);
await saveButton(page).click();
await expect(statusOf(page)).toHaveText('Saved to the server configuration.');
expect((await serverJson(request, SETTINGS_URL)).sources.configured.map(source => source.path)).toEqual(['output']);
});
+3 -369
View File
@@ -11,7 +11,7 @@ const number = value => value.toLocaleString('en-US');
test.beforeEach(async ({ page, request, browserName }) => {
await startCoverage(page, browserName);
page.manifest = await seedConcept(request);
await seedConcept(request);
await expect(await request.post('/__test__/system/reset')).toBeOK();
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
page.systemErrors = [];
@@ -237,11 +237,10 @@ test('out-of-order overview responses never repaint a newer section', async ({ p
await nav.getByRole('button', { name: 'Appearance', exact: true }).click();
await nav.getByRole('button', { name: 'Overview', exact: true }).click();
await expect.poll(held).toBe(2);
// Integrations still hands off (S10), so its body is one known panel.
await nav.getByRole('button', { name: 'Integrations', exact: true }).click();
await nav.getByRole('button', { name: 'Sources & scans', exact: true }).click();
await expect(await request.post('/__test__/release')).toBeOK();
await expect.poll(held).toBe(0);
// Both late answers reached a section that had left: Integrations keeps its own body.
// Both late answers reached a section that had left: Sources keeps its own body.
await expect(layout.locator(':scope > section.handoff')).toBeVisible();
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
await expect(layout.locator('.summary-metrics')).toHaveCount(0);
@@ -251,368 +250,3 @@ test('out-of-order overview responses never repaint a newer section', async ({ p
await expect(layout.locator('.summary-metrics')).toHaveCount(1);
await expect(layout.locator('.two-col')).toHaveCount(1);
});
// System › Appearance (#249 System S3 Task 8): the browser display defaults (`gallerySettings`, preferences.mjs) and the
// two host fields behind the one Save row, and the consumers that read them on their next visit.
const SETTINGS_URL = '/prompt_manager/settings';
const INSPECT_PATH = '/prompt_manager/images/inspect/';
const statusOf = page => page.locator('#workspace-main #settings-status');
const saveOf = page => page.locator('#workspace-main .save-row > button');
const storedSettings = page => page.evaluate(() => JSON.parse(localStorage.getItem('gallerySettings') || 'null'));
const imageCards = page => page.locator('#workspace-main article.media-card');
const panel = page => page.getByRole('complementary', { name: 'Selected image details', exact: true });
async function openAppearance(page) {
await page.goto(`${workspace}#/system/appearance`);
await expect(page.locator('#workspace-main .settings-layout > section.surface > h2')).toHaveText('Browsing & display');
// The host rows are enabled once the server settings answered.
await expect(page.locator('#timeout')).toBeEnabled();
}
async function saveAppearance(page, text) {
await expect(saveOf(page)).not.toHaveAttribute('aria-disabled', 'true');
await saveOf(page).click();
await expect(statusOf(page)).toHaveText(text);
}
/** Set toggles (by row id) through their Toolkit switches. */
async function toggle(page, values) {
for (const [id, checked] of Object.entries(values)) await page.locator(`#${id}`).setChecked(checked, { force: true });
}
/**
* A request barrier: one request issued and answered after everything the page has asked for so far, so a read the
* last action started has been seen (Playwright reports a page's requests in order).
*/
async function requestBarrier(page) {
expect(await page.evaluate(url => fetch(url, { cache: 'no-store' }).then(response => response.ok), SETTINGS_URL)).toBe(true);
}
async function openImages(page) {
await page.goto(`${workspace}#/images`);
await expect(imageCards(page).first()).toBeVisible();
}
test('defaults apply to a new visit but not to the current view or a saved view', async ({ page, request }) => {
const created = await request.post('/prompt_manager/saved-views', { data: { page: 'images', name: 'Everything',
state: { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false } } });
expect(created.status()).toBe(201);
const view = (await created.json()).view;
try {
await openImages(page);
// The current view: the toolbar sets List.
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');
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.
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');
// The saved view opens as before.
await page.goto(`${workspace}#/saved/${view.id}`);
await expect(imageCards(page).first()).toBeVisible();
await expect(page.locator('.image-grid')).toHaveAttribute('data-layout', 'grid');
await expect(page.locator('.image-grid')).toHaveAttribute('data-density', 'comfortable');
// A new visit starts from the defaults.
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-density', 'compact');
await expect(page.getByRole('group', { name: 'Image layout' }).getByRole('button', { name: 'Masonry', exact: true }))
.toHaveAttribute('aria-pressed', 'true');
} finally {
await request.delete(`/prompt_manager/saved-views/${view.id}`);
}
});
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');
await page.locator('#density').selectOption('compact');
await saveAppearance(page, 'Saved in this browser.');
// The prompt history's shared gallery has no toolbar to change them, so it opens as it always did.
await page.goto(`${workspace}#/library/${page.manifest.prompts[0].positive_id}`);
await expect(imageCards(page).first()).toBeVisible();
await expect(page.locator('#workspace-main .image-grid')).toHaveAttribute('data-layout', 'grid');
await expect(page.locator('#workspace-main .image-grid')).toHaveAttribute('data-density', 'comfortable');
// The Images page starts from them.
await page.locator('#primary-nav a[data-route="images"]').click();
await expect(imageCards(page).first()).toBeVisible();
await expect(page.locator('.image-grid')).toHaveAttribute('data-layout', 'list');
await expect(page.locator('.image-grid')).toHaveAttribute('data-density', 'compact');
});
test('metadata off shows an explicit load and loads once on request', async ({ page }) => {
await openAppearance(page);
await toggle(page, { 'metadata-auto': false });
await saveAppearance(page, 'Saved in this browser.');
const reads = [];
page.on('request', item => { if (new URL(item.url()).pathname.startsWith(INSPECT_PATH)) reads.push(item.url()); });
await openImages(page);
const first = imageCards(page).first();
await first.locator('.image-card-actions button').first().click();
await expect(panel(page).getByRole('status')).toHaveText('Metadata loads when you ask for it.');
const load = panel(page).getByRole('button', { name: 'Load metadata', exact: true });
await expect(load).toBeVisible();
expect(reads).toEqual([]);
await load.click();
await expect(panel(page).locator('.panel-heading h2')).toBeVisible();
expect(reads).toHaveLength(1);
// The same file again reads nothing more (the cache is on).
await first.locator('.image-card-actions button').first().click();
await expect(panel(page).locator('.panel-heading h2')).toBeVisible();
await requestBarrier(page);
expect(reads).toHaveLength(1);
// The viewer's details use the same inspector: another file waits there too, and loads on request.
await imageCards(page).nth(1).locator('.image-open').click();
const viewerDetails = page.locator('#viewer-details');
await expect(viewerDetails.getByRole('status')).toHaveText('Metadata loads when you ask for it.');
expect(reads).toHaveLength(1);
await viewerDetails.getByRole('button', { name: 'Load metadata', exact: true }).click();
await expect(viewerDetails.locator('.panel-heading h2')).toBeVisible();
expect(reads).toHaveLength(2);
});
test('cache off rereads the current selection; on reuses the one current record', async ({ page }) => {
const reads = [];
page.on('request', item => { if (new URL(item.url()).pathname.startsWith(INSPECT_PATH)) reads.push(item.url()); });
for (const cache of [true, false]) {
if (!cache) {
await openAppearance(page);
await toggle(page, { 'metadata-cache': false });
await saveAppearance(page, 'Saved in this browser.');
await page.reload();
}
await openImages(page);
const info = imageCards(page).first().locator('.image-card-actions button').first();
await info.click();
await expect(panel(page).locator('.panel-heading h2')).toBeVisible();
await expect.poll(() => reads.length).toBeGreaterThan(0);
await requestBarrier(page);
reads.length = 0;
// Inspect the selected file twice more.
for (const _ of [1, 2]) {
const answered = cache ? null : page.waitForResponse(response => new URL(response.url()).pathname.startsWith(INSPECT_PATH));
await info.click();
if (answered) await answered;
await expect(panel(page).locator('.panel-heading h2')).toBeVisible();
}
await requestBarrier(page);
expect(reads, `cache ${cache}`).toHaveLength(cache ? 0 : 2);
}
});
test('paths hidden only changes display', async ({ page }) => {
const rows = async () => page.locator('#viewer-details details').filter({ hasText: 'File & provenance' }).locator('dt')
.evaluateAll(terms => terms.map(term => term.textContent));
const openViewer = async () => {
await openImages(page);
await imageCards(page).first().locator('.image-open').click();
const provenance = page.locator('#viewer-details details').filter({ hasText: 'File & provenance' });
await provenance.locator('summary').click();
await expect(provenance.locator('dl')).toBeVisible();
return provenance;
};
let provenance = await openViewer();
expect(await rows()).toContain('Path');
const before = { show: await provenance.getByRole('button', { name: /^Show .* in folder$/ }).count(),
download: await provenance.getByRole('link', { name: 'Download original', exact: true }).getAttribute('href') };
await page.keyboard.press('Escape');
await openAppearance(page);
await toggle(page, { paths: false });
await saveAppearance(page, 'Saved in this browser.');
provenance = await openViewer();
const shown = await rows();
expect(shown).not.toContain('Path');
expect(shown).toEqual(expect.arrayContaining(['Source', 'Size']));
// Everything else is as it was: the folder action and the original download stay.
expect(await provenance.getByRole('button', { name: /^Show .* in folder$/ }).count()).toBe(before.show);
await expect(provenance.getByRole('link', { name: 'Download original', exact: true })).toHaveAttribute('href', before.download);
});
test('video defaults reach the viewer', async ({ page }) => {
await openAppearance(page);
await toggle(page, { 'video-auto': true, 'video-muted': false, 'video-loop': false });
await saveAppearance(page, 'Saved in this browser.');
expect(await page.evaluate(async () => (await import('/prompt_manager/js/workspace/media-viewer.mjs')).readViewerPreferences()))
.toEqual({ videoAutoplay: true, videoMute: false, videoLoop: false });
const stored = await storedSettings(page);
expect([stored.videoAutoplay, stored.videoMute, stored.videoLoop]).toEqual([true, false, false]);
});
test('host fields round-trip through /settings with revision', async ({ page, request }) => {
const posts = [];
page.on('request', item => { if (item.method() === 'POST' && new URL(item.url()).pathname === SETTINGS_URL) posts.push(item.postDataJSON()); });
const before = await serverJson(request, SETTINGS_URL);
await openAppearance(page);
await expect(page.locator('#open-mode')).toHaveValue(before.settings.webui_display_mode);
await expect(page.locator('#timeout')).toHaveValue(String(before.settings.result_timeout));
// Only whole seconds from 0 to 300: anything else is highlighted and never sent.
for (const bad of ['301', '-1', '2.5']) {
await page.locator('#timeout').fill(bad);
await page.locator('#timeout').press('Tab');
await expect(page.locator('#timeout')).toHaveAttribute('aria-invalid', 'true');
await expect(statusOf(page)).toHaveText('Some changes were not saved. Review the highlighted settings.');
await expect(saveOf(page)).toHaveAttribute('aria-disabled', 'true');
}
await page.locator('#timeout').fill('0');
await page.locator('#timeout').press('Tab');
await expect(page.locator('#timeout')).not.toHaveAttribute('aria-invalid');
await page.locator('#open-mode').selectOption('popup');
await saveAppearance(page, 'Saved to the server configuration.');
expect(posts).toEqual([{ webui_display_mode: 'popup', result_timeout: 0, expected_revision: before.revision }]);
const after = await serverJson(request, SETTINGS_URL);
expect([after.settings.webui_display_mode, after.settings.result_timeout]).toEqual(['popup', 0]);
expect(after.revision).not.toBe(before.revision);
// No browser key was written for a host field.
expect(await storedSettings(page)).toBeNull();
});
test('server settings that cannot be read say so in their rows', async ({ page }) => {
await page.route(url => url.pathname === SETTINGS_URL, route => (route.request().method() === 'GET'
? route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ success: false, error: 'Injected failure' }) })
: route.continue()));
await page.goto(`${workspace}#/system/appearance`);
for (const id of ['open-mode', 'timeout']) {
const row = page.locator('#workspace-main .setting-row').filter({ has: page.locator(`#${id}`) });
await expect(row.locator('p.small')).toHaveText('Server settings could not be read.');
await expect(page.locator(`#${id}`)).toBeDisabled();
}
// The browser rows are this browser's, and work as before.
await expect(page.locator('#default-view')).toBeEnabled();
await expect(page.locator('#workspace-main .setting-row p.small')).toHaveCount(2);
});
test('stale revision shows the conflict status', async ({ page, request }) => {
await openAppearance(page);
// Another page saves after this one read the settings.
const current = await serverJson(request, SETTINGS_URL);
await expect(await request.post(SETTINGS_URL, { data: { result_timeout: 42, expected_revision: current.revision } })).toBeOK();
await page.locator('#open-mode').selectOption('popup');
await saveAppearance(page, 'Settings changed elsewhere. Reload this section to see them.');
await expect(page.locator('#open-mode')).toHaveAttribute('aria-invalid', 'true');
// The draft is kept and the section now shows the other page's value.
await expect(page.locator('#open-mode')).toHaveValue('popup');
await expect(page.locator('#timeout')).toHaveValue('42');
expect((await serverJson(request, SETTINGS_URL)).settings.webui_display_mode).toBe('newtab');
// With the reloaded revision, Save succeeds.
await saveAppearance(page, 'Saved to the server configuration.');
expect((await serverJson(request, SETTINGS_URL)).settings.webui_display_mode).toBe('popup');
});
test('reload keeps saved values; a new tab reads them', async ({ page, context }) => {
await openAppearance(page);
await page.locator('#image-quality').selectOption('high');
await toggle(page, { 'card-details': false });
await page.locator('#open-mode').selectOption('popup');
await saveAppearance(page, 'Saved in this browser. Saved to the server configuration.');
await page.reload();
await expect(page.locator('#timeout')).toBeEnabled();
await expect(page.locator('#image-quality')).toHaveValue('high');
await expect(page.locator('#card-details')).not.toBeChecked();
await expect(page.locator('#open-mode')).toHaveValue('popup');
const other = await context.newPage();
await openAppearance(other);
await expect(other.locator('#image-quality')).toHaveValue('high');
await expect(other.locator('#card-details')).not.toBeChecked();
// A change saved in one tab reaches the other's clean controls; a draft it overtakes is flagged and kept.
await other.locator('#density').selectOption('large');
await page.locator('#density').selectOption('compact');
await saveAppearance(page, 'Saved in this browser.');
await expect(other.locator('#density')).toHaveValue('large');
await expect(other.locator('#settings-status')).toHaveText('Settings changed elsewhere. Reload this section to see them.');
await expect(other.locator('#density')).toHaveAttribute('aria-invalid', 'true');
await page.locator('#image-quality').selectOption('low');
await saveAppearance(page, 'Saved in this browser.');
await expect(other.locator('#image-quality')).toHaveValue('low');
await other.close();
});
test('a change another tab saved while System was closed is a conflict on return, never saved over silently', async ({ page, context }) => {
await openAppearance(page);
await page.locator('#density').selectOption('compact');
await expect(statusOf(page)).toHaveText('Unsaved changes.');
// Leave System for Images: the draft stays for the page session, but System no longer listens.
await page.locator('#primary-nav a[data-route="images"]').click();
await expect(imageCards(page).first()).toBeVisible();
const other = await context.newPage();
await openAppearance(other);
await other.locator('#density').selectOption('large');
await saveAppearance(other, 'Saved in this browser.');
await other.close();
// Back in System: the draft is kept and flagged, and nothing was written over the other tab's value.
await page.evaluate(() => { location.hash = '#/system/appearance'; });
await expect(page.locator('#workspace-main .settings-layout > section.surface > h2')).toHaveText('Browsing & display');
await expect(statusOf(page)).toHaveText('Settings changed elsewhere. Reload this section to see them.');
await expect(page.locator('#density')).toHaveAttribute('aria-invalid', 'true');
await expect(page.locator('#density')).toHaveValue('compact');
expect((await storedSettings(page)).workspaceDensity).toBe('large');
// Saving after the conflict was shown is an explicit choice: it wins.
await saveAppearance(page, 'Saved in this browser.');
expect((await storedSettings(page)).workspaceDensity).toBe('compact');
await expect(page.locator('#density')).not.toHaveAttribute('aria-invalid');
});
test('a browser that refuses storage is saved for this tab only, never "Saved"', async ({ page }) => {
await openAppearance(page);
await page.evaluate(() => {
Storage.prototype.setItem = function setItem() { throw new DOMException('Quota exceeded', 'QuotaExceededError'); };
});
await page.locator('#default-view').selectOption('list');
await saveAppearance(page, 'Saved for this tab only; browser storage is unavailable.');
expect(await storedSettings(page)).toBeNull();
// This tab's next Images visit still uses it.
await page.locator('#primary-nav a[data-route="images"]').click();
await expect(imageCards(page).first()).toBeVisible();
await expect(page.locator('.image-grid')).toHaveAttribute('data-layout', 'list');
});
test('added rows reach their consumers', async ({ page }) => {
await openImages(page);
await expect(imageCards(page).first().locator('.media-info h3')).toBeVisible();
await openAppearance(page);
await toggle(page, { 'card-details': false, 'viewer-details': true });
await saveAppearance(page, 'Saved in this browser.');
await page.reload();
await openImages(page);
// Show image details on cards off: no title or file line; Info and Tag stay.
const card = imageCards(page).first();
await expect(card.locator('.media-info h3')).toBeHidden();
await expect(card.locator('.image-card-actions button')).toHaveCount(2);
// Collapse viewer details by default: the viewer opens with its details hidden behind one toggle.
await card.locator('.image-open').click();
const details = page.locator('#viewer-details');
await expect(page.locator('#viewer-dialog')).toBeVisible();
await expect(details).toBeHidden();
const show = page.locator('#viewer-toolbar').getByRole('button', { name: 'Show details', exact: true });
await expect(show).toHaveAttribute('aria-expanded', 'false');
await show.click();
await expect(details).toBeVisible();
await expect(page.locator('#viewer-toolbar').getByRole('button', { name: 'Hide details', exact: true })).toHaveAttribute('aria-expanded', 'true');
});
test('reset restores defaults without touching other keys', async ({ page }) => {
await page.goto(`${workspace}#/system/appearance`);
await page.evaluate(() => localStorage.setItem('gallerySettings', JSON.stringify({ gridColumns: 5, defaultLimit: 200,
thumbnailPromptDismissed: 'kept', workspaceLayout: 'list', videoMute: false, showImageInfo: false })));
await page.reload();
await expect(page.locator('#timeout')).toBeEnabled();
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');
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',
videoMute: true, showImageInfo: true });
});
+6 -8
View File
@@ -425,7 +425,8 @@ for (const width of [1600, 390]) test(`overview and handoff render with concept
const SYSTEM_SECTION_LABELS = ['Overview', 'Appearance', 'Sources & scans', 'Integrations', 'Auto-tagging', 'Maintenance',
'Backup & export', 'Diagnostics & logs'];
const SYSTEM_HASHES = ['overview', 'appearance', 'sources', 'integrations', 'tagging', 'maintenance', 'backups', 'diagnostics'];
const SYSTEM_HANDOFFS = { maintenance: ['/prompt_manager/admin', '/prompt_manager/gallery'] };
const SYSTEM_HANDOFFS = { appearance: ['/prompt_manager/gallery', '/prompt_manager/admin'], sources: ['/prompt_manager/admin', '/prompt_manager/gallery'],
maintenance: ['/prompt_manager/admin', '/prompt_manager/gallery'] };
test('System is native: the concept shell, section navigation, sections, hand-offs and the Save row', async ({ page }) => {
const mutations = [];
@@ -461,17 +462,14 @@ test('System is native: the concept shell, section navigation, sections, hand-of
await expect(nav.locator('[aria-pressed="true"]')).toHaveCount(1);
// The section changes in place: focus stays on the pressed section, not the page title.
await expect(button).toBeFocused();
// S2 Task 6: Overview is native (workspace-system.spec.mjs), and so are Appearance (S3 Task 8) and Sources & scans
// (S4 Task 11, workspace-system-sources.spec.mjs, its update notice shown in a fresh browser); Maintenance shows
// its health-check notice above the hand-off (D123); every other section hands off.
// S2 Task 6: Overview is native (workspace-system.spec.mjs); Maintenance shows its health-check notice above
// the hand-off (D123); every other section hands off.
if (hash === 'overview') await expect(layout.locator(':scope > .summary-metrics')).toBeVisible();
if (hash === 'sources') await expect(layout.locator('#source-list .operation')).toHaveCount(2);
expect(await layout.evaluate(node => [...node.children].map(child => `${child.localName}.${child.className}`)))
.toEqual({ overview: ['div.summary-metrics', 'div.two-col', 'div.save-row'], appearance: ['section.surface', 'div.save-row'],
sources: ['div.notice', 'section.surface', 'div.save-row'],
.toEqual({ overview: ['div.summary-metrics', 'div.two-col', 'div.save-row'],
maintenance: ['div.notice', 'section.surface handoff', 'div.save-row'] }[hash]
?? ['section.surface handoff', 'div.save-row']);
if (['overview', 'appearance', 'sources'].includes(hash)) continue;
if (hash === 'overview') continue;
const handoff = layout.locator('section.handoff');
await expect(handoff.locator('h2')).toHaveText('Your current tools are ready');
expect(await handoff.locator('a.workspace-link').evaluateAll(links => links.map(link => link.getAttribute('href'))))
+6 -96
View File
@@ -11,18 +11,11 @@ read. These per-server hooks only change what a System test needs on top:
requests for ``path`` (a ``/prompt_manager/system/`` route) with the routes'
500 error envelope;
- ``POST /__test__/system/missing-source`` configures one more source that
does not exist (an unmounted drive); with ``{"inside": true}`` it is an
existing folder inside the output folder (``output/unplugged``), which
``POST /__test__/system/unplug`` then removes (a drive unplugged while the
page shows it);
does not exist (an unmounted drive);
- ``POST /__test__/system/missing-file`` deletes the newest linked image's
file from disk, leaving its database row (a missing-file link);
- ``POST /__test__/system/unthumbnailed`` ``{count}`` writes ``count`` plain
PNGs with no thumbnail into the output folder;
- ``POST /__test__/system/monitor`` ``{watching, alive}`` stands in a fake
image monitor whose observer is ``alive`` (default true) and schedules the
named folders of this server's root (``["output"]``), so Sources reads
watching, manual scan and restart pending states (S4).
PNGs with no thumbnail into the output folder.
Delays use the shared request holds (``request_hold.py``).
"""
@@ -38,31 +31,9 @@ MAX_UNTHUMBNAILED = 50
def register_system_commands(app, routes, api, root: Path):
# System › Appearance saves the host fields to this server's config.json (S3);
# reset restores both. The file is this server's own, inside its temp root:
# reset rewrites or deletes it, so a server whose configuration points
# anywhere else (a real config.json) refuses to register these hooks.
config_path = root / "config.json"
configured_path = os.environ.get("PROMPT_MANAGER_CONFIG_PATH")
if (
configured_path is None
or Path(configured_path).resolve() != config_path.resolve()
):
raise RuntimeError(
f"PROMPT_MANAGER_CONFIG_PATH must be {config_path}, not {configured_path!r}"
)
from py.config import GalleryConfig, PromptManagerConfig
from utils import image_monitor
from py.config import GalleryConfig
configured = list(GalleryConfig.MONITORING_DIRECTORIES)
config_bytes = config_path.read_bytes() if config_path.exists() else None
host_values = (
PromptManagerConfig.RESULT_TIMEOUT,
PromptManagerConfig.WEBUI_DISPLAY_MODE,
PromptManagerConfig.WORKER_THREADS,
GalleryConfig.MONITORING_ENABLED,
)
monitor = getattr(image_monitor, "_monitor_instance", None)
failures: dict[str, int] = {}
@web.middleware
@@ -77,28 +48,10 @@ def register_system_commands(app, routes, api, root: Path):
app.middlewares.append(inject_failures)
unplugged = root / "output" / "unplugged"
def reset():
failures.clear()
if unplugged.is_dir():
unplugged.rmdir()
GalleryConfig.MONITORING_DIRECTORIES = list(configured)
api._health_report = None
(
PromptManagerConfig.RESULT_TIMEOUT,
PromptManagerConfig.WEBUI_DISPLAY_MODE,
PromptManagerConfig.WORKER_THREADS,
GalleryConfig.MONITORING_ENABLED,
) = host_values
image_monitor._monitor_instance = monitor
if config_bytes is None:
config_path.unlink(missing_ok=True)
else:
# Replace in one step, as the server writes it (Windows preflight).
staged = config_path.with_name(config_path.name + ".reset")
staged.write_bytes(config_bytes)
os.replace(staged, config_path)
@routes.post("/__test__/system/reset")
async def system_reset(request):
@@ -122,26 +75,14 @@ def register_system_commands(app, routes, api, root: Path):
@routes.post("/__test__/system/missing-source")
async def missing_source(request):
data = await request.json() if request.can_read_body else {}
if data.get("inside") is True:
unplugged.mkdir(exist_ok=True)
absent = str(unplugged)
else:
absent = str(root / "unmounted-drive")
if os.path.exists(absent):
raise web.HTTPConflict()
absent = str(root / "unmounted-drive")
if os.path.exists(absent):
raise web.HTTPConflict()
GalleryConfig.MONITORING_DIRECTORIES = [*configured, absent]
return web.json_response(
{"success": True, "configured": len(GalleryConfig.MONITORING_DIRECTORIES)}
)
@routes.post("/__test__/system/unplug")
async def unplug(request):
if not unplugged.is_dir():
raise web.HTTPConflict()
unplugged.rmdir()
return web.json_response({"success": True})
@routes.post("/__test__/system/missing-file")
async def missing_file(request):
with api.db.model.get_connection() as conn:
@@ -172,35 +113,4 @@ def register_system_commands(app, routes, api, root: Path):
api.invalidate_gallery_cache()
return web.json_response({"success": True})
@routes.post("/__test__/system/monitor")
async def fake_monitor(request):
data = await request.json()
watching, alive = data.get("watching", []), data.get("alive", True)
if (
not isinstance(watching, list)
or not all(name in ("output", "input") for name in watching)
or not isinstance(alive, bool)
):
raise web.HTTPBadRequest()
image_monitor._monitor_instance = _FakeMonitor(
[str(root / name) for name in watching], alive
)
return web.json_response({"success": True})
return reset
class _FakeMonitor:
"""The image monitor's status surface (``get_status``) with no observer thread."""
def __init__(self, directories, alive):
self.monitored_directories = directories
self._alive = alive
def get_status(self):
return {
"running": True,
"monitored_directories": list(self.monitored_directories),
"handler_active": True,
"observer_alive": self._alive,
}
+1 -10
View File
@@ -1,20 +1,11 @@
import { expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import runtime from '../../tools/vendor/comfyui-runtime.json' with { type: 'json' };
import { hostBaseDirectory } from '../../tools/vendor/comfyui-host.mjs';
export function installHostHooks(test, expectedTypes = ['PromptManager', 'PromptManagerText', 'PromptSearchList']) {
test.beforeEach(async ({ page, context, request }, testInfo) => {
const response = await request.get('/system_stats');
expect(response.ok()).toBe(true);
const stats = await response.json();
// comfyui.config.mjs's launcher reports the host it owns. The execution suite has no
// web server: it starts its own host per worker behind a port refusal.
if (testInfo.config.webServer) {
const ownedBase = process.env.PM_COMFY_HOST_BASE;
expect(ownedBase, 'the launcher reported the host it started').toBeTruthy();
expect(hostBaseDirectory(stats), 'the host answering the port is the one this run started').toBe(ownedBase);
}
const info = stats.system;
const info = (await response.json()).system;
expect(info.comfyui_version).toBe(runtime.serverVersion);
expect(info.pytorch_version).toBe(`${runtime.torchVersion}+cpu`);
expect(info.required_frontend_version).toBe(runtime.frontendVersion);
+4 -4
View File
@@ -594,12 +594,12 @@
"anchor": null,
"expect": {
"metadata_status": "available",
"state": "resolved",
"reason": "",
"state": "ambiguous",
"reason": "Multiple sampler branches",
"reason_code": null,
"selection": null,
"positive": "a red fox in snow, detailed fur",
"negative": "blurry, lowres",
"positive": null,
"negative": null,
"candidate_count": 0,
"facts": {
"seed": {
File diff suppressed because one or more lines are too long
-17
View File
@@ -355,20 +355,3 @@ test("keepalive is forwarded only when asked, so a request can outlive its page"
for (const call of fetch.calls) call.resolve(jsonResponse(200, { success: true }));
await Promise.all(writes);
});
test("ApiError keeps a validated running job on busy and a validated source on invalid-source", () => {
const body = { running: { id: "a".repeat(32), title: "Scan for metadata" }, source: "output/gone" };
const busy = new ApiError(409, "busy", "busy", null, body);
assert.deepEqual([busy.running, busy.source], [{ id: "a".repeat(32), title: "Scan for metadata" }, null]);
assert.notEqual(busy.running, body.running, "server objects must be copied");
const invalid = new ApiError(400, "refused", "invalid-source", null, body);
assert.deepEqual([invalid.running, invalid.source], [null, "output/gone"]);
for (const running of [null, "x", { id: 1, title: "t" }, { id: "a b", title: "t" }, { id: "a", title: "" },
{ id: "a", title: "x".repeat(201) }, { id: "a".repeat(65), title: "t" }]) {
assert.equal(new ApiError(409, "busy", "busy", null, { running }).running, null);
}
for (const source of [null, 7, "", "x".repeat(4097)]) {
assert.equal(new ApiError(400, "refused", "invalid-source", null, { source }).source, null);
}
assert.deepEqual([new ApiError(409, "old caller").running, new ApiError(409, "old caller").source], [null, null]);
});
-47
View File
@@ -61,50 +61,3 @@ test("infinite scrolling is off by default and must be a boolean", () => {
assert.equal(normalizeGallerySettings({ infiniteScroll: true }).infiniteScroll, true);
assert.equal(normalizeGallerySettings({ infiniteScroll: "yes" }).infiniteScroll, false);
});
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");
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({ workspaceDensity: "large" }).workspaceDensity, "large");
assert.equal(normalizeGallerySettings({ workspaceDensity: 3 }).workspaceDensity, "comfortable");
// The legacy gallery's own view mode is separate: it has no masonry.
assert.equal(normalizeGallerySettings({ defaultViewMode: "masonry" }).defaultViewMode, "grid");
});
test("a legacy gallery Save keeps the workspace keys and every other stored key", () => {
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const settings = require("../../web/js/gallery-settings.js");
const storage = new Map([["gallerySettings", JSON.stringify({ workspaceLayout: "masonry", workspaceDensity: "compact", someOtherTool: 1 })]]);
const controls = {
lazyLoadingToggle: { checked: true }, imageQualitySelect: { value: "high" }, defaultViewModeSelect: { value: "list" },
defaultLimitSelect: { value: "200" }, gridColumnsSelect: { value: "6" }, showImageInfoToggle: { checked: false },
autoLoadMetadataToggle: { checked: true }, cacheMetadataToggle: { checked: true }, showFilePathsToggle: { checked: false },
sidebarCollapsedByDefaultToggle: { checked: true }, infiniteScrollToggle: { checked: false }, debugModeToggle: { checked: false },
apiTimeoutInput: { value: "30" }, checkThumbnailsAtStartup: { checked: true }, videoAutoplayToggle: { checked: true },
videoMuteToggle: { checked: false }, videoLoopToggle: { checked: true },
};
const context = vm.createContext({
document: { addEventListener() {}, getElementById: id => controls[id] },
window: {}, console: { debug() {}, warn() {}, error() {} }, GallerySettings: settings,
localStorage: { getItem: key => storage.get(key) ?? null, setItem: (key, value) => storage.set(key, value) },
});
const file = path.join(__dirname, "../../web/js/gallery.js");
const Manager = vm.runInContext(`${fs.readFileSync(file, "utf8")}\nGalleryManager;`, context, { filename: file });
const manager = Object.create(Manager.prototype);
Object.assign(manager, { showNotification() {}, hideSettings() {}, applySettings() {} });
manager.saveSettings();
const saved = JSON.parse(storage.get("gallerySettings"));
assert.equal(saved.workspaceLayout, "masonry");
assert.equal(saved.workspaceDensity, "compact");
assert.equal(saved.someOtherTool, 1);
assert.equal(saved.defaultViewMode, "list");
assert.equal(saved.showImageInfo, false);
assert.equal(saved.videoMute, false);
});
-26
View File
@@ -82,32 +82,6 @@ test('a zeroed conditioning negative shows as an explicit empty negative without
}finally{scope.dispose();f.restore();}
});
test('a prompt that left out run-time trigger words says so under its text (D125)',()=>{
const f=dom(),scope=createScope(),container=new f.Element('div'),sent=[];
const note='Text added at run time, such as trigger words, is not included.';
try{
module.mountPromptBlocks(container,{parentScope:scope,sender:{open:roles=>sent.push(roles)},prompts:{positive:{status:'resolved',text:'own text',source:'runtime-parts-omitted'},negative:{status:'resolved',text:'blur',source:'graph-inferred'}}});
const notes=f.walk(container,node=>node.textContent===note);
assert.equal(notes.length,1,'one note, under the positive only');
assert.equal(notes[0].parent.attributes['aria-label'],'Positive prompt');
container.click(f.walk(container,node=>node.dataset.sendRole==='pair')[0]);
assert.deepEqual(sent,[{positive:'own text',negative:'blur'}],'the note is never sent');
}finally{scope.dispose();f.restore();}
});
test('a prompt a later stage added conditioning to says so, and both lines show together (D126)',()=>{
const f=dom(),scope=createScope(),container=new f.Element('div');
const added='Conditioning added at a later stage is not included.', runtime='Text added at run time, such as trigger words, is not included.';
try{
const view=module.mountPromptBlocks(container,{parentScope:scope,prompts:{positive:{status:'resolved',text:'base',source:'added-conditioning'}}});
assert.deepEqual(f.walk(container,node=>node.className==='small muted').map(node=>node.textContent),[added]);
view.update({positive:{status:'resolved',text:'base',source:'runtime-parts-omitted-and-added-conditioning'}});
assert.deepEqual(f.walk(container,node=>node.className==='small muted').map(node=>node.textContent),[runtime,added]);
view.update({positive:{status:'resolved',text:'base',source:'graph-inferred'}});
assert.equal(f.walk(container,node=>node.className==='small muted').length,0);
}finally{scope.dispose();f.restore();}
});
const unresolved=message=>({status:'unresolved',text:null,source:null,...(message===undefined?{}:{message})});
const statusLines=(f,container)=>f.walk(container,node=>node.tagName==='p'&&node.className==='prompt-text muted').map(node=>node.textContent);
test('unsupported node status names the class',()=>{
-16
View File
@@ -478,19 +478,3 @@ test('a refresh deferred behind a page load is dropped when the stream is dispos
await new Promise(resolve=>setImmediate(resolve));
assert.equal(requests.length,2,'no refresh runs after dispose');
});
test('defaults apply to a new visit but not to the current view or a saved view', async () => {
const { initialImagesView } = await source;
assert.equal(typeof initialImagesView, 'function', 'initialImagesView is exported');
const preferences = { workspaceLayout: 'masonry', workspaceDensity: 'compact' };
// A new visit starts from System › Appearance.
assert.deepEqual(initialImagesView({ preferences }), { layout: 'masonry', density: 'compact' });
// The current view (the last visit) keeps its own toolbar choices.
assert.deepEqual(initialImagesView({ prior: { layout: 'list', density: 'large' }, preferences }), { layout: 'list', density: 'large' });
assert.deepEqual(initialImagesView({ prior: {}, preferences }), { layout: 'grid', density: 'comfortable' });
// A saved view opens as it always did.
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' });
});
@@ -825,73 +825,3 @@ test('refresh re-reads the selected file and keeps the shown record until the ne
assert.equal(controller.state.record.identity.path, 'folder/b.png');
controller.dispose(); assert.equal(await controller.refresh(), undefined);
});
test('metadata off shows an explicit load and loads once on request', async () => {
const { api, requests } = pendingApi(), updates = [];
const controller = contract('createInspectionController')({ api, extractFacts: extractGenerationFacts, autoLoad: false });
controller.subscribe(state => updates.push(state));
// Selecting reads nothing: the state says the metadata waits for a request.
assert.equal(controller.select(image()), undefined);
assert.equal(requests.length, 0);
assert.equal(controller.state.deferred, true);
assert.equal(controller.state.loading, false);
assert.equal(controller.state.record, null);
// Selecting the same file again still reads nothing.
controller.select({ ...image() });
assert.equal(requests.length, 0);
const load = controller.load();
assert.equal(requests.length, 1);
assert.equal(controller.state.loading, true);
assert.equal(controller.state.deferred, false);
// A second press while it reads, and the same file selected again once it loaded, read nothing more.
controller.load();
assert.equal(requests.length, 1);
requests[0].resolve(envelope()); await load;
assert.equal(controller.state.record.generation.seed.value, '9007199254740993');
controller.load(); controller.select(image());
assert.equal(requests.length, 1);
// Another file waits for its own request again.
controller.select({ ...image(), path: 'folder/b.png', filename: 'b.png', url: image().url.replace('a%20%23%25', 'b') });
assert.equal(controller.state.deferred, true);
assert.equal(requests.length, 1);
// The default controller loads on selection, as before.
const eager = contract('createInspectionController')({ api, extractFacts: extractGenerationFacts });
eager.select(image());
assert.equal(requests.length, 2);
assert.equal(eager.state.deferred, false);
eager.dispose(); controller.dispose();
assert.equal(controller.load(), undefined);
});
test('cache off rereads the current selection; on reuses the one current record', async () => {
for (const cache of [true, false]) {
const { api, requests } = pendingApi();
const controller = contract('createInspectionController')({ api, extractFacts: extractGenerationFacts, cache });
const first = controller.select(image());
// A read in flight is shared either way.
controller.select(image());
assert.equal(requests.length, 1);
requests[0].resolve(envelope()); await first;
const again = controller.select(image());
assert.equal(requests.length, cache ? 1 : 2, `cache ${cache}`);
if (!cache) { requests[1].resolve(envelope()); await again; assert.equal(controller.state.record.identity.path, 'folder/a #%.png'); }
controller.dispose();
}
});
test('cache off never rereads a selection whose source changed, and refresh never loads a deferred one', async () => {
const { api, requests } = pendingApi();
const controller = contract('createInspectionController')({ api, extractFacts: extractGenerationFacts, cache: false });
const first = controller.select(image());
requests[0].reject(new ApiError(409, refreshMessage, 'source-changed')); await first;
assert.equal(controller.state.refreshRequired, true);
controller.select(image());
assert.equal(requests.length, 1);
controller.dispose();
const deferred = contract('createInspectionController')({ api, extractFacts: extractGenerationFacts, autoLoad: false });
deferred.select(image());
assert.equal(deferred.refresh(), undefined);
assert.equal(requests.length, 1);
assert.equal(deferred.state.deferred, true);
deferred.dispose();
});
-11
View File
@@ -136,17 +136,6 @@ test('an unusable semantics table is refused', () => {
for (const doc of [null, {schema: 2}, {schema: 1, samplers: []}]) assert.throws(() => resolver.compileSemantics(doc), resolver.SemanticsError);
});
test('the lineage section is compiled, and a bad one refuses the table', () => {
const doc = JSON.parse(fs.readFileSync(new URL('../../web/js/inspection-semantics.json', import.meta.url), 'utf8'));
assert.deepEqual(semantics.lineage.get('Image Filter\u00001'), { strategy: 'continue', input: 'latents' });
// Lineage describes no prompt: its classes are not table classes for the stage walk.
assert.equal(semantics.allClasses.has('VAEEncode'), false);
const row = { class: 'VAEEncode', slot: 0, strategy: 'continue', input: 'pixels' };
for (const lineage of [undefined, {}, [null], [{ ...row, slot: -1 }], [{ ...row, strategy: 'passthrough' }], [{ ...row, input: '' }], [row, row]]) {
assert.throws(() => resolver.compileSemantics({ ...doc, lineage }), resolver.SemanticsError, JSON.stringify(lineage));
}
});
// The committed server answers (tests/fixtures/local-inspect/resolver-vectors.json, written by
// `python3 -m tests.local_resolver_cases --write` and checked current by the Python suite).
test('committed server answers equal the local evidence', () => {
+5 -38
View File
@@ -124,51 +124,18 @@ test('real inspection navigation preserves slideshow across two advances and def
} finally {view.dispose();controller.dispose();parentScope.dispose();globalThis.setTimeout=oldSetTimeout;globalThis.clearTimeout=oldClearTimeout;f.restore();}
});
test('Viewer.js is built only on a loaded image and an unbuilt viewer is released at once', async () => {
const {reportedLocationAdapter:adapter} = await import('../../web/js/workspace/history-media.mjs');
const f = mountedDom(), parentScope = createScope(), controller = borrowedController(), items = [occurrenceMedia('1',1,'c'),occurrenceMedia('2',0,'d')];
const instances = [];
// On a loaded image Viewer.js schedules its inline build; until it runs `ready` is false and destroy() cancels it.
f.win.Viewer = class { constructor(node,options) { this.node = node; this.options = options; this.destroyed = 0; this.ready = false; this.viewed = false; instances.push(this); }
destroy() { this.destroyed++; } resize() {} };
const globals = {ResizeObserver:globalThis.ResizeObserver, requestAnimationFrame:globalThis.requestAnimationFrame, cancelAnimationFrame:globalThis.cancelAnimationFrame};
Object.assign(globalThis, {ResizeObserver:class { observe() {} disconnect() {} }, requestAnimationFrame:() => 1, cancelAnimationFrame() {}});
const view = module.createMediaViewer({container:f.container,parentScope,inspectionController:controller,adapter,sender:{open(){}}});
try {
const stageImage = () => f.walk(f.container,n => n.tagName === 'img')[0];
view.open({items,selectedKey:'d'.repeat(64)});
// Viewer.js's own cancel misses an image whose load event is still queued, so none exists before the load.
const unloaded = stageImage(), dropped = [];unloaded.removeAttribute = key => dropped.push(key);
assert.equal(instances.length, 0, 'no Viewer.js before the image loads');
f.walk(f.container,n => n.attributes['aria-label'] === 'Previous media')[0].fire('click');
assert.deepEqual(dropped, ['src'], 'an image left before it loads is released at once');
unloaded.fire('load');
assert.equal(instances.length, 0, 'its late load builds no Viewer.js');
stageImage().fire('load');
const [loading] = instances, image = loading.node, removed = [];
image.removeAttribute = key => removed.push(key);
// Next drops the image whose build is pending; a deferred destroy would let that build run there.
f.walk(f.container,n => n.attributes['aria-label'] === 'Next media')[0].fire('click');
stageImage().fire('load');assert.equal(instances.length, 2, 'the next image builds its own Viewer.js once loaded');
assert.equal(loading.destroyed, 1, 'an unbuilt viewer is destroyed at once');
assert.deepEqual(removed, ['src'], 'and its image is released at once');
loading.options.viewed.call(loading);
assert.equal(loading.destroyed, 1, 'never destroyed twice');
} finally { view.dispose(); parentScope.dispose(); f.restore(); Object.assign(globalThis, globals); }
});
test('an inline Viewer still loading its image is destroyed only after it has viewed it', async () => {
const {reportedLocationAdapter:adapter} = await import('../../web/js/workspace/history-media.mjs');
const f = mountedDom(), parentScope = createScope(), controller = borrowedController(), items = [occurrenceMedia('1',1,'c')];
const instances = [];
// A built (`ready`) inline Viewer.js's destroy() leaves an in-flight view running; its callback then needs a live instance.
f.win.Viewer = class { constructor(node,options) { this.node = node; this.options = options; this.destroyed = 0; this.ready = true; this.viewed = false; instances.push(this); }
// Viewer.js's inline destroy() leaves an in-flight view running; its callback then needs a live instance.
f.win.Viewer = class { constructor(node,options) { this.node = node; this.options = options; this.destroyed = 0; this.viewed = false; instances.push(this); }
destroy() { this.destroyed++; } resize() {} };
const globals = {ResizeObserver:globalThis.ResizeObserver, requestAnimationFrame:globalThis.requestAnimationFrame, cancelAnimationFrame:globalThis.cancelAnimationFrame};
Object.assign(globalThis, {ResizeObserver:class { observe() {} disconnect() {} }, requestAnimationFrame:() => 1, cancelAnimationFrame() {}});
const view = module.createMediaViewer({container:f.container,parentScope,inspectionController:controller,adapter,sender:{open(){}}});
try {
view.open({items,selectedKey:'c'.repeat(64)});f.walk(f.container,n => n.tagName === 'img')[0].fire('load');
view.open({items,selectedKey:'c'.repeat(64)});
const [loading] = instances, image = loading.node, removed = [];
image.removeAttribute = key => removed.push(key);
f.walk(f.container,n => n.attributes['aria-label'] === 'Close image viewer')[0].fire('click');
@@ -181,7 +148,7 @@ test('an inline Viewer still loading its image is destroyed only after it has vi
loading.options.viewed.call(loading);
assert.equal(loading.destroyed, 1, 'never destroyed twice');
view.open({items,selectedKey:'c'.repeat(64)});f.walk(f.container,n => n.tagName === 'img')[0].fire('load');
view.open({items,selectedKey:'c'.repeat(64)});
const shown = instances[1]; shown.viewed = true; shown.options.viewed.call(shown);
f.walk(f.container,n => n.attributes['aria-label'] === 'Close image viewer')[0].fire('click');
assert.equal(shown.destroyed, 1, 'a viewed image is destroyed at once');
@@ -269,7 +236,7 @@ test('viewer uses adapter.mediaUrl for image and video', async () => {
const img = f.walk(f.container,n => n.tagName === 'img')[0];
assert.equal(img.src, image.url, 'the blob URL is used exactly');
assert.doesNotMatch(img.src, /\?/);
img.fire('load');assert.equal(instances[0].node, img, 'Viewer.js shows the blob image');
assert.equal(instances[0].node, img, 'Viewer.js shows the blob image');
const download = f.walk(f.container,n => n.attributes['aria-label'] === 'Download original')[0];
assert.equal(download.href, image.url);assert.equal(download.download, 'a.png');
f.walk(f.container,n => n.attributes['aria-label'] === 'Next media')[0].fire('click');
-193
View File
@@ -1,193 +0,0 @@
// Browser display defaults (#249 System S3 Task 7): the workspace reads and writes the legacy gallery's own
// `gallerySettings` object (Q3, "One display configuration"), writes only the keys it changed, never consumes the
// legacy paging keys, and says when this browser refused to keep a change.
import test from 'node:test';
import assert from 'node:assert/strict';
import gallerySettings from '../../web/js/gallery-settings.js';
const module = await import('../../web/js/workspace/preferences.mjs').catch(error => {
if (error.code !== 'ERR_MODULE_NOT_FOUND') throw error;
return {};
});
globalThis.GallerySettings = gallerySettings;
const KEY = 'gallerySettings';
/** A localStorage stand-in holding `text` under the settings key; `setItem` can be made to throw. */
function storage(text = null, { quota = false } = {}) {
const writes = [];
const store = {
text,
writes,
getItem(key) { assert.equal(key, KEY); return store.text; },
setItem(key, value) {
assert.equal(key, KEY);
writes.push(value);
if (quota) { const error = new Error('Quota exceeded'); error.name = 'QuotaExceededError'; throw error; }
store.text = value;
},
removeItem() { assert.fail('Preferences never remove the shared settings'); },
};
return store;
}
const denied = () => { const error = new Error('Denied'); error.name = 'SecurityError'; throw error; };
test('normalizes new workspace keys and keeps legacy keys', () => {
assert.equal(typeof module.readPreferences, 'function', 'preferences.mjs exports readPreferences');
module.resetTabPreferences();
const legacy = { defaultViewMode: 'list', gridColumns: 5, defaultLimit: 200, thumbnailPromptDismissed: 'sig', extra: 1 };
const store = storage(JSON.stringify({ ...legacy, workspaceLayout: 'masonry', workspaceDensity: 'compact', showFilePaths: false }));
const { values, persisted } = module.readPreferences(() => store);
assert.equal(persisted, true);
assert.equal(values.workspaceLayout, 'masonry');
assert.equal(values.workspaceDensity, 'compact');
assert.equal(values.showFilePaths, false);
// 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.workspaceDensity, 'comfortable');
assert.equal(fallback.videoMute, true);
// The legacy normalizer knows the new keys and still keeps unknown ones.
const normalized = gallerySettings.normalizeGallerySettings({ ...legacy, workspaceLayout: 'list' });
assert.equal(normalized.workspaceLayout, 'list');
assert.equal(normalized.workspaceDensity, 'comfortable');
assert.equal(normalized.extra, 1);
assert.equal(normalized.thumbnailPromptDismissed, 'sig');
assert.deepEqual(gallerySettings.WORKSPACE_LAYOUTS, ['grid', 'list', 'masonry']);
assert.deepEqual(gallerySettings.WORKSPACE_DENSITIES, ['comfortable', 'large', 'compact']);
});
test('write touches only dirty keys', () => {
module.resetTabPreferences();
const stored = { defaultViewMode: 'list', gridColumns: 5, thumbnailsGenerated: true, extra: { kept: true }, videoMute: false };
const store = storage(JSON.stringify(stored));
const result = module.writePreferences({ workspaceLayout: 'masonry', showImageInfo: false }, () => store);
assert.deepEqual(result, { persisted: true, conflicts: [] });
assert.equal(store.writes.length, 1);
// Every other key, legacy or unknown, is written back exactly as it was stored: no defaults are filled in.
assert.deepEqual(JSON.parse(store.text), { ...stored, workspaceLayout: 'masonry', showImageInfo: false });
// It re-reads the stored object, so a key another page wrote after this one read is kept.
store.text = JSON.stringify({ ...JSON.parse(store.text), gridColumns: 9 });
module.writePreferences({ videoLoop: false }, () => store);
assert.equal(JSON.parse(store.text).gridColumns, 9);
assert.equal(JSON.parse(store.text).workspaceLayout, 'masonry');
// An empty or malformed stored value starts from an empty object, never the defaults.
for (const text of [null, '{broken', '[]', '42']) {
const fresh = storage(text);
assert.equal(module.writePreferences({ cacheMetadata: false }, () => fresh).persisted, true);
assert.deepEqual(JSON.parse(fresh.text), { cacheMetadata: false });
}
// Only workspace keys with valid values are accepted; nothing is written for a bad change.
for (const bad of [{ gridColumns: 4 }, { defaultLimit: 50 }, { workspaceLayout: 'mosaic' }, { videoMute: 'yes' }, { imageQuality: 'ultra' }]) {
const untouched = storage(JSON.stringify(stored));
assert.throws(() => module.writePreferences(bad, () => untouched), TypeError);
assert.equal(untouched.writes.length, 0);
}
// A key changed elsewhere since this tab read it (its baseline) is reported, and the explicit save still wins.
const elsewhere = storage(JSON.stringify({ videoLoop: false }));
const outcome = module.writePreferences({ videoLoop: true, videoMute: false }, () => elsewhere, { baseline: { videoLoop: true, videoMute: true } });
assert.deepEqual(outcome, { persisted: true, conflicts: ['videoLoop'] });
assert.equal(JSON.parse(elsewhere.text).videoLoop, true);
});
test('denied storage reports tab-only', () => {
module.resetTabPreferences();
const read = module.readPreferences(denied);
assert.equal(read.persisted, false);
assert.equal(read.values.workspaceLayout, 'grid');
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.
const tab = module.readPreferences(denied);
assert.equal(tab.persisted, false);
assert.equal(tab.values.workspaceLayout, 'list');
assert.equal(tab.values.videoAutoplay, true);
// A getItem that throws is denied storage too.
const throwing = { getItem: denied, setItem: denied };
assert.equal(module.readPreferences(() => throwing).values.workspaceLayout, 'list');
assert.equal(module.writePreferences({ workspaceDensity: 'large' }, () => throwing).persisted, false);
// Once storage accepts a write, the tab copy of that key gives way to the stored value.
const working = storage(JSON.stringify({ workspaceLayout: 'masonry' }));
assert.equal(module.writePreferences({ workspaceLayout: 'masonry' }, () => working).persisted, true);
assert.equal(module.readPreferences(() => working).values.workspaceLayout, 'masonry');
module.resetTabPreferences();
assert.equal(module.readPreferences(denied).values.videoAutoplay, false);
});
test('quota error keeps previous bytes', () => {
module.resetTabPreferences();
const before = JSON.stringify({ gridColumns: 6, workspaceDensity: 'large' });
const store = storage(before, { quota: true });
const result = module.writePreferences({ workspaceDensity: 'compact' }, () => store);
assert.deepEqual(result, { persisted: false, conflicts: [] });
assert.equal(store.writes.length, 1);
assert.equal(store.text, before);
// This tab keeps the change it could not store.
assert.equal(module.readPreferences(() => store).values.workspaceDensity, 'compact');
module.resetTabPreferences();
assert.equal(module.readPreferences(() => store).values.workspaceDensity, 'large');
});
/** A window stand-in that records its storage listeners. */
function fakeWindow() {
const listeners = new Set();
return {
listeners,
addEventListener(type, listener) { assert.equal(type, 'storage'); listeners.add(listener); },
removeEventListener(type, listener) { assert.equal(type, 'storage'); listeners.delete(listener); },
fire(event) { for (const listener of [...listeners]) listener(event); },
};
}
test('storage event updates clean values and flags dirty conflicts', () => {
module.resetTabPreferences();
const store = storage(JSON.stringify({ workspaceLayout: 'list', videoMute: false }));
const win = fakeWindow();
const seen = [];
const unsubscribe = module.subscribePreferences(win, next => seen.push(next), () => store);
assert.equal(win.listeners.size, 1);
// Another key, or another storage area's event, is not ours.
win.fire({ key: 'pm_update_notice_dismissed', storageArea: store });
win.fire({ key: KEY, storageArea: { other: true } });
assert.equal(seen.length, 0);
win.fire({ key: KEY, storageArea: store });
win.fire({ key: null, storageArea: store }); // localStorage.clear() elsewhere
assert.equal(seen.length, 2);
assert.equal(seen[0].values.workspaceLayout, 'list');
unsubscribe();
assert.equal(win.listeners.size, 0);
// The reconciliation of the System drafts: clean values follow the other page; a key this tab has a draft for and
// the other page changed is a conflict, and the draft stays.
const baseline = { workspaceLayout: 'grid', videoMute: true, videoLoop: true };
const next = { workspaceLayout: 'list', videoMute: true, videoLoop: false };
assert.deepEqual(module.reconcilePreferences({ baseline, drafts: { videoMute: false, videoLoop: false }, next }), { conflicts: [] });
assert.deepEqual(module.reconcilePreferences({ baseline, drafts: { workspaceLayout: 'masonry' }, next }), { conflicts: ['workspaceLayout'] });
assert.deepEqual(module.reconcilePreferences({ baseline, drafts: { workspaceLayout: 'list' }, next }), { conflicts: [] });
});
test('legacy paging keys are never consumed by the workspace', () => {
module.resetTabPreferences();
const store = storage(JSON.stringify({ defaultLimit: 500, gridColumns: 12, infiniteScroll: true, lazyLoading: false,
defaultViewMode: 'list', debugMode: true, apiTimeout: 9, thumbnailsGenerated: true, checkThumbnailsAtStartup: false }));
const { values } = module.readPreferences(() => store);
assert.deepEqual(Object.keys(values).sort(), [...module.WORKSPACE_PREFERENCE_KEYS].sort());
for (const key of ['defaultLimit', 'gridColumns', 'infiniteScroll', 'lazyLoading', 'defaultViewMode', 'debugMode', 'apiTimeout',
'thumbnailsGenerated', 'checkThumbnailsAtStartup', 'thumbnailPromptDismissed']) assert.equal(Object.hasOwn(values, key), false, key);
// The display defaults are the gallery's own defaults for these keys.
const defaults = module.preferenceDefaults();
for (const key of module.WORKSPACE_PREFERENCE_KEYS) assert.equal(defaults[key], gallerySettings.DEFAULT_SETTINGS[key], key);
});
test('a later save flushes the changes kept for this tab only', () => {
module.resetTabPreferences();
const store = storage(JSON.stringify({ gridColumns: 4 }), { quota: true });
assert.equal(module.writePreferences({ workspaceLayout: 'list' }, () => store).persisted, false);
// Storage accepts writes again: saving another key stores the tab-only change with it, so "Saved" is true for both.
const working = storage(store.text);
assert.deepEqual(module.writePreferences({ videoLoop: false }, () => working), { persisted: true, conflicts: [] });
assert.deepEqual(JSON.parse(working.text), { gridColumns: 4, workspaceLayout: 'list', videoLoop: false });
// Nothing is overlaid any more: another page's later change reads through.
working.text = JSON.stringify({ ...JSON.parse(working.text), workspaceLayout: 'masonry' });
assert.equal(module.readPreferences(() => working).values.workspaceLayout, 'masonry');
module.resetTabPreferences();
});
@@ -1,188 +0,0 @@
// System › Appearance (#249 System S3 Task 8): the concept's "Browsing & display" rows (app.js 676–678) on the drafts
// of the one Save row, their stored values (Q3 mappings), the host timeout's 0–300 whole seconds and the D97 rows.
const test = require('node:test');
const assert = require('node:assert/strict');
const gallerySettings = require('../../web/js/gallery-settings.js');
const appearanceModule = import('../../web/js/workspace/system-appearance.mjs');
const stateModule = import('../../web/js/workspace/system-state.mjs');
const coreModule = import('../../web/js/shared/toolkit/core.js');
globalThis.GallerySettings = gallerySettings;
/** A minimal DOM whose elements count their live listeners (a listener's signal aborts it, as in a browser). */
function fakeDOM() {
const previous = global.document;
const elements = [];
class Element {
constructor(tag) {
this.tagName = tag.toUpperCase();
this.children = [];
this.attributes = {};
this.listeners = [];
this.dataset = {};
this.className = '';
this.id = '';
this.value = '';
this._text = '';
elements.push(this);
}
get textContent() { return this._text + this.children.map(child => child.textContent).join(''); }
set textContent(text) { this.children = []; this._text = String(text); }
append(...nodes) {
for (const node of nodes) {
const child = typeof node === 'string' ? Object.assign(new Element('#text'), { _text: node }) : node;
child.parentNode = this;
this.children.push(child);
}
}
remove() { if (this.parentNode) this.parentNode.children = this.parentNode.children.filter(child => child !== this); this.parentNode = null; }
replaceChildren(...nodes) { this.children = []; this._text = ''; this.append(...nodes); }
setAttribute(name, value) { this.attributes[name] = String(value); }
getAttribute(name) { return Object.hasOwn(this.attributes, name) ? this.attributes[name] : null; }
removeAttribute(name) { delete this.attributes[name]; }
addEventListener(type, callback, options = {}) {
if (options.signal?.aborted) return;
const entry = { type, callback };
this.listeners.push(entry);
options.signal?.addEventListener('abort', () => { this.listeners = this.listeners.filter(item => item !== entry); });
}
removeEventListener(type, callback) { this.listeners = this.listeners.filter(item => item.type !== type || item.callback !== callback); }
dispatch(type) { for (const { callback } of this.listeners.filter(item => item.type === type)) callback({ type, target: this }); }
click() { this.dispatch('click'); }
}
global.document = {
createElement: tag => new Element(tag),
createTextNode: text => Object.assign(new Element('#text'), { _text: String(text) }),
};
const walk = (node, found = []) => { found.push(node); node.children.forEach(child => walk(child, found)); return found; };
return {
listenerCount: () => elements.reduce((sum, element) => sum + element.listeners.length, 0),
find: (root, predicate) => walk(root).find(predicate),
tags: node => node.children.map(child => child.tagName.toLowerCase()),
restore() { global.document = previous; },
};
}
const BROWSER = { workspaceLayout: 'grid', workspaceDensity: 'comfortable', autoLoadMetadata: true, cacheMetadata: true,
imageQuality: 'medium', videoAutoplay: false, videoMute: true, videoLoop: true, showFilePaths: true, showImageInfo: true,
sidebarCollapsedByDefault: false };
async function mounted({ host = { webui_display_mode: 'newtab', result_timeout: 5 }, browser = BROWSER, hostFails = false } = {}) {
const { mountAppearance } = await appearanceModule;
const { createSystemDrafts } = await stateModule;
const { createScope } = await coreModule;
const dom = fakeDOM();
let hostValues = null;
const drafts = createSystemDrafts({
readBrowser: () => ({ ...browser }), readRuntime: () => ({}),
readHost: async () => { if (hostFails) throw new Error('offline'); return { values: hostValues, revision: 'sha256:one' }; },
writeBrowser: async () => ({ persisted: true }), writeHost: async () => ({ success: true, persisted: true }), writeRuntime: async () => ({}),
});
await drafts.load('browser');
const scope = createScope();
const [surface] = mountAppearance(null, { scope, drafts });
const byId = id => dom.find(surface, node => node.dataset?.setting === id);
return { dom, drafts, scope, surface, byId, loadHost: async () => { hostValues = host; await drafts.load('host'); },
fixHost: () => { hostFails = false; } };
}
test('the surface is the concept section with the concept rows in order, then the D97 rows', async () => {
const { APPEARANCE_ROWS } = await appearanceModule;
const { dom, surface, scope } = await mounted();
try {
assert.equal(surface.className, 'surface');
assert.equal(surface.children[0].textContent, 'Browsing & display');
assert.equal(surface.children[1].textContent, 'One set of defaults; toolbar changes apply to the current view.');
const rows = surface.children.slice(2);
const titles = rows.map(row => row.children[0].children[0].textContent);
assert.deepEqual(titles, ['Default layout', 'Thumbnail size', 'Continuous browsing', 'Load metadata on selection',
'Cache metadata in this browser', 'Image loading quality', 'Autoplay videos', 'Mute videos by default', 'Loop videos',
'Show file paths', 'Open from ComfyUI', 'Node search auto-hide (seconds)', 'Show image details on cards',
'Collapse viewer details by default', 'Reset display defaults']);
const continuous = rows[2];
assert.deepEqual(dom.tags(continuous.children[0]), ['h3', 'p']);
assert.equal(continuous.children[1].className, 'chip blue');
assert.equal(continuous.children[1].textContent, 'Infinite scroll');
assert.equal(rows.at(-1).className, 'operation');
assert.equal(APPEARANCE_ROWS.filter(Array.isArray).length, 13);
} finally { scope.dispose(); assert.equal(dom.listenerCount(), 0); dom.restore(); }
});
test('added rows reach their consumers: each control edits its stored key with the Q3 values', async () => {
const { dom, drafts, scope, byId, loadHost } = await mounted();
try {
// Host rows stay disabled until the server settings answered.
assert.equal(byId('open-mode').disabled, true);
assert.equal(byId('timeout').disabled, true);
await loadHost();
assert.equal(byId('open-mode').disabled, false);
assert.equal(byId('timeout').value, '5');
assert.deepEqual(byId('image-quality').children.map(option => [option.value, option.textContent]),
[['medium', 'Optimized'], ['high', 'Original'], ['low', 'Fast']]);
assert.deepEqual(byId('open-mode').children.map(option => [option.value, option.textContent]),
[['newtab', 'New tab'], ['popup', 'Popup window']]);
const edits = [['default-view', 'masonry'], ['density', 'large'], ['image-quality', 'low'], ['open-mode', 'popup']];
for (const [id, value] of edits) { byId(id).value = value; byId(id).dispatch('change'); }
for (const id of ['metadata-auto', 'metadata-cache', 'video-muted', 'video-loop', 'paths', 'card-details']) {
byId(id).checked = false; byId(id).dispatch('change');
}
for (const id of ['video-auto', 'viewer-details']) { byId(id).checked = true; byId(id).dispatch('change'); }
assert.deepEqual(drafts.changes('browser'), { workspaceLayout: 'masonry', workspaceDensity: 'large', imageQuality: 'low',
autoLoadMetadata: false, cacheMetadata: false, videoMute: false, videoLoop: false, showFilePaths: false, showImageInfo: false,
videoAutoplay: true, sidebarCollapsedByDefault: true });
assert.deepEqual(drafts.changes('host'), { webui_display_mode: 'popup' });
} finally { scope.dispose(); dom.restore(); }
});
test('the auto-hide timeout takes whole seconds from 0 to 300 only', async () => {
const { resultTimeout } = await appearanceModule;
assert.deepEqual(['0', '30', ' 300 ', '301', '-1', '2.5', '', 'abc', '1e2'].map(resultTimeout), [0, 30, 300, null, null, null, null, null, null]);
const { dom, drafts, scope, byId, loadHost } = await mounted();
try {
await loadHost();
const timeout = byId('timeout');
timeout.value = '45'; timeout.dispatch('change');
assert.deepEqual(drafts.changes('host'), { result_timeout: 45 });
timeout.value = '301'; timeout.dispatch('change');
assert.deepEqual(drafts.changes('host'), {});
assert.deepEqual([...drafts.failed('host')], ['result_timeout']);
timeout.value = '0'; timeout.dispatch('change');
assert.deepEqual(drafts.changes('host'), { result_timeout: 0 });
assert.deepEqual([...drafts.failed('host')], []);
} finally { scope.dispose(); dom.restore(); }
});
test('reset restores defaults without touching other keys', async () => {
const { DISPLAY_KEYS } = await appearanceModule;
const changed = { ...BROWSER, workspaceLayout: 'list', videoMute: false, showImageInfo: false, sidebarCollapsedByDefault: true };
const { dom, drafts, scope, surface, loadHost } = await mounted({ browser: changed });
try {
await loadHost();
drafts.set('host', 'result_timeout', 9);
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 });
// 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());
} finally { scope.dispose(); dom.restore(); }
});
test('host rows that could not be read stay disabled and say so; a later read clears it', async () => {
const { HOST_UNAVAILABLE } = await appearanceModule;
const { dom, drafts, scope, byId, surface, fixHost } = await mounted({ hostFails: true });
const notes = () => surface.children.slice(2).flatMap(row => row.children[0].children.slice(2).map(note => [row.children[0].children[0].textContent, note.className, note.textContent]));
try {
assert.deepEqual(notes(), []);
assert.equal(await drafts.reloadHost(), false);
assert.deepEqual(notes(), [['Open from ComfyUI', 'small', HOST_UNAVAILABLE], ['Node search auto-hide (seconds)', 'small', HOST_UNAVAILABLE]]);
assert.equal(byId('timeout').disabled, true);
// Repaints keep one line per row.
drafts.set('browser', 'videoMute', false);
assert.equal(notes().length, 2);
fixHost();
assert.equal(await drafts.reloadHost(), true);
assert.deepEqual(notes(), []);
} finally { scope.dispose(); assert.equal(dom.listenerCount(), 0); dom.restore(); }
});
-145
View File
@@ -1,145 +0,0 @@
// System › Sources & scans (#249 System S4 Task 11): the concept's "Image sources" list (app.js 679–681) built from the
// server's configured, effective and watched sources (GET /settings), the host draft of `gallery_root_paths`, the
// 3.2.0 update notice's dismissal and the Refresh files toast (D98).
const test = require('node:test');
const assert = require('node:assert/strict');
const sourcesModule = import('../../web/js/workspace/system-sources.mjs');
const STATUS = Object.freeze({
configured: [
{ label: 'ComfyUI output', path: 'output', valid: true, code: null, reason: null },
{ label: 'ComfyUI input', path: 'input', valid: true, code: null, reason: null },
],
effective: [
{ label: 'ComfyUI output', path: 'output', root_index: 0, watch: 'watching' },
{ label: 'ComfyUI input', path: 'input', root_index: 1, watch: 'manual' },
],
auto_detected: false,
});
test('each effective source is the concept row: label, "<path> · watching|manual scan", Scan changes or Scan folder', async () => {
const { sourceRows } = await sourcesModule;
const saved = ['output', 'input'];
const { rows, note } = sourceRows({ sources: STATUS, saved, current: saved });
assert.equal(note, null);
assert.deepEqual(rows, [
{ key: 'output', title: 'ComfyUI output', description: 'output · watching', scan: { label: 'Scan changes', root: 0, path: 'output' },
remove: 'output' },
{ key: 'input', title: 'ComfyUI input', description: 'input · manual scan', scan: { label: 'Scan folder', root: 1, path: 'input' },
remove: 'input' },
]);
});
test('watching only with a live observer; restart pending otherwise (D98)', async () => {
const { sourceRows, WATCH_COPY } = await sourcesModule;
assert.deepEqual(WATCH_COPY, { watching: 'watching', stopping: 'watching until restart', manual: 'manual scan',
restart_pending: 'restart pending' });
const sources = { ...STATUS, effective: STATUS.effective.map(source => ({ ...source, watch: 'restart_pending' })) };
const { rows } = sourceRows({ sources, saved: ['output', 'input'], current: ['output', 'input'] });
assert.deepEqual(rows.map(row => [row.description, row.scan.label]),
[['output · restart pending', 'Scan folder'], ['input · restart pending', 'Scan folder']]);
});
test('a configured source that cannot be used shows the server reason and only Remove', async () => {
const { sourceRows } = await sourcesModule;
const sources = { ...STATUS, configured: [...STATUS.configured,
{ label: 'unmounted-drive', path: 'unmounted-drive', valid: false, code: 'not-found', reason: 'Gallery root does not exist' }] };
const saved = ['output', 'input', 'unmounted-drive'];
const { rows } = sourceRows({ sources, saved, current: saved });
assert.deepEqual(rows.at(-1), { key: 'unmounted-drive', title: 'unmounted-drive',
description: 'unmounted-drive · Gallery root does not exist', scan: null, remove: 'unmounted-drive' });
});
test('an added source is listed as not saved yet with its validated label; a removed one leaves the list', async () => {
const { sourceRows } = await sourcesModule;
const labels = new Map([['output/archive', 'archive']]);
const { rows, note } = sourceRows({ sources: STATUS, saved: ['output', 'input'], current: ['input', 'output/archive'], labels });
assert.equal(note, null);
assert.deepEqual(rows.map(row => [row.title, row.description, row.scan?.label ?? null, row.remove]), [
['ComfyUI input', 'input · manual scan', 'Scan folder', 'input'],
['archive', 'output/archive · not saved yet', null, 'output/archive'],
]);
// Without a recorded label (a reload of System), the folder name stands in.
assert.equal(sourceRows({ sources: STATUS, saved: [], current: ['output/new-collection'] }).rows[0].title, 'new-collection');
});
test('removing the last entry states auto-detect (D98)', async () => {
const { sourceRows, NOTES } = await sourcesModule;
const { rows, note } = sourceRows({ sources: STATUS, saved: ['output', 'input'], current: [] });
assert.deepEqual(rows, []);
assert.equal(note, NOTES.removedAll);
assert.equal(NOTES.removedAll, 'No source is configured. After saving, ComfyUI’s output folder is used.');
});
test('auto-detected sources are listed without Remove, with the line that says how they were chosen', async () => {
const { sourceRows, NOTES } = await sourcesModule;
const auto = { configured: [], effective: [{ label: 'ComfyUI output', path: 'output', root_index: 0, watch: 'manual' }], auto_detected: true };
assert.deepEqual(sourceRows({ sources: auto, saved: [], current: [] }), { rows: [
{ key: 'auto:0', title: 'ComfyUI output', description: 'output · manual scan', scan: { label: 'Scan folder', root: 0, path: 'output' },
remove: null },
], note: NOTES.autoDetected });
// Every configured source unusable: ComfyUI's output folder is used instead.
const fallback = { ...auto, configured: [{ label: 'gone', path: 'gone', valid: false, code: 'not-found', reason: 'Gallery root does not exist' }] };
const result = sourceRows({ sources: fallback, saved: ['gone'], current: ['gone'] });
assert.deepEqual(result.rows.map(row => [row.title, row.remove]), [['gone', 'gone'], ['ComfyUI output', null]]);
assert.equal(result.note, NOTES.fallback);
assert.equal(sourceRows({ sources: { configured: [], effective: [], auto_detected: false }, saved: [], current: [] }).note, NOTES.none);
});
test('a path listed twice in the draft is one row', async () => {
const { sourceRows } = await sourcesModule;
assert.equal(sourceRows({ sources: STATUS, saved: ['output', 'input'], current: ['output', 'output'] }).rows.length, 1);
});
test('roots equal to the saved list are the saved list itself, so the draft is clean again', async () => {
const { draftRoots } = await sourcesModule;
const saved = ['output', 'input'];
assert.equal(draftRoots(['output', 'input'], saved), saved);
assert.deepEqual(draftRoots(['input'], saved), ['input']);
assert.notEqual(draftRoots(['input', 'output'], saved), saved);
assert.deepEqual(draftRoots([], undefined), []);
});
test('notice Later persists the 3.2.0 dismissal (pm_update_notice_dismissed); storage that throws keeps it for this view', async () => {
const { noticeDismissed, dismissNotice, NOTICE_KEY, NOTICE_VERSION } = await sourcesModule;
assert.deepEqual([NOTICE_KEY, NOTICE_VERSION], ['pm_update_notice_dismissed', '3.2.0']);
const values = new Map();
const storage = { getItem: key => values.get(key) ?? null, setItem: (key, value) => values.set(key, String(value)) };
assert.equal(noticeDismissed(() => storage), false);
values.set(NOTICE_KEY, '3.1.0');
assert.equal(noticeDismissed(() => storage), false);
assert.equal(dismissNotice(() => storage), true);
assert.equal(values.get(NOTICE_KEY), '3.2.0');
assert.equal(noticeDismissed(() => storage), true);
const denied = () => { throw new Error('SecurityError'); };
assert.equal(noticeDismissed(denied), false);
assert.equal(dismissNotice(denied), false);
assert.equal(dismissNotice(() => ({ setItem() { throw new Error('QuotaExceededError'); } })), false);
});
test('Refresh files toasts the rescan counts, or says it finished when they are missing', async () => {
const { refreshedText } = await sourcesModule;
assert.equal(refreshedText({ total: 1234, roots: 2 }), 'Files refreshed. 1,234 files in 2 output folders.');
assert.equal(refreshedText({ total: 1, roots: 1 }), 'Files refreshed. 1 file in 1 output folder.');
assert.equal(refreshedText({ total: -1, roots: 1 }), 'Files refreshed.');
assert.equal(refreshedText(null), 'Files refreshed.');
});
test('monitoring switched off: a source still watched says so until the restart (D128)', async () => {
const { sourceRows } = await sourcesModule;
const sources = { ...STATUS, effective: [{ ...STATUS.effective[0], watch: 'stopping' }, STATUS.effective[1]] };
const { rows } = sourceRows({ sources, saved: ['output', 'input'], current: ['output', 'input'] });
assert.deepEqual(rows.map(row => [row.description, row.scan.label]),
[['output · watching until restart', 'Scan changes'], ['input · manual scan', 'Scan folder']]);
});
test('a valid configured source missing from the effective list is another spelling of a listed folder (D128)', async () => {
const { sourceRows, DUPLICATE_SOURCE } = await sourcesModule;
assert.equal(DUPLICATE_SOURCE, 'same folder as another source');
const sources = { ...STATUS, configured: [...STATUS.configured,
{ label: 'output', path: 'output/.', valid: true, code: null, reason: null }] };
const saved = ['output', 'input', 'output/.'];
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/.' });
});
-105
View File
@@ -192,7 +192,6 @@ test('status text per outcome', async () => {
partial: 'Some changes were not saved. Review the highlighted settings.',
tabOnly: 'Saved for this tab only; browser storage is unavailable.',
conflict: 'Settings changed elsewhere. Reload this section to see them.',
restart: 'Restart ComfyUI to apply:',
});
const clean = { browser: 'clean', host: 'clean', runtime: 'clean' };
assert.equal(statusText(clean), 'No unsaved changes.');
@@ -260,107 +259,3 @@ test('a failed read keeps the store usable and drafts survive a reload (Q10)', a
assert.equal(failing.dirty(), false);
assert.throws(() => createSystemDrafts({ ...domains, writeHost: null }), TypeError);
});
test('a rejected value drops its draft and is highlighted; a conflict elsewhere keeps the drafts', async () => {
const domains = adapters({ browser: { videoMute: true, videoLoop: true }, host: { result_timeout: 5 } });
const drafts = await loaded(domains);
assert.deepEqual(drafts.saved('browser'), { videoMute: true, videoLoop: true });
drafts.set('host', 'result_timeout', 12);
assert.equal(drafts.dirty('host'), true);
drafts.reject('host', 'result_timeout');
assert.equal(drafts.dirty('host'), false);
assert.deepEqual([...drafts.failed('host')], ['result_timeout']);
assert.equal(drafts.get('host', 'result_timeout'), 5);
// A valid value clears the highlight.
drafts.set('host', 'result_timeout', 7);
assert.deepEqual([...drafts.failed('host')], []);
drafts.discard();
drafts.set('browser', 'videoMute', false);
drafts.conflict('browser', []);
assert.equal(drafts.statusText(), 'Unsaved changes.');
drafts.conflict('browser', ['videoMute']);
assert.equal(drafts.statusText(), 'Settings changed elsewhere. Reload this section to see them.');
assert.deepEqual([...drafts.failed('browser')], ['videoMute']);
assert.equal(drafts.get('browser', 'videoMute'), false);
assert.throws(() => drafts.reject('nowhere', 'x'), TypeError);
});
test('a rejected value says why in the status until the next edit', async () => {
const drafts = await loaded(adapters({ host: { result_timeout: 5 } }));
drafts.reject('host', 'result_timeout');
assert.equal(drafts.statusText(), 'Some changes were not saved. Review the highlighted settings.');
drafts.set('host', 'result_timeout', 9);
assert.equal(drafts.statusText(), 'Unsaved changes.');
});
test('a browser write refused as changed elsewhere keeps the draft and reads the browser values again', async () => {
const domains = adapters({ browser: { workspaceDensity: 'comfortable' } });
const drafts = await loaded(domains);
drafts.set('browser', 'workspaceDensity', 'compact');
domains.outcome.browser = () => { throw Object.assign(new Error('changed'), { code: 'settings-changed' }); };
assert.deepEqual(await drafts.save(), { browser: 'conflict', host: 'clean', runtime: 'clean' });
assert.deepEqual(domains.calls.map(([name]) => name), ['writeBrowser', 'readBrowser']);
assert.equal(drafts.get('browser', 'workspaceDensity'), 'compact');
assert.deepEqual([...drafts.failed('browser')], ['workspaceDensity']);
assert.equal(drafts.statusText(), 'Settings changed elsewhere. Reload this section to see them.');
});
test('host read state says whether the server settings could be read', async () => {
const { createSystemDrafts } = await source;
const domains = adapters({ host: { result_timeout: 5 } });
let fail = true;
const drafts = createSystemDrafts({ ...domains, readHost: async () => { if (fail) throw new Error('offline'); return { values: {}, revision: 'r' }; } });
assert.equal(drafts.hostState(), 'pending');
assert.equal(await drafts.reloadHost(), false);
assert.equal(drafts.hostState(), 'failed');
fail = false;
assert.equal(await drafts.reloadHost(), true);
assert.equal(drafts.hostState(), 'loaded');
});
test('a host save names the settings that apply after a restart (D128)', async () => {
const { statusText, RESTART_LABELS } = await source;
assert.deepEqual(RESTART_LABELS, { monitoring_enabled: 'Watch for new outputs' });
const saved = { browser: 'clean', host: 'saved', runtime: 'clean' };
assert.equal(statusText({ ...saved, restart: ['monitoring_enabled'] }),
'Saved to the server configuration. Restart ComfyUI to apply: Watch for new outputs.');
// A field without a row label (the sources say "restart pending" per row) adds nothing.
assert.equal(statusText({ ...saved, restart: ['gallery_root_paths'] }), 'Saved to the server configuration.');
assert.equal(statusText({ ...saved, restart: [] }), 'Saved to the server configuration.');
const domains = adapters({ host: { monitoring_enabled: true } });
const drafts = await loaded(domains);
domains.outcome.host = () => ({ success: true, persisted: true, revision: 'sha256:two',
restart_required_fields: ['monitoring_enabled', 7] });
drafts.set('host', 'monitoring_enabled', false);
assert.deepEqual(await drafts.save(), { browser: 'clean', host: 'saved', runtime: 'clean', restart: ['monitoring_enabled'] });
assert.equal(drafts.statusText(), 'Saved to the server configuration. Restart ComfyUI to apply: Watch for new outputs.');
// The next edit is unsaved again.
drafts.set('host', 'monitoring_enabled', true);
assert.equal(drafts.statusText(), 'Unsaved changes.');
});
test('a host save refused for one source says the server reason and names it until the next edit', async () => {
const domains = adapters({ host: { gallery_root_paths: ['output', 'output/gone'] } });
const drafts = await loaded(domains);
const message = "output/gone can't be used: Gallery root does not exist. Remove it or reconnect it, then save.";
domains.outcome.host = () => { throw Object.assign(new Error(message), { code: 'invalid-source', source: 'output/gone' }); };
drafts.set('host', 'gallery_root_paths', ['output/gone']);
const outcome = await drafts.save();
assert.deepEqual(outcome, { browser: 'clean', host: 'failed', runtime: 'clean',
invalid: { source: 'output/gone', message } });
assert.equal(drafts.statusText(), message);
assert.equal(drafts.invalidSource(), 'output/gone');
assert.deepEqual([...drafts.failed('host')], ['gallery_root_paths']);
// Any other failure keeps the generic copy and names no source.
domains.outcome.host = () => { throw Object.assign(new Error('busy'), { code: 'busy' }); };
await drafts.save();
assert.equal(drafts.statusText(), 'Some changes were not saved. Review the highlighted settings.');
assert.equal(drafts.invalidSource(), null);
domains.outcome.host = () => { throw Object.assign(new Error(message), { code: 'invalid-source' }); };
await drafts.save();
assert.equal(drafts.invalidSource(), null);
drafts.set('host', 'gallery_root_paths', ['output']);
assert.equal(drafts.invalidSource(), null);
assert.equal(drafts.statusText(), 'Unsaved changes.');
});
-2
View File
@@ -29,7 +29,6 @@ VECTORS_PATH = (
# Committed record sets stay small; the large bound cases are built at test time.
MAX_VECTOR_CHARS = 20_000
COMMITTED_FUZZ = (0, 60)
COMMITTED_PASSTHROUGH = (0, 40)
SPACES = ["", " ", "\t", "\n", "\x1c", "\x85", " ", "", "
 "]
WORDS = ["fox", "snow", "café", "猫", "\U0001f600", "a,", "detailed fur", ""]
@@ -1381,7 +1380,6 @@ def committed_vectors():
}
cases.update(fuzz_cases(*COMMITTED_FUZZ))
cases.update(selection.selection_fuzz_cases(*COMMITTED_FUZZ))
cases.update(selection.passthrough_fuzz_cases(*COMMITTED_PASSTHROUGH))
vectors = [
{
"name": name,
+1 -304
View File
@@ -12,10 +12,8 @@ import json
import random
try:
from tests import passthrough_graphs as shapes
from tests.local_resolver_cases import EXECUTION_KEY, SPACES, WORDS, _Builder
except ImportError: # pragma: no cover - discover -s tests puts tests/ first
import passthrough_graphs as shapes
from local_resolver_cases import EXECUTION_KEY, SPACES, WORDS, _Builder
@@ -439,213 +437,10 @@ def _parameters():
return cases
def _passthrough():
"""Chains joined through lineage nodes, and the floor for added conditioning."""
cases = {
"hires-fix": shapes.hires_fix(),
"hires-fix-differs": shapes.hires_fix(second_positive=shapes.OTHER),
"hires-fix-unlisted": shapes.hires_fix(filter_class="MysteryImageEffect"),
"hires-fix-generated": shapes.generated_text_chain(),
"flux-three-stage": shapes.flux_three_stage(),
"flux-concat": shapes.flux_three_stage(composite="ConditioningConcat"),
"flux-average": shapes.flux_three_stage(composite="ConditioningAverage"),
"edit-batch": shapes.edit_batch(3),
}
graph = shapes.flux_three_stage(combined=(("37", 0), ("31", 0)))
graph["37"] = shapes.encode(shapes.OTHER)
cases["flux-without-base"] = graph
graph = shapes.flux_three_stage()
graph["38"] = shapes.encode("watermark")
graph["60"]["inputs"]["negative"] = ["38", 0]
cases["flux-other-negative"] = graph
graph = shapes.hires_fix()
graph["20"]["inputs"]["latent_image"] = ["10", 0]
graph["10"]["inputs"]["latent_image"] = ["20", 0]
cases["hires-fix-cycle"] = graph
graph = shapes.hires_fix()
graph["17"]["inputs"]["pixels"] = ["18", 0]
graph["18"] = {"class_type": "Reroute", "inputs": {"value": ["16", 0]}}
cases["hires-fix-reroute"] = graph
graph = shapes.hires_fix()
graph["24"] = {"class_type": "SaveImage", "inputs": {"images": ["11", 0]}}
cases["hires-fix-both-saved"] = graph
graph = shapes.hires_fix()
graph["20"]["inputs"]["latent_image"] = ["99", 0]
cases["hires-fix-missing-latent"] = graph
graph = shapes.flux_three_stage()
graph["38"] = {"class_type": "MysteryNegative", "inputs": {"seed": 1}}
graph["60"]["inputs"]["negative"] = ["38", 0]
cases["flux-negative-unsupported"] = graph
graph = shapes.flux_three_stage()
graph["39"] = {"class_type": "Reroute", "inputs": {"input": ["35", 0]}}
graph["60"]["inputs"]["positive"] = ["39", 0]
cases["flux-reroute-input"] = graph
graph = shapes.flux_three_stage()
graph["39"] = {
"class_type": "CLIPTextEncodeSDXL",
"inputs": {"text_g": shapes.POSITIVE, "text_l": shapes.STYLE},
}
graph["60"]["inputs"]["positive"] = ["39", 0]
cases["flux-encoder-parts"] = graph
cases.update(_passthrough_edges())
cases.update(_runtime_parts())
return cases
def _runtime_parts():
"""Option A: a PromptManager part fed by a run-time node is left out."""
cases = {"trigger-prepend": shapes.hires_fix_with_trigger_words()}
graph = shapes.hires_fix_with_trigger_words()
graph["2"]["inputs"].update(prepend_text="masterpiece", append_text=["8", 0])
graph["20"]["inputs"]["latent_image"] = ["4", 0]
cases["trigger-append-single"] = graph
graph = shapes.hires_fix_with_trigger_words()
graph["8"] = {"class_type": "PrimitiveString", "inputs": {"value": "trig9word"}}
cases["traced-prepend"] = graph
graph = shapes.hires_fix_with_trigger_words()
graph["8"]["class_type"] = "MysteryWords"
cases["unknown-prepend"] = graph
graph = shapes.hires_fix_with_trigger_words()
graph["9"] = {
"class_type": "StringConcatenate",
"inputs": {"string_a": "kept", "string_b": ["8", 0], "delimiter": ""},
}
graph["2"]["inputs"]["prepend_text"] = ["9", 0]
cases["indirect-prepend"] = graph
graph = shapes.hires_fix_with_trigger_words()
graph["2"]["inputs"].update(prepend_text="", text=["8", 0])
cases["runtime-main-text"] = graph
graph = shapes.hires_fix_with_trigger_words()
graph["2"]["class_type"] = "PromptManagerPair"
graph["2"]["inputs"]["negative_text"] = shapes.NEGATIVE
graph["10"]["inputs"]["negative"] = ["2", 1]
graph["20"]["inputs"]["negative"] = ["2", 1]
cases["trigger-pair"] = graph
graph = shapes.hires_fix_with_trigger_words()
graph["30"] = {"class_type": "PromptSearchList", "inputs": {"search": "x"}}
graph["2"]["inputs"]["append_text"] = ["30", 0]
cases["search-list-append"] = graph
for shape in ("text-encode", "chained", "concat"):
cases[f"through-{shape}"] = shapes.trigger_words_through(shape)
cases["branches-with-and-without"] = (
shapes.branches_with_and_without_trigger_words()
)
graph = shapes.hires_fix_with_trigger_words()
graph["9"] = shapes.prompt_manager(shapes.POSITIVE)
graph["20"]["inputs"]["positive"] = ["9", 0]
cases["stage-with-and-without"] = graph
graph = shapes.hires_fix()
graph["9"] = {
"class_type": "JoinStringMulti",
"inputs": {
"inputcount": 2,
"delimiter": " ",
"return_list": ["98", 0],
"string_1": "static words",
"string_2": "more",
},
}
graph["98"] = {"class_type": "PrimitiveBoolean", "inputs": {"value": False}}
graph["2"]["inputs"]["prepend_text"] = ["9", 0]
cases["join-multi-flag"] = graph
graph = shapes.flux_three_stage()
graph["31"] = shapes.encode(shapes.OTHER)
cases["flux-other-full-prompt"] = graph
graph = shapes.flux_three_stage()
graph["7"] = {
"class_type": "Lora Loader (LoraManager)",
"inputs": {"model": ["1", 0], "clip": ["2", 0], "text": "x"},
}
graph["8"] = shapes.trigger_toggle()
graph["30"]["inputs"]["prepend_text"] = ["8", 0]
cases["flux-trigger"] = graph
graph = shapes.flux_three_stage()
graph["60"] = {
"class_type": "SamplerCustomAdvanced",
"inputs": {"guider": ["63", 0], "latent_image": ["66", 0]},
}
graph["63"] = {
"class_type": "CFGGuider",
"inputs": {"positive": ["35", 0], "negative": ["33", 0]},
}
graph["66"] = {"class_type": "VAEEncode", "inputs": {"pixels": ["51", 0]}}
graph["67"] = {"class_type": "VAEDecode", "inputs": {"samples": ["60", 0]}}
graph["61"]["inputs"]["images"] = ["67", 0]
cases["flux-custom-advanced-chained"] = graph
return {f"runtime-{name}": graph for name, graph in cases.items()}
def _passthrough_edges():
"""Lineage and floor edges: branching refiners, other slots, cycles, guiders."""
cases = {}
graph = shapes.hires_fix()
graph["30"] = shapes.ksampler(["2", 0], ["3", 0], ["17", 0], denoise=0.4)
graph["31"] = {"class_type": "SaveImage", "inputs": {"images": ["30", 0]}}
cases["hires-fix-two-refiners"] = graph
graph = shapes.hires_fix()
graph["10"]["class_type"] = "SamplerCustom"
graph["11"]["inputs"]["samples"] = ["10", 1]
cases["hires-fix-denoised-slot"] = graph
graph = shapes.hires_fix()
graph["30"] = shapes.ksampler(["2", 0], ["3", 0], ["4", 0])
graph["31"] = {"class_type": "VAEDecode", "inputs": {"samples": ["30", 0]}}
graph["32"] = {"class_type": "VAEEncode", "inputs": {"pixels": ["31", 0]}}
graph["30"]["inputs"]["latent_image"] = ["32", 0]
cases["hires-fix-self-latent"] = graph
graph = shapes.hires_fix()
graph["30"] = shapes.ksampler(["2", 0], ["3", 0], ["32", 0])
graph["31"] = {"class_type": "VAEDecode", "inputs": {"samples": ["32", 0]}}
graph["32"] = {"class_type": "VAEEncode", "inputs": {"pixels": ["31", 0]}}
graph["33"] = {"class_type": "SaveImage", "inputs": {"images": ["30", 0]}}
cases["hires-fix-lineage-cycle"] = graph
graph = shapes.flux_three_stage()
graph["39"] = shapes.encode(shapes.OTHER)
graph["40"]["inputs"]["positive"] = ["39", 0]
cases["flux-base-stages-differ"] = graph
graph = shapes.flux_three_stage()
del graph["60"]["inputs"]["negative"]
cases["flux-no-last-negative"] = graph
graph = shapes.flux_three_stage()
graph["39"] = {"class_type": "MysteryPrompt", "inputs": {"seed": 1}}
graph["40"]["inputs"]["positive"] = ["39", 0]
graph["50"]["inputs"]["positive"] = ["39", 0]
cases["flux-unreadable-base"] = graph
graph = shapes.flux_three_stage()
graph["39"] = {
"class_type": "MysteryUpscaler",
"inputs": {"image": ["51", 0], "model": ["1", 0], "positive": ["34", 0]},
}
graph["52"]["inputs"]["images"] = ["39", 0]
cases["flux-unsupported-ancestry"] = graph
graph = shapes.flux_three_stage()
graph["60"] = {
"class_type": "SamplerCustomAdvanced",
"inputs": {
"noise": ["62", 0],
"guider": ["63", 0],
"sampler": ["64", 0],
"sigmas": ["65", 0],
"latent_image": ["66", 0],
},
}
graph["62"] = {"class_type": "RandomNoise", "inputs": {"noise_seed": 1}}
graph["63"] = {
"class_type": "CFGGuider",
"inputs": {"model": ["1", 0], "positive": ["35", 0], "negative": ["33", 0]},
}
graph["64"] = {"class_type": "KSamplerSelect", "inputs": {"sampler_name": "euler"}}
graph["65"] = {"class_type": "BasicScheduler", "inputs": {"steps": 4}}
graph["66"] = {"class_type": "VAEEncode", "inputs": {"pixels": ["52", 0]}}
graph["67"] = {"class_type": "VAEDecode", "inputs": {"samples": ["60", 0]}}
graph["61"]["inputs"]["images"] = ["67", 0]
cases["flux-custom-advanced-last"] = graph
return {f"edge-{name}": graph for name, graph in cases.items()}
def selection_cases():
"""Named record sets for every Task 9 path, each a ``read_inspection_file`` dict."""
cases = {}
for group in (_samplers(), _previews(), _detailers(), _passthrough()):
for group in (_samplers(), _previews(), _detailers()):
for name, graph in group.items():
cases[f"select-{name}"] = {"prompt": json.dumps(graph, ensure_ascii=False)}
cases.update(_parameters())
@@ -777,101 +572,3 @@ def selection_fuzz_cases(seed, count):
records.update(builder.records)
cases[f"select-fuzz-{seed}-{index}"] = records
return cases
_LINEAGE_HOPS = (
("VAEDecode", "samples"),
("VAEDecodeTiled", "samples"),
("Image Filter", "images"),
("ImageUpscaleWithModel", "image"),
("ImageScale", "image"),
("ImageScaleBy", "image"),
("VAEEncode", "pixels"),
("LatentUpscaleBy", "samples"),
("Reroute", "value"),
("MysteryImageEffect", "image"),
)
def passthrough_fuzz_cases(seed, count):
"""Seeded sampler chains joined by random lineage (and unlisted) hops.
Each later stage reads the earlier one's output through zero to four hops,
with a shared, a different, an added-to (combine, concat, average) or a
run-time positive, and a shared or different negative, under a saver or a
preview. The server is the oracle; nothing states the expected answer.
"""
rng = random.Random(seed)
cases = {}
for index in range(count):
graph = {
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "m"}},
"2": shapes.prompt_manager(rng.choice([shapes.POSITIVE, "fox"])),
"3": shapes.encode(shapes.NEGATIVE),
"4": {"class_type": "EmptyLatentBatch", "inputs": {"batch_size": 2}},
"5": shapes.encode(shapes.STYLE),
"6": shapes.encode(shapes.OTHER),
}
previous, next_id = ["4", 0], 10
for stage in range(rng.randint(2, 3)):
positive = ["2", 0]
choice = rng.randrange(8)
if stage and choice == 1:
positive = ["6", 0]
elif stage and choice in (2, 3, 4):
name = (
"ConditioningCombine",
"ConditioningConcat",
"ConditioningAverage",
)[choice - 2]
names = shapes._COMPOSITE_INPUTS[name]
parts = [["2", 0], rng.choice([["5", 0], ["6", 0]])]
rng.shuffle(parts)
graph[str(next_id)] = {
"class_type": name,
"inputs": dict(zip(names, parts)),
}
positive, next_id = [str(next_id), 0], next_id + 1
elif stage and choice == 5:
graph[str(next_id)] = {
"class_type": "FluxGuidance",
"inputs": {"guidance": 3.5, "conditioning": ["2", 0]},
}
positive, next_id = [str(next_id), 0], next_id + 1
negative = ["6", 0] if stage and rng.random() < 0.15 else ["3", 0]
latent = previous
if stage:
image = previous
for _ in range(rng.randrange(5)):
name, key = rng.choice(_LINEAGE_HOPS)
graph[str(next_id)] = {"class_type": name, "inputs": {key: image}}
image, next_id = [str(next_id), 0], next_id + 1
latent = image
sampler = shapes.ksampler(positive, negative, latent)
if stage and rng.random() < 0.2:
sampler = {
"class_type": "UltimateSDUpscale",
"inputs": {
"image": latent,
"model": ["1", 0],
"positive": positive,
"negative": negative,
},
}
graph[str(next_id)] = sampler
previous, next_id = [str(next_id), 0], next_id + 1
if rng.random() < 0.3:
saver = rng.choice(["SaveImage", "PreviewImage"])
graph[str(next_id)] = {
"class_type": saver,
"inputs": {"images": previous},
}
next_id += 1
graph[str(next_id)] = {
"class_type": "SaveImage",
"inputs": {"images": previous},
}
cases[f"select-passthrough-fuzz-{seed}-{index}"] = {
"prompt": json.dumps(graph, ensure_ascii=False)
}
return cases
-332
View File
@@ -1,332 +0,0 @@
"""Synthetic multi-stage graphs for the pass-through lineage rule.
Each builder reproduces one graph shape with neutral texts and generic names:
a hires-fix or refiner chain (sampler, decode, image filter, model upscale,
scale, encode, sampler), a three-stage Flux run whose last stage combines the
base prompt with a style trigger, an image-edit batch of samplers with distinct
prompts, and a chain whose prompt is generated at run time. They are shared by
the resolver tests and the browser-port parity cases; no user file is involved.
"""
import copy
POSITIVE = "a lighthouse on a cliff, golden hour"
NEGATIVE = "blurry, lowres"
OTHER = "a red bicycle in the rain"
STYLE = "film grain, analog photo"
def encode(text):
return {"class_type": "CLIPTextEncode", "inputs": {"text": text, "clip": ["1", 1]}}
def prompt_manager(text, prepend=""):
return {
"class_type": "PromptManager",
"inputs": {
"text": text,
"category": "",
"tags": "",
"search_text": "",
"prepend_text": prepend,
"append_text": "",
"clip": ["1", 1],
},
}
def ksampler(positive, negative, latent, denoise=1.0):
return {
"class_type": "KSampler",
"inputs": {
"seed": 7,
"steps": 20,
"cfg": 7.0,
"sampler_name": "euler",
"scheduler": "normal",
"denoise": denoise,
"model": ["1", 0],
"positive": positive,
"negative": negative,
"latent_image": latent,
},
}
def hires_fix(second_positive=None, filter_class="Image Filter"):
"""Two KSamplers joined in image space, like a hires-fix or refiner run.
Sampler 10 runs on a custom batch latent; its output goes through a tiled
decode, an interactive image filter, a model upscale and a scale (sized by a
resolution node reading the filtered image), then is encoded again for
sampler 20 (denoise 0.25), which is decoded and saved.
"""
graph = {
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "m"}},
"2": prompt_manager(POSITIVE),
"3": encode(NEGATIVE),
"4": {
"class_type": "EmptyLatentBatch",
"inputs": {"width": 1024, "height": 512, "batch_size": 4},
},
"10": ksampler(["2", 0], ["3", 0], ["4", 0]),
"11": {
"class_type": "VAEDecodeTiled",
"inputs": {"tile_size": 512, "samples": ["10", 0], "vae": ["1", 2]},
},
"12": {
"class_type": filter_class,
"inputs": {"timeout": 600, "ontimeout": "send none", "images": ["11", 0]},
},
"13": {"class_type": "UpscaleModelLoader", "inputs": {"model_name": "x4"}},
"14": {
"class_type": "ImageUpscaleWithModel",
"inputs": {"upscale_model": ["13", 0], "image": ["12", 0]},
},
"15": {
"class_type": "ResolutionCalculator",
"inputs": {"scale_factor": 1.5, "image": ["12", 0]},
},
"16": {
"class_type": "ImageScale",
"inputs": {
"upscale_method": "nearest-exact",
"width": ["15", 0],
"height": ["15", 1],
"crop": "disabled",
"image": ["14", 0],
},
},
"17": {
"class_type": "VAEEncode",
"inputs": {"pixels": ["16", 0], "vae": ["1", 2]},
},
"20": ksampler(["2", 0], ["3", 0], ["17", 0], denoise=0.25),
"21": {
"class_type": "VAEDecodeTiled",
"inputs": {"tile_size": 512, "samples": ["20", 0], "vae": ["1", 2]},
},
"22": {
"class_type": "SaveImage",
"inputs": {"filename_prefix": "hires", "images": ["21", 0]},
},
"23": {"class_type": "PlaySound|pysssss", "inputs": {"any": ["21", 0]}},
}
if second_positive is not None:
graph["5"] = encode(second_positive)
graph["20"]["inputs"]["positive"] = ["5", 0]
return graph
_COMPOSITE_INPUTS = {
"ConditioningCombine": ("conditioning_1", "conditioning_2"),
"ConditioningConcat": ("conditioning_to", "conditioning_from"),
"ConditioningAverage": ("conditioning_to", "conditioning_from"),
}
def flux_three_stage(combined=(("30", 0), ("31", 0)), composite="ConditioningCombine"):
"""FluxGuidance base, a refiner through an image filter, then a tiled upscale.
Samplers 40 and 50 share FluxGuidance(PromptManager 30) and a zeroed empty
negative. Sampler 50 refines 40's filtered image (denoise 0.25). The final
UltimateSDUpscale 60 reads 50's image, scaled down, and a positive that is
``composite`` of ``combined`` (by default the base PromptManager and a
style-trigger encoder 31).
"""
names = _COMPOSITE_INPUTS[composite]
graph = {
"1": {"class_type": "UNETLoader", "inputs": {"unet_name": "flux"}},
"2": {"class_type": "DualCLIPLoader", "inputs": {"type": "flux"}},
"3": {"class_type": "VAELoader", "inputs": {"vae_name": "ae"}},
"30": prompt_manager(POSITIVE),
"31": encode(STYLE),
"32": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "", "clip": ["2", 0]},
},
"33": {
"class_type": "ConditioningZeroOut",
"inputs": {"conditioning": ["32", 0]},
},
"34": {
"class_type": "FluxGuidance",
"inputs": {"guidance": 3.5, "conditioning": ["30", 0]},
},
"35": {
"class_type": composite,
"inputs": dict(zip(names, map(list, combined))),
},
"36": {
"class_type": "EmptyLatentBatch",
"inputs": {"width": 1080, "height": 1920, "batch_size": 4},
},
"40": ksampler(["34", 0], ["33", 0], ["36", 0]),
"41": {
"class_type": "VAEDecode",
"inputs": {"samples": ["40", 0], "vae": ["3", 0]},
},
"42": {
"class_type": "Image Filter",
"inputs": {"timeout": 600, "images": ["41", 0]},
},
"43": {"class_type": "PreviewImage", "inputs": {"images": ["42", 0]}},
"44": {
"class_type": "VAEEncode",
"inputs": {"pixels": ["42", 0], "vae": ["3", 0]},
},
"50": ksampler(["34", 0], ["33", 0], ["44", 0], denoise=0.25),
"51": {
"class_type": "VAEDecode",
"inputs": {"samples": ["50", 0], "vae": ["3", 0]},
},
"52": {
"class_type": "ImageScaleDownBy",
"inputs": {"scale_by": 0.75, "images": ["51", 0]},
},
"53": {"class_type": "PreviewImage", "inputs": {"images": ["51", 0]}},
"54": {
"class_type": "Image Comparer (rgthree)",
"inputs": {"image_a": ["51", 0], "image_b": ["42", 0]},
},
"60": {
"class_type": "UltimateSDUpscale",
"inputs": {
"upscale_by": 2.0,
"seed": 7,
"steps": 4,
"denoise": 0.35,
"image": ["52", 0],
"model": ["1", 0],
"positive": ["35", 0],
"negative": ["33", 0],
"vae": ["3", 0],
},
},
"61": {
"class_type": "SaveImage",
"inputs": {"filename_prefix": "flux", "images": ["60", 0]},
},
}
return graph
def edit_batch(count=3):
"""Image-edit samplers with distinct prompts, all saved with one prefix."""
graph = {
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "m"}},
"2": {"class_type": "LoadImage", "inputs": {"image": "ref.png"}},
"3": {
"class_type": "VAEEncode",
"inputs": {"pixels": ["2", 0], "vae": ["1", 2]},
},
"4": {"class_type": "PrimitiveString", "inputs": {"value": "edit"}},
}
for index in range(count):
base = 100 + index * 10
graph[str(base)] = {
"class_type": "TextEncodeQwenImageEdit",
"inputs": {
"prompt": f"edit number {index}: change the pose",
"clip": ["1", 1],
},
}
graph[str(base + 1)] = ksampler([str(base), 0], [str(base), 0], ["3", 0])
graph[str(base + 2)] = {
"class_type": "VAEDecode",
"inputs": {"samples": [str(base + 1), 0], "vae": ["1", 2]},
}
graph[str(base + 3)] = {
"class_type": "SaveImage",
"inputs": {"filename_prefix": ["4", 0], "images": [str(base + 2), 0]},
}
return graph
def trigger_toggle(words="trig9word"):
"""LoraManager's TriggerWord Toggle: its output is decided at run time."""
return {
"class_type": "TriggerWord Toggle (LoraManager)",
"inputs": {
"group_mode": True,
"default_active": True,
"toggle_trigger_words": {"__value__": [{"text": words, "active": True}]},
"orinalMessage": words,
"trigger_words": ["7", 2],
},
}
def hires_fix_with_trigger_words():
"""``hires_fix`` whose PromptManager prepends LoRA trigger words (node 8)."""
graph = hires_fix()
graph["7"] = {
"class_type": "Lora Loader (LoraManager)",
"inputs": {"model": ["1", 0], "clip": ["1", 1], "text": "<lora:x:1>"},
}
graph["8"] = trigger_toggle()
graph["2"]["inputs"]["prepend_text"] = ["8", 0]
return graph
def prompt_manager_text(text, prepend=""):
"""PromptManagerText: a STRING PromptManager (prepend/main/append)."""
return {
"class_type": "PromptManagerText",
"inputs": {
"text": text,
"category": "",
"tags": "",
"search_text": "",
"prepend_text": prepend,
"append_text": "",
},
}
def trigger_words_through(shape):
"""``hires_fix_with_trigger_words`` with nodes between the toggle and sampler.
``text-encode``: PromptManagerText (prepend <- toggle) -> CLIPTextEncode.
``chained``: a PromptManager whose ``text`` reads that PromptManagerText.
``concat``: PromptManagerText -> StringConcatenate(..., "extra") -> encoder.
"""
graph = hires_fix_with_trigger_words()
graph["9"] = prompt_manager_text(POSITIVE, ["8", 0])
if shape == "chained":
graph["2"]["inputs"].update(prepend_text="", text=["9", 0])
elif shape == "text-encode":
graph["2"] = {"class_type": "CLIPTextEncode", "inputs": {"text": ["9", 0]}}
else:
graph["19"] = {
"class_type": "StringConcatenate",
"inputs": {"string_a": ["9", 0], "string_b": "extra", "delimiter": ", "},
}
graph["2"] = {"class_type": "CLIPTextEncode", "inputs": {"text": ["19", 0]}}
return graph
def branches_with_and_without_trigger_words():
"""Two saved branches: one PromptManager prepends trigger words, one does not."""
graph = hires_fix_with_trigger_words()
graph["9"] = prompt_manager(POSITIVE)
graph["20"]["inputs"].update(positive=["9", 0], latent_image=["4", 0])
return graph
def generated_text_chain():
"""``hires_fix`` whose positive text comes from a run-time text generator."""
graph = hires_fix()
graph["2"] = {
"class_type": "CLIPTextEncode",
"inputs": {"text": ["6", 0], "clip": ["1", 1]},
}
graph["6"] = {
"class_type": "TextGenerate",
"inputs": {"prompt": "write a prompt", "model": ["1", 0]},
}
return graph
def copied(graph):
return copy.deepcopy(graph)
-35
View File
@@ -52,7 +52,6 @@ class AdminAPITestCase(AioHTTPTestCase):
self.outside_dir = Path(self.tmpdir) / "outside"
self.outside_dir.mkdir()
self.addAsyncCleanup(self._drain_and_remove)
self.config_path = os.path.join(self.tmpdir, "config.json")
self._orig_env = {
key: os.environ.pop(key, None) for key in (CONFIG_PATH_ENV, EXTRA_ROOTS_ENV)
@@ -90,23 +89,6 @@ class AdminAPITestCase(AioHTTPTestCase):
sys.modules.pop("folder_paths", None)
else:
sys.modules["folder_paths"] = self._orig_folder_paths
async def _drain_and_remove(self):
"""The last cleanup: no job outlives the temporary database.
Registered first, so it runs after every cleanup a test added (which
may release its own gated jobs). A job still running then, such as a
scan a failed assertion never awaited, is cancelled and awaited
before the database and the tree are removed.
"""
api = getattr(self, "api", None)
jobs = api._jobs.list() if api is not None else []
tasks = [
job.task for job in jobs if job.task is not None and not job.task.done()
]
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
PromptModel.close_all_instances() # restore may have swapped api.db
assert_closed(self, self.tmpdir)
shutil.rmtree(self.tmpdir, ignore_errors=True)
@@ -886,23 +868,6 @@ class TestDeleteDuplicatesEndpoint(AdminAPITestCase):
self.assertFalse(target.exists())
self.assertTrue(thumb_dir.exists())
async def test_deleted_files_leave_the_cached_gallery_listing(self):
# The listing is cached for 30 s; a delete must not leave the gallery
# listing (and counting) a file that is gone.
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.output_dir)]
for name in ("keep.png", "gone.png"):
(self.output_dir / name).write_bytes(b"same bytes")
async def listed():
resp = await self.client.get("/prompt_manager/images/output")
data = await resp.json()
return data["total"], sorted(i["filename"] for i in data["images"])
self.assertEqual(await listed(), (2, ["gone.png", "keep.png"]))
resp = await self._post({"image_paths": ["gone.png"]})
self.assertEqual((await resp.json())["deleted_count"], 1)
self.assertEqual(await listed(), (1, ["keep.png"]))
def test_unexpected_error_per_file_is_reported_without_path(self):
self.api._delete_one_duplicate = lambda *a: (_ for _ in ()).throw(
PermissionError(13, "Permission denied", os.path.join(self.tmpdir, "x.png"))
-57
View File
@@ -68,55 +68,6 @@ class CardSeed(unittest.TestCase):
self.assertIn(expected, (111, 222))
self.assertEqual(info_seed(info), str(expected))
def test_a_chain_the_inspector_names_one_stage_of_shows_no_differing_seed(self):
# The inspector shows the named final stage's seed (D19, pass-through
# chains); the parser shows another stage's. Never two seeds: none.
extra = {"2": {"class_type": "EmptyLatentImage", "inputs": {}}}
extra["20"] = {"class_type": "VAEDecode", "inputs": {"samples": ["10", 0]}}
extra["21"] = {
"class_type": "FaceDetailer",
"inputs": {
"seed": 333,
"image": ["20", 0],
"model": ["4", 0],
"positive": ["6", 0],
"negative": ["7", 0],
},
}
info = graph(seeded(111), extra=extra)
self.assertEqual(extract_from_records(info["prompt"], None)["seed"], 111)
self.assertIsNone(info_seed(info))
# The same chain on one seed keeps it; so does a refiner over a decode.
extra["21"]["inputs"]["seed"] = 111
self.assertEqual(info_seed(graph(seeded(111), extra=extra)), "111")
refiner = seeded(222, latent_image=["22", 0])
extra["22"] = {"class_type": "VAEEncode", "inputs": {"pixels": ["20", 0]}}
del extra["21"]
self.assertIsNone(info_seed(graph(seeded(111), refiner, extra=extra)))
refiner["inputs"]["seed"] = 111
self.assertEqual(info_seed(graph(seeded(111), refiner, extra=extra)), "111")
def test_a_seed_that_agrees_with_the_named_stage_is_kept(self):
# Review I3: a saved sampler (seed 7) beside a preview-only sampler
# (seed 5): the parser and the inspector both show 7, as on main.
extra = {"2": {"class_type": "EmptyLatentImage", "inputs": {}}}
extra["20"] = {"class_type": "VAEDecode", "inputs": {"samples": ["10", 0]}}
extra["21"] = {"class_type": "SaveImage", "inputs": {"images": ["20", 0]}}
extra["30"] = {"class_type": "VAEDecode", "inputs": {"samples": ["11", 0]}}
extra["31"] = {"class_type": "PreviewImage", "inputs": {"images": ["30", 0]}}
info = graph(seeded(7), seeded(5), extra=extra)
self.assertEqual(info_seed(info), "7")
def test_a_named_ambiguous_chain_keeps_the_parser_seed(self):
# Review M2: multistage-differs names a sampler, but its facts merge,
# and the inspector fills the parser's seed: the card keeps it.
extra = {"2": {"class_type": "EmptyLatentImage", "inputs": {}}}
extra["8"] = encoder("another scene")
extra["20"] = {"class_type": "VAEDecode", "inputs": {"samples": ["10", 0]}}
extra["22"] = {"class_type": "VAEEncode", "inputs": {"pixels": ["20", 0]}}
refiner = ksampler(("8", 0), ("7", 0), seed=222, latent_image=["22", 0])
self.assertEqual(info_seed(graph(seeded(111), refiner, extra=extra)), "111")
def test_card_seed_linked_input_follows_shared_parser(self):
primitive = {"class_type": "PrimitiveNode", "inputs": {"value": 555}}
info = graph(seeded(["20", 0]), extra={"20": primitive})
@@ -256,7 +207,6 @@ class CardSeed(unittest.TestCase):
self.assertIsNone(parsed_positive(unparsable))
def test_every_shared_case_reads_the_projected_graphs(self):
withheld = []
for name, case in CASES.items():
with self.subTest(case=name):
info = {k: v for k, v in case.items() if v is not None}
@@ -275,18 +225,11 @@ class CardSeed(unittest.TestCase):
except GenerationParamsError:
self.assertIsNone(params)
continue
if name == "shared-prompt-pipeline":
# The inspector names the detailer (seed 333); the parser
# reads the base sampler (111): the card shows none.
self.assertEqual(expected["seed"], 111)
expected = {**expected, "seed": None}
withheld.append(name)
self.assertEqual(params, expected)
# Prompt text is the same as from a plain parse of the records.
if len(values) == len(info):
raw = extract_from_records(case["prompt"], case["workflow"])
self.assertEqual(params["positivePrompt"], raw["positivePrompt"])
self.assertEqual(withheld, ["shared-prompt-pipeline"])
if __name__ == "__main__":
+5 -17
View File
@@ -24,9 +24,6 @@ from database.operations import PromptDatabase
from tests.open_handles import assert_closed
from utils.hashing import generate_prompt_hash
# A bound on thread hand-offs that only a hung test reaches (never a pacing timer).
THREAD_WAIT_SECONDS = 120
def _count_prompts(path):
"""Row count read through a brand-new connection (closed before return)."""
@@ -353,13 +350,7 @@ class TestRestoreFromFile(BackupTestCase):
def worker():
self.db.search_prompts(text="old") # opens this thread's connection
ready.set()
# Wait for the restore itself, not for a timer: a stalled Windows
# runner once took longer than 5 s to restore, and the worker then
# read the old database before the restore had run.
if not proceed.wait(THREAD_WAIT_SECONDS):
results["error"] = "the restore did not finish"
self.db.close()
return
proceed.wait(5)
try:
results["texts"] = [p["text"] for p in self.db.search_prompts()]
except Exception as exc: # noqa: BLE001 - reported through results
@@ -369,15 +360,12 @@ class TestRestoreFromFile(BackupTestCase):
thread = threading.Thread(target=worker)
thread.start()
self.assertTrue(ready.wait(THREAD_WAIT_SECONDS))
ready.wait(5)
try:
self.db.model.restore_from_file(src)
finally:
proceed.set()
thread.join(THREAD_WAIT_SECONDS)
self.db.model.restore_from_file(src)
proceed.set()
thread.join(5)
self.assertFalse(thread.is_alive())
self.assertNotIn("error", results)
self.assertEqual(results["texts"], ["restored"])
self.assertEqual([p["text"] for p in self.db.search_prompts()], ["restored"])
+3 -18
View File
@@ -23,11 +23,7 @@ sys.modules.setdefault("server", _mock_server)
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from py.config import ( # noqa: E402
OUTSIDE_ALLOWED_REASON,
GalleryConfig,
PromptManagerConfig,
)
from py.config import GalleryConfig, PromptManagerConfig # noqa: E402
EXTRA_ROOTS_ENV = "PROMPT_MANAGER_EXTRA_GALLERY_ROOTS"
@@ -162,10 +158,9 @@ class GalleryRootTestCase(unittest.TestCase):
class TestValidateGalleryRootRejects(GalleryRootTestCase):
def test_filesystem_root_rejected(self):
"""Refused as outside the allowed folders: no reason names the layout."""
ok, reason = GalleryConfig.validate_gallery_root(os.path.abspath(os.sep))
self.assertFalse(ok)
self.assertEqual(reason, OUTSIDE_ALLOWED_REASON)
self.assertIn("root", reason.lower())
@unittest.skipUnless(os.name == "nt", "drive roots only exist on Windows")
def test_windows_drive_root_rejected(self):
@@ -175,17 +170,7 @@ class TestValidateGalleryRootRejects(GalleryRootTestCase):
def test_home_directory_rejected(self):
ok, reason = GalleryConfig.validate_gallery_root(os.path.expanduser("~"))
self.assertFalse(ok)
self.assertEqual(reason, OUTSIDE_ALLOWED_REASON)
def test_home_directory_inside_an_allowed_parent_is_still_refused(self):
"""Even when the environment opens the home's parent, home answers
like any outside path."""
home = os.path.expanduser("~")
os.environ[EXTRA_ROOTS_ENV] = os.path.dirname(home)
self.assertEqual(
GalleryConfig.gallery_root_problem(home),
("outside-allowed", OUTSIDE_ALLOWED_REASON),
)
self.assertIn("home", reason.lower())
def test_directory_outside_comfyui_rejected(self):
outside = Path(self.tmpdir) / "elsewhere"
+3 -8
View File
@@ -800,15 +800,10 @@ class TestUpstreamVerifiedNodeSemantics(unittest.TestCase):
prompt["31"] = dict(prompt["3"], inputs=dict(prompt["3"]["inputs"]))
prompt["31"]["inputs"]["latent_image"] = ["3", 0]
prompt["8"]["inputs"]["samples"] = ["31", 0]
# 31 continues 3's latent: the stages are compared, as for KSampler.
for anchor in (None, "9"):
result = _inspect({"prompt": prompt}, output_node_id=anchor)
self.assertEqual(
(result["state"], result["sampler_id"]), ("resolved", "31")
)
prompt["31"]["inputs"]["positive"] = ["7", 0]
# Unanchored, two listed samplers are two branches, as for KSampler.
self.assertEqual(_inspect({"prompt": prompt})["state"], "ambiguous")
result = _inspect({"prompt": prompt}, output_node_id="9")
self.assertEqual(result["reason_code"], "multistage-differs")
self.assertEqual(result["reason"], resolver.MULTISTAGE_UNATTRIBUTED_REASON)
def test_seed_variance_enhancer_passes_its_conditioning(self):
prompt = _f02(
+3 -105
View File
@@ -55,9 +55,6 @@ def _minimal():
"runtime": [],
"savers": {},
"non_file_outputs": [],
"lineage": [
{"class": "VAEEncode", "slot": 0, "strategy": "continue", "input": "pixels"}
],
"stage_inputs": {"shaped": ["positive"], "with_model": ["samples"]},
"facts": {
"configuration_outputs": {},
@@ -202,26 +199,16 @@ class SpecEntriesTest(unittest.TestCase):
},
)
# Combine and concat keep every part in effect (additive); an average
# blends its parts, so a part may be weighted away.
self.assertEqual(
table.composites["ConditioningCombine"],
{
"strategy": "combine",
"inputs": ("conditioning_1", "conditioning_2"),
"additive": True,
},
{"strategy": "combine", "inputs": ("conditioning_1", "conditioning_2")},
)
for name, additive in (
("ConditioningConcat", {"additive": True}),
("ConditioningAverage", {}),
):
for name in ("ConditioningConcat", "ConditioningAverage"):
self.assertEqual(
table.composites[name],
{
"strategy": "combine",
"inputs": ("conditioning_to", "conditioning_from"),
**additive,
},
)
@@ -270,12 +257,7 @@ class SpecEntriesTest(unittest.TestCase):
)
# D18: decided at run time.
# LoraManager's TriggerWord Toggle outputs the LoRA's trigger words as
# filtered at run time (option A leaves them out of a PromptManager).
self.assertEqual(
table.runtime,
frozenset({"PromptSearchList", "TriggerWord Toggle (LoraManager)"}),
)
self.assertEqual(table.runtime, frozenset({"PromptSearchList"}))
comfy = "{name}_{counter:05}_.{ext}"
for name, image in (
@@ -482,89 +464,6 @@ class SpecEntriesTest(unittest.TestCase):
self.assertIsInstance(table.composites["ConditioningCombine"]["inputs"], tuple)
class LineageTableTest(_TableCase):
"""``lineage``: outputs whose image or latent continues one input's."""
def test_repository_lineage_lists_the_pass_through_nodes(self):
table = load_semantics()
self.assertEqual(
{key: dict(entry) for key, entry in table.lineage.items()},
{
("VAEDecode", 0): {"strategy": "continue", "input": "samples"},
("VAEDecodeTiled", 0): {"strategy": "continue", "input": "samples"},
("VAEEncode", 0): {"strategy": "continue", "input": "pixels"},
("VAEEncodeTiled", 0): {"strategy": "continue", "input": "pixels"},
("ImageUpscaleWithModel", 0): {
"strategy": "continue",
"input": "image",
},
("ImageScale", 0): {"strategy": "continue", "input": "image"},
("ImageScaleBy", 0): {"strategy": "continue", "input": "image"},
("ImageScaleToTotalPixels", 0): {
"strategy": "continue",
"input": "image",
},
("LatentUpscale", 0): {"strategy": "continue", "input": "samples"},
("LatentUpscaleBy", 0): {"strategy": "continue", "input": "samples"},
("Image Filter", 0): {"strategy": "continue", "input": "images"},
("Image Filter", 1): {"strategy": "continue", "input": "latents"},
},
)
# Lineage describes no prompt: its classes are not prompt categories.
self.assertFalse({name for name, _ in table.lineage} & table.all_classes)
def test_lineage_rows_are_validated(self):
def row(**changes):
entry = {
"class": "VAEEncode",
"slot": 0,
"strategy": "continue",
"input": "pixels",
}
entry.update(changes)
return entry
cases = (
({"lineage": {}}, "must be a list"),
({"lineage": ["VAEEncode"]}, "must be an object"),
({"lineage": [row(strategy="passthrough")]}, "unknown strategy"),
({"lineage": [row(slot=-1)]}, "slot"),
({"lineage": [row(slot=True)]}, "slot"),
({"lineage": [row(input="")]}, "input name"),
({"lineage": [row(note="x")]}, "field"),
({"lineage": [row(), row()]}, "duplicate"),
({"lineage": [row(**{"class": ""})]}, "class"),
)
for index, (change, fragment) in enumerate(cases):
with self.subTest(case=index):
doc = _minimal()
doc.update(change)
self.assertRejected(doc, fragment)
doc = _minimal()
doc["lineage"].append(row(slot=1, input="latents"))
self.assertEqual(len(self.load_doc(doc).lineage), 2)
def test_a_lineage_class_may_also_carry_conditioning(self):
# InpaintModelConditioning-like: conditioning slots, a latent slot.
doc = _minimal()
doc["lineage"].append(
{"class": "FluxGuidance", "slot": 1, "strategy": "continue", "input": "x"}
)
self.assertIn(("FluxGuidance", 1), self.load_doc(doc).lineage)
def test_composite_additive_must_be_a_boolean(self):
doc = _minimal()
doc["composites"]["ConditioningCombine"] = {
"strategy": "combine",
"inputs": ["conditioning_1", "conditioning_2"],
"additive": "yes",
}
self.assertRejected(doc, "additive must be a boolean")
doc["composites"]["ConditioningCombine"]["additive"] = True
table = self.load_doc(doc)
self.assertIs(table.composites["ConditioningCombine"]["additive"], True)
class ValidationTest(_TableCase):
def test_minimal_table_loads(self):
table = self.load_doc(_minimal())
@@ -602,7 +501,6 @@ class ValidationTest(_TableCase):
{"literal", "strip-newlines", "join-multi", "concat"}
),
"savers": frozenset({"comfy-counter", "vhs-counter"}),
"lineage": frozenset({"continue"}),
},
)
with self.assertRaises(TypeError):
+1 -28
View File
@@ -52,7 +52,7 @@ from utils.prompt_inspector import (
)
from utils.prompt_inspector import inspect_prompt_evidence, unreadable_evidence
from utils.prompt_pair_resolver import ADDED_CONDITIONING_REASON, INSPECTION_PROFILE
from utils.prompt_pair_resolver import INSPECTION_PROFILE
try:
from tests import inspection_parity as parity
@@ -1293,33 +1293,6 @@ class LocalResolverParityTests(unittest.TestCase):
self.assertEqual(
answers["select-preview-dropped"]["resolution"]["sampler_id"], "3"
)
# Pass-through lineage: the chain shapes reach every new outcome.
self.assertEqual(
{
name: answers[f"select-{name}"]["resolution"]["reason"]
for name in ("hires-fix", "flux-three-stage", "hires-fix-differs")
},
{
"hires-fix": "",
"flux-three-stage": ADDED_CONDITIONING_REASON,
"hires-fix-differs": "Sampler stages have different prompts",
},
)
def test_local_roles_match_fuzzed_pass_through_chains(self):
cases = {}
for seed in range(4):
cases.update(selection_cases.passthrough_fuzz_cases(seed, 120))
answers = self._compare(cases)
reasons = [answer["resolution"]["reason"] for answer in answers.values()]
for reason in (
"",
"Sampler stages have different prompts",
"A later stage adds conditioning to the base prompt",
"Combined conditioning has different texts",
"Multiple sampler branches",
):
self.assertGreater(reasons.count(reason), 5, reason)
def test_local_roles_match_fuzzed_selection_sets(self):
cases = {}
-360
View File
@@ -1,360 +0,0 @@
"""ComfyUI's ``NaN``/``Infinity`` metadata ingests, and is stored as strict JSON.
ComfyUI serializes the prompt with Python's ``json.dumps``, so a node whose
``IS_CHANGED`` returns ``float("nan")`` (the "always re-run" idiom) writes
``"is_changed": [NaN]`` into every saved PNG; widgets can hold ``Infinity``.
The legacy pages replaced ``NaN`` with ``null`` before ``JSON.parse``, so such
files must ingest. Whatever is stored or answered as JSON stays strict (the
non-finite numbers become ``null``) and the prompt text and hash never change.
"""
import json
import logging
import os
import sqlite3
import sys
import tempfile
import time
import types
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from PIL import Image, PngImagePlugin
from database.operations import PromptDatabase
from tests.history_waits import drain_writer
from tests.test_api_admin import AdminAPITestCase
from tests.test_api_images import ImageAPITestCase
from utils.hashing import generate_prompt_hash
from utils.image_monitor import ImageGenerationHandler
try:
from tests.open_handles import assert_closed
except ImportError: # discovered with tests/ as the top-level directory
from open_handles import assert_closed
# The words "NaN" and "Infinity" inside the prompt are text, never numbers.
POSITIVE = " A cat named NaN beside an Infinity pool, ratio: NaN, [NaN] "
NEGATIVE = "blurry"
NAN, INF = float("nan"), float("inf")
def graph(positive=POSITIVE, negative=NEGATIVE):
"""An API prompt graph with non-finite numbers where ComfyUI writes them."""
g = {
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": positive}},
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": negative}},
"3": {
"class_type": "KSampler",
"inputs": {"positive": ["1", 0], "negative": ["2", 0], "model": ["4", 0]},
},
"4": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "m"}},
"5": {
"class_type": "Image Filter",
"inputs": {"strength": INF, "floor": -INF},
"_meta": {"title": "Image Filter"},
"is_changed": [NAN],
},
}
return g
WORKFLOW = {
"nodes": [
{"id": 5, "type": "Image Filter", "widgets_values": [NAN, INF, -INF]},
],
"links": [],
}
def comfy_text(value):
"""The record text ComfyUI writes: Python's default, non-strict JSON."""
text = json.dumps(value)
assert "NaN" in text and "Infinity" in text
return text
def write_png(path, prompt_graph=None, workflow=WORKFLOW):
info = PngImagePlugin.PngInfo()
info.add_text("prompt", comfy_text(prompt_graph or graph()))
if workflow is not None:
info.add_text("workflow", comfy_text(workflow))
path.parent.mkdir(parents=True, exist_ok=True)
with Image.new("RGB", (4, 4)) as image:
image.save(path, pnginfo=info)
return path
def _reject(name):
raise ValueError(f"non-strict JSON constant {name}")
def strict_loads(text):
return json.loads(text, parse_constant=_reject)
class StrictRows:
"""Assertions over the rows a write left behind."""
def image_rows(self, db):
with db.model.get_connection() as conn:
return [
dict(row)
for row in conn.execute(
"SELECT prompt_id, workflow_data, prompt_metadata, parameters "
"FROM generated_images ORDER BY id"
)
]
def assert_strict_snapshot(self, row, *, workflow=True):
prompt = strict_loads(row["prompt_metadata"])
self.assertEqual(prompt["5"]["is_changed"], [None])
self.assertEqual(prompt["5"]["inputs"], {"strength": None, "floor": None})
self.assertEqual(prompt["1"]["inputs"]["text"], POSITIVE)
stored_workflow = strict_loads(row["workflow_data"])
if workflow:
self.assertEqual(
stored_workflow["nodes"][0]["widgets_values"], [None, None, None]
)
if row["parameters"] is not None:
strict_loads(row["parameters"])
def assert_prompt_text(self, db, prompt_id):
with db.model.get_connection() as conn:
row = conn.execute(
"SELECT text, hash FROM prompts WHERE id=?", (prompt_id,)
).fetchone()
self.assertEqual(row["text"], POSITIVE.strip())
self.assertEqual(row["hash"], generate_prompt_hash(POSITIVE))
def dumps_strict(value, **kwargs):
from utils.json_strict import dumps_strict as dumps
return dumps(value, **kwargs)
class TestDumpsStrict(unittest.TestCase):
def test_finite_values_serialize_byte_identically(self):
value = {"b": [1, 2.5, "NaN", None, True], "a": {"x": "Infinity"}}
for kwargs in ({}, {"sort_keys": True, "separators": (",", ":")}):
with self.subTest(kwargs=kwargs):
self.assertEqual(
dumps_strict(value, **kwargs),
json.dumps(value, allow_nan=False, **kwargs),
)
def test_non_finite_numbers_become_null_and_strings_stay(self):
value = {"n": NAN, "i": [INF, -INF], "s": "NaN Infinity", "t": (NAN, 1)}
encoded = dumps_strict(value, sort_keys=True)
self.assertEqual(
strict_loads(encoded),
{"n": None, "i": [None, None], "s": "NaN Infinity", "t": [None, 1]},
)
def test_other_errors_still_raise(self):
cycle = []
cycle.append(cycle)
for value in (cycle, [NAN, cycle]):
with self.assertRaisesRegex(ValueError, "Circular"):
dumps_strict(value)
with self.assertRaises(TypeError):
dumps_strict({"x": object()})
self.assertEqual(
dumps_strict({"x": object()}, default=lambda _: NAN), '{"x": null}'
)
class TestScanIngestsNonFiniteMetadata(StrictRows, AdminAPITestCase):
def ingest(self, name, prompt_graph=None):
path = write_png(self.output_dir / name, prompt_graph)
extracted = self.api._extract_comfyui_metadata(str(path))
return self.api._ingest_scan_batch_sync([(path, extracted)])
def test_resolved_pair_with_nan_is_added_with_strict_snapshots(self):
counts = self.ingest("nan.png")
self.assertEqual(counts["failed"], 0)
self.assertEqual(counts["added"], 1)
rows = self.image_rows(self.api.db)
self.assertEqual(len(rows), 1)
self.assert_strict_snapshot(rows[0])
self.assert_prompt_text(self.api.db, rows[0]["prompt_id"])
pairs = self.api.db.list_prompt_pairs()["pairs"]
self.assertEqual(
[(p["positive_text"], p["negative_text"]) for p in pairs],
[(POSITIVE.strip(), NEGATIVE)],
)
# A repeated scan compares the same strict snapshot: unchanged.
self.assertEqual(self.ingest("nan.png")["unchanged"], 1)
async def test_scan_route_reports_no_failures(self):
from py.config import GalleryConfig
write_png(self.output_dir / "nan.png")
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.output_dir)]
resp = await self.client.request("POST", "/prompt_manager/scan")
body = await resp.text()
self.assertEqual(resp.status, 200)
self.assertIn('"type": "complete"', body)
self.assertIn('"added": 1', body)
self.assertIn('"failed": 0', body)
class TestMonitorIngestsNonFiniteMetadata(StrictRows, unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.db = PromptDatabase(str(Path(self.temp.name) / "db.sqlite"))
self.pid = self.db.save_prompt(
POSITIVE.strip(), prompt_hash=generate_prompt_hash(POSITIVE)
)
self.tracker = SimpleNamespace(
pop_next_prompt=lambda: None,
get_current_prompt=lambda: None,
extend_prompt_timeout=lambda *args: None,
)
self.handler = ImageGenerationHandler(self.db, self.tracker)
self.handler.logger = logging.getLogger("test.nonfinite.monitor")
self.handler.logger.disabled = True
def tearDown(self):
self.db.close_all()
assert_closed(self, self.temp.name)
self.temp.cleanup()
def test_new_image_with_nan_links_its_pair(self):
# The monitor links only images a PromptManager node produced.
g = graph()
g["1"]["class_type"] = "PromptManager"
path = write_png(Path(self.temp.name) / "out" / "nan.png", g)
self.handler.process_new_image(str(path))
rows = self.image_rows(self.db)
self.assertEqual([row["prompt_id"] for row in rows], [self.pid])
self.assert_strict_snapshot(rows[0])
self.assert_prompt_text(self.db, self.pid)
pairs = self.db.list_prompt_pairs()["pairs"]
self.assertEqual(len(pairs), 1)
self.assertEqual(pairs[0]["image_count"], 1)
def test_legacy_link_stores_strict_json(self):
# The plain prompt link (no exact pair) must not write NaN either.
path = str(Path(self.temp.name) / "plain.png")
metadata = {"prompt": graph(), "workflow": WORKFLOW, "parameters": {"x": NAN}}
self.assertTrue(self.db.link_image_to_prompt(self.pid, path, metadata))
row = self.image_rows(self.db)[0]
self.assert_strict_snapshot(row)
self.assertEqual(strict_loads(row["parameters"]), {"x": None})
def test_stored_images_read_back_without_non_finite_numbers(self):
path = str(Path(self.temp.name) / "legacy.png")
self.db.link_image_to_prompt(self.pid, path, {"prompt": graph()})
with self.db.model.get_connection() as conn:
# A row an older version wrote with Python's default json.dumps.
conn.execute(
"UPDATE generated_images SET prompt_metadata=?", (comfy_text(graph()),)
)
conn.commit()
images = self.db.get_prompt_images(str(self.pid))
strict_loads(json.dumps(images, default=str))
class TestInspectorAnswersStrictJSON(ImageAPITestCase):
async def test_inspect_route_resolves_and_answers_strict_json(self):
write_png(self.output_dir / "nan.png")
response = await self.client.get(
"/prompt_manager/images/inspect/nan.png", params={"root": "0"}
)
self.assertEqual(response.status, 200)
result = strict_loads(await response.text())
self.assertEqual(result["resolution"]["state"], "resolved")
self.assertEqual(
result["prompts"]["positive"],
{"status": "resolved", "text": POSITIVE, "source": "graph-inferred"},
)
async def test_prompt_images_route_answers_strict_json(self):
pid = self.api.db.save_prompt(
POSITIVE.strip(), prompt_hash=generate_prompt_hash(POSITIVE)
)
path = str(write_png(self.output_dir / "linked.png"))
self.api.db.link_image_to_prompt(pid, path, {"prompt": graph()})
with self.api.db.model.get_connection() as conn:
conn.execute(
"UPDATE generated_images SET prompt_metadata=?", (comfy_text(graph()),)
)
conn.commit()
response = await self.client.get(f"/prompt_manager/prompts/{pid}/images")
self.assertEqual(response.status, 200)
result = strict_loads(await response.text())
self.assertEqual(len(result["images"]), 1)
stored = result["images"][0]["prompt_metadata"]
self.assertEqual(stored["1"]["inputs"]["text"], POSITIVE)
self.assertEqual(stored["5"]["is_changed"], [None])
class TestHistoryCapturesNonFiniteGraphs(unittest.TestCase):
"""The queued graph ComfyUI hands the hook may carry float('nan')."""
JOB = "a0c26da3-eb26-405c-b143-c662a84c3112"
def setUp(self):
self.directory = tempfile.TemporaryDirectory()
self.addCleanup(self.directory.cleanup)
self.db = PromptDatabase(os.path.join(self.directory.name, "history.db"))
self.addCleanup(self.db.model.reset_schema_cache)
self.addCleanup(self.db.model.close_all)
def test_queued_graph_with_nan_records_its_run_and_contexts(self):
from tests.test_execution_history_hooks import HostQueue, status
from utils.execution_context_capture import record_runtime_context
from utils.execution_history import register_history_hook
queue = HostQueue()
registration = register_history_hook(
SimpleNamespace(prompt_queue=queue), lambda: self.db
)
self.addCleanup(lambda: registration.dispose(3))
self.addCleanup(registration.writer.invalidate)
deadline = time.monotonic() + 3
while not registration.writer.status().accepting:
self.assertLess(time.monotonic(), deadline, "writer never accepted")
time.sleep(0.005)
g = {
"p": {
"class_type": "PromptManagerPair",
"inputs": {"text": POSITIVE, "negative_text": NEGATIVE},
},
"s": {
"class_type": "KSampler",
"inputs": {"positive": ["p", 0], "negative": ["p", 1], "cfg": INF},
"is_changed": [NAN],
},
"5": graph()["5"],
}
extra = {"extra_pnginfo": {"workflow": WORKFLOW}}
queue.results.append(((0, self.JOB, g, extra, ["s"]), 1))
queue.get()
host = types.ModuleType("comfy_execution.utils")
host.get_executing_context = lambda: SimpleNamespace(
prompt_id=self.JOB, node_id="p", list_index=0
)
with patch.dict(sys.modules, {"comfy_execution.utils": host}):
for role, text in (("positive", POSITIVE), ("negative", NEGATIVE)):
record_runtime_context(
"PromptManagerPair", text, "", "", text, role=role
)
queue.task_done(1, {}, status("execution_success", job=self.JOB))
drain_writer(registration.writer, clock=time.monotonic)
with self.db.model.get_connection() as conn:
conn.row_factory = sqlite3.Row
runs = [dict(r) for r in conn.execute("SELECT * FROM execution_runs")]
self.assertEqual([run["outcome"] for run in runs], ["succeeded"])
contexts = self.db.get_execution_contexts(runs[0]["run_id"])
self.assertEqual(contexts["state"], "complete")
self.assertEqual(contexts["contexts"][0]["positive"]["raw"], POSITIVE)
strict_loads(json.dumps(contexts))
if __name__ == "__main__":
unittest.main()
+20 -31
View File
@@ -1245,7 +1245,7 @@ class TestGuiderSamplers(unittest.TestCase):
source["3"]["inputs"]["guider"] = [previous, 0]
self.assertEqual(_inspect(source)["reason_code"], "depth-limit")
def test_custom_advanced_upstream_of_a_sampler_is_a_compared_stage(self):
def test_custom_advanced_upstream_of_a_sampler_is_multistage(self):
source = _guided()
source["41"] = {
"class_type": "KSampler",
@@ -1256,18 +1256,15 @@ class TestGuiderSamplers(unittest.TestCase):
},
}
source["8"]["inputs"]["samples"] = ["41", 0]
# 41 continues 3's latent and reads the same texts as 3's guider.
for anchor in ("9", None):
result = _inspect(source, output_node_id=anchor)
self.assertEqual(
(result["state"], result["sampler_id"]), ("resolved", "41")
)
source["41"]["inputs"]["negative"] = ["6", 0]
result = _inspect(source, output_node_id="9")
self.assertEqual(result["state"], "ambiguous")
self.assertEqual(result["reason_code"], "multistage-differs")
self.assertEqual(result["sampler_id"], "41")
self.assertEqual(result["state"], "unresolved")
self.assertEqual(result["reason"], "Multistage sampler ancestry is ambiguous")
unanchored = _inspect(source)
self.assertEqual(unanchored["state"], "ambiguous")
self.assertEqual(
sorted(item["sampler_id"] for item in result["candidates"]), ["3", "41"]
sorted(item["sampler_id"] for item in unanchored["candidates"]),
["3", "41"],
)
@@ -2635,19 +2632,17 @@ class TestLatentAncestry(unittest.TestCase):
self.assertEqual(_sides(result), (parity.POS, parity.NEG))
self.assertEqual(_inspect(source, output_node_id="9"), result)
def test_upstream_sampler_through_vaedecode_vaeencode_is_compared(self):
# The latent continues the first stage's through lineage nodes: the
# stages are compared (spec 9.3), equal prompts resolve, others differ.
result = _inspect(_image_to_image(parity.POS), output_node_id="63")
self.assertEqual((result["state"], result["sampler_id"]), ("resolved", "61"))
self.assertEqual(_sides(result), (parity.POS, parity.NEG))
result = _inspect(_image_to_image(OTHER), output_node_id="63")
self.assertEqual(result["state"], "ambiguous")
self.assertEqual(result["sampler_id"], "61")
self.assertEqual(result["reason_code"], "multistage-differs")
self.assertEqual(
[item["sampler_id"] for item in result["candidates"]], ["61", "3"]
)
def test_upstream_sampler_through_vaedecode_vaeencode_is_multistage(self):
for positive in (OTHER, parity.POS):
with self.subTest(positive=positive):
result = _inspect(_image_to_image(positive), output_node_id="63")
# Until PR3 compares stage texts, never one stage's prompt.
self.assertEqual(result["state"], "unresolved")
self.assertEqual(result["sampler_id"], "61")
self.assertEqual(result["reason"], MULTISTAGE)
self.assertIsNone(result["reason_code"])
self.assertIsNone(result["positive"])
self.assertIsNone(result["negative"])
# An upstream sampler reached through a non-latent input is a stage too.
source = _latent_from(
{
@@ -2964,15 +2959,9 @@ class TestLatentAncestry(unittest.TestCase):
result = _inspect(source, output_node_id="9")
self.assertEqual(result["reason_code"], "unsupported-node")
self.assertEqual(result["node"], {"id": "60", "class_type": "MysteryUpscaler"})
# The listed stage is now an earlier sampler stage, continued through
# VAEEncode: its prompt equals this stage's, so the chain resolves.
# The listed stage is now an earlier sampler stage in the ancestry.
source["60"]["class_type"] = "UltimateSDUpscale"
result = _inspect(source, output_node_id="9")
self.assertEqual((result["state"], result["sampler_id"]), ("resolved", "3"))
# Reached only through an unlisted image node, it stays unattributed.
source["61"] = {"class_type": "MysteryFilter", "inputs": {"image": ["60", 0]}}
source["5"]["inputs"]["pixels"] = ["61", 0]
result = _inspect(source, output_node_id="9")
self.assertEqual(result["reason"], resolver.MULTISTAGE_UNATTRIBUTED_REASON)
def test_latent_walk_budget_exhaustion_is_work_limit(self):
-419
View File
@@ -1,419 +0,0 @@
"""Multi-stage chains joined through image-space pass-through nodes.
A hires-fix or refiner run decodes sampler A's latent, filters, upscales and
scales the image, then encodes it again for sampler B. The semantics table's
``lineage`` section lists the nodes through which the latent or image carries
on unchanged in kind. When B's latent descends from A only through those nodes
and the stages share one pair, the chain resolves to it. A last stage that adds
conditioning to the base prompt (ConditioningCombine of the base and a style
trigger) keeps the base pair: the floor legacy linked. Everything else stays
ambiguous or unresolved. Ingestion (the default profile) is unchanged (D1).
"""
import json
import unittest
from utils import prompt_pair_resolver as resolver
from utils.prompt_inspector import inspect_prompt_evidence
from utils.prompt_pair_resolver import resolve_prompt_pair
try:
from tests import passthrough_graphs as shapes
except ImportError: # discovered with ``-s tests``
import passthrough_graphs as shapes
def _inspect(graph, **options):
return resolve_prompt_pair(
{"prompt": graph}, profile=resolver.INSPECTION_PROFILE, **options
)
def _texts(result):
return tuple(
result[role]["text"] if isinstance(result[role], dict) else None
for role in ("positive", "negative")
)
class TestHiresFixChain(unittest.TestCase):
def test_shared_pair_resolves_through_image_space_pass_through(self):
graph = shapes.hires_fix()
for anchor in (None, "22"):
with self.subTest(anchor=anchor):
result = _inspect(graph, output_node_id=anchor)
self.assertEqual(result["state"], "resolved")
self.assertEqual(result["sampler_id"], "20")
self.assertEqual(_texts(result), (shapes.POSITIVE, shapes.NEGATIVE))
self.assertIsNone(result["reason_code"])
def test_the_earlier_stage_is_judged_in_its_chain(self):
# Sampler 10 only feeds sampler 20, so the file is 20's: one candidate.
result = _inspect(shapes.hires_fix())
self.assertEqual(result["candidates"], [])
def test_inspector_shows_the_shared_prompt(self):
evidence = inspect_prompt_evidence({"prompt": json.dumps(shapes.hires_fix())})
self.assertEqual(evidence["resolution"]["state"], "resolved")
self.assertEqual(
[evidence["prompts"][role]["text"] for role in ("positive", "negative")],
[shapes.POSITIVE, shapes.NEGATIVE],
)
def test_different_stage_prompts_are_multistage_ambiguous(self):
result = _inspect(shapes.hires_fix(second_positive=shapes.OTHER))
self.assertEqual(result["state"], "ambiguous")
self.assertEqual(result["reason_code"], "multistage-differs")
self.assertEqual(
sorted(_texts(item)[0] for item in result["candidates"]),
sorted([shapes.POSITIVE, shapes.OTHER]),
)
def test_an_unlisted_image_node_breaks_the_lineage(self):
# The walk still finds the earlier sampler, but not through listed
# pass-through nodes: the stages are never compared.
result = _inspect(shapes.hires_fix(filter_class="MysteryImageEffect"))
self.assertEqual(result["state"], "ambiguous")
later = next(
item for item in result["candidates"] if item["sampler_id"] == "20"
)
self.assertEqual(later["reason"], resolver.MULTISTAGE_UNATTRIBUTED_REASON)
def test_a_second_earlier_sampler_in_the_ancestry_stays_unattributed(self):
graph = shapes.hires_fix()
graph["30"] = shapes.ksampler(["2", 0], ["3", 0], ["4", 0])
graph["31"] = {"class_type": "VAEDecode", "inputs": {"samples": ["30", 0]}}
graph["32"] = {
"class_type": "ImageBlend",
"inputs": {"image1": ["14", 0], "image2": ["31", 0], "blend_factor": 0.5},
}
graph["16"]["inputs"]["image"] = ["32", 0]
result = _inspect(graph, output_node_id="22")
self.assertEqual(result["state"], "unresolved")
self.assertEqual(result["reason"], resolver.MULTISTAGE_UNATTRIBUTED_REASON)
def test_a_direct_latent_link_is_a_chain_too(self):
graph = shapes.hires_fix()
graph["20"]["inputs"]["latent_image"] = ["10", 0]
result = _inspect(graph, output_node_id="22")
self.assertEqual((result["state"], result["sampler_id"]), ("resolved", "20"))
def test_an_unreadable_earlier_stage_blocks_the_chain(self):
graph = shapes.hires_fix(second_positive=shapes.POSITIVE)
graph["10"]["inputs"]["positive"] = ["6", 0]
graph["6"] = {"class_type": "MysteryPrompt", "inputs": {"seed": 1}}
result = _inspect(graph, output_node_id="22")
self.assertEqual(result["state"], "unresolved")
self.assertEqual(result["reason_code"], "unsupported-node")
self.assertEqual(result["node"]["class_type"], "MysteryPrompt")
def test_generated_text_stays_untraceable(self):
result = _inspect(shapes.generated_text_chain())
self.assertEqual(result["state"], "unresolved")
self.assertEqual(result["reason_code"], "unsupported-node")
self.assertEqual(result["node"]["class_type"], "TextGenerate")
def test_ingestion_is_unchanged(self):
# D1: the default profile keeps its historical latent rule.
result = resolve_prompt_pair({"prompt": shapes.hires_fix()})
self.assertEqual(result["state"], "ambiguous")
self.assertEqual(
{item["reason"] for item in result["candidates"]},
{"Unsupported latent ancestry"},
)
class TestFluxThreeStage(unittest.TestCase):
def test_added_style_conditioning_keeps_the_base_pair(self):
graph = shapes.flux_three_stage()
for anchor in (None, "61"):
with self.subTest(anchor=anchor):
result = _inspect(graph, output_node_id=anchor)
self.assertEqual(result["state"], "resolved")
self.assertEqual(_texts(result), (shapes.POSITIVE, ""))
self.assertEqual(result["reason"], resolver.ADDED_CONDITIONING_REASON)
self.assertEqual(result["sampler_id"], "50")
# D126: the positive says a later stage added conditioning.
self.assertEqual(
result["positive"]["provenance"]["text_source"],
resolver.ADDED_CONDITIONING_SOURCE,
)
self.assertEqual(
result["negative"]["provenance"]["text_source"],
"zeroed-conditioning",
)
def test_any_added_conditioning_is_labelled_never_measured(self):
# Ruling I2: a whole other prompt combined in is labelled the same way;
# no length or "short trigger" heuristic.
graph = shapes.flux_three_stage()
graph["31"] = shapes.encode(shapes.OTHER)
result = _inspect(graph)
self.assertEqual(result["state"], "resolved")
self.assertEqual(_texts(result)[0], shapes.POSITIVE)
self.assertEqual(
result["positive"]["provenance"]["text_source"],
resolver.ADDED_CONDITIONING_SOURCE,
)
def test_trigger_words_and_added_conditioning_carry_both_labels(self):
graph = shapes.flux_three_stage()
graph["7"] = {
"class_type": "Lora Loader (LoraManager)",
"inputs": {"model": ["1", 0], "clip": ["2", 0], "text": "x"},
}
graph["8"] = shapes.trigger_toggle()
graph["30"]["inputs"]["prepend_text"] = ["8", 0]
result = _inspect(graph)
self.assertEqual(result["state"], "resolved")
self.assertEqual(
result["positive"]["provenance"]["text_source"],
resolver.RUNTIME_AND_ADDED_SOURCE,
)
def test_a_custom_advanced_last_stage_keeps_its_failure(self):
# Review M2: a guider's combined positive is not floored.
graph = shapes.flux_three_stage()
graph["60"] = {
"class_type": "SamplerCustomAdvanced",
"inputs": {"guider": ["63", 0], "latent_image": ["66", 0]},
}
graph["63"] = {
"class_type": "CFGGuider",
"inputs": {"positive": ["35", 0], "negative": ["33", 0]},
}
graph["66"] = {"class_type": "VAEEncode", "inputs": {"pixels": ["51", 0]}}
graph["67"] = {"class_type": "VAEDecode", "inputs": {"samples": ["60", 0]}}
graph["61"]["inputs"]["images"] = ["67", 0]
result = _inspect(graph, output_node_id="61")
self.assertEqual(
(result["state"], result["reason_code"]), ("unresolved", "composite")
)
def test_concat_also_adds_to_the_base_prompt(self):
result = _inspect(shapes.flux_three_stage(composite="ConditioningConcat"))
self.assertEqual(result["state"], "resolved")
self.assertEqual(_texts(result), (shapes.POSITIVE, ""))
def test_an_average_blends_the_base_away(self):
result = _inspect(shapes.flux_three_stage(composite="ConditioningAverage"))
self.assertEqual(
(result["state"], result["reason_code"]), ("unresolved", "composite")
)
def test_a_combine_without_the_base_prompt_is_not_the_base_pair(self):
graph = shapes.flux_three_stage(combined=(("37", 0), ("31", 0)))
graph["37"] = shapes.encode(shapes.OTHER)
result = _inspect(graph)
self.assertEqual(
(result["state"], result["reason_code"]), ("unresolved", "composite")
)
def test_a_different_last_negative_is_not_the_base_pair(self):
graph = shapes.flux_three_stage()
graph["38"] = shapes.encode("watermark")
graph["60"]["inputs"]["negative"] = ["38", 0]
result = _inspect(graph)
self.assertEqual(
(result["state"], result["reason_code"]), ("unresolved", "composite")
)
def test_different_base_stages_are_never_floored(self):
graph = shapes.flux_three_stage()
graph["39"] = shapes.encode(shapes.OTHER)
graph["40"]["inputs"]["positive"] = ["39", 0]
result = _inspect(graph)
self.assertNotEqual(result["state"], "resolved")
def test_a_rerouted_combine_is_followed(self):
graph = shapes.flux_three_stage()
graph["39"] = {"class_type": "Reroute", "inputs": {"input": ["35", 0]}}
graph["60"]["inputs"]["positive"] = ["39", 0]
result = _inspect(graph)
self.assertEqual(result["reason"], resolver.ADDED_CONDITIONING_REASON)
def test_an_unreadable_last_negative_or_encoder_parts_keep_the_failure(self):
graph = shapes.flux_three_stage()
graph["38"] = {"class_type": "MysteryNegative", "inputs": {"seed": 1}}
graph["60"]["inputs"]["negative"] = ["38", 0]
self.assertEqual(_inspect(graph)["reason_code"], "composite")
# Differing encoder parts are a composite too, but not an added stage.
graph = shapes.flux_three_stage()
graph["39"] = {
"class_type": "CLIPTextEncodeSDXL",
"inputs": {"text_g": shapes.POSITIVE, "text_l": shapes.STYLE},
}
graph["60"]["inputs"]["positive"] = ["39", 0]
result = _inspect(graph)
self.assertEqual(
(result["state"], result["reason_code"]), ("unresolved", "composite")
)
self.assertEqual(result["node"]["class_type"], "CLIPTextEncodeSDXL")
def test_inspector_shows_the_base_prompt(self):
evidence = inspect_prompt_evidence(
{"prompt": json.dumps(shapes.flux_three_stage())}
)
self.assertEqual(evidence["resolution"]["state"], "resolved")
self.assertEqual(evidence["prompts"]["positive"]["text"], shapes.POSITIVE)
class TestRunTimePromptManagerParts(unittest.TestCase):
"""Maintainer option A: a run-time prepend or append is left out, labelled."""
def test_trigger_words_prepend_resolves_to_the_prompt_manager_text(self):
for anchor in (None, "22"):
with self.subTest(anchor=anchor):
result = _inspect(
shapes.hires_fix_with_trigger_words(), output_node_id=anchor
)
self.assertEqual(result["state"], "resolved")
self.assertEqual(_texts(result), (shapes.POSITIVE, shapes.NEGATIVE))
self.assertEqual(result["reason"], resolver.RUNTIME_PARTS_REASON)
provenance = result["positive"]["provenance"]
self.assertEqual(
provenance["text_source"], resolver.RUNTIME_PARTS_SOURCE
)
self.assertEqual(result["positive"]["storage_text"], shapes.POSITIVE)
# The negative is untouched by the positive's run-time part.
self.assertEqual(
result["negative"]["provenance"]["text_source"], "graph-inferred"
)
def test_append_and_a_single_sampler_are_labelled_too(self):
graph = shapes.hires_fix_with_trigger_words()
graph["2"]["inputs"]["prepend_text"] = "masterpiece"
graph["2"]["inputs"]["append_text"] = ["8", 0]
graph["20"]["inputs"]["latent_image"] = ["4", 0]
result = _inspect(graph, output_node_id="22")
self.assertEqual(result["state"], "resolved")
# The literal prepend is still combined exactly as before.
self.assertEqual(_texts(result)[0], "masterpiece " + shapes.POSITIVE)
self.assertEqual(result["reason"], resolver.RUNTIME_PARTS_REASON)
def test_the_inspector_shows_the_prompt_and_its_source(self):
evidence = inspect_prompt_evidence(
{"prompt": json.dumps(shapes.hires_fix_with_trigger_words())}
)
self.assertEqual(evidence["resolution"]["state"], "resolved")
self.assertEqual(
evidence["prompts"]["positive"],
{
"status": "resolved",
"text": shapes.POSITIVE,
"source": resolver.RUNTIME_PARTS_SOURCE,
},
)
def test_literal_and_traceable_parts_are_combined_as_before(self):
graph = shapes.hires_fix_with_trigger_words()
graph["8"] = {"class_type": "PrimitiveString", "inputs": {"value": "trig9word"}}
result = _inspect(graph)
self.assertEqual(_texts(result)[0], "trig9word " + shapes.POSITIVE)
self.assertEqual(result["reason"], "")
self.assertEqual(
result["positive"]["provenance"]["text_source"], "graph-inferred"
)
def test_only_a_run_time_feeder_is_left_out(self):
# An unknown feeder stays unsupported, naming it.
graph = shapes.hires_fix_with_trigger_words()
graph["8"]["class_type"] = "MysteryWords"
result = _inspect(graph)
self.assertEqual(result["reason_code"], "unsupported-node")
self.assertEqual(result["node"]["class_type"], "MysteryWords")
# A run-time node deeper inside the part makes the whole part run time,
# but it is not a direct feeder: never leave out a traced literal.
graph = shapes.hires_fix_with_trigger_words()
graph["9"] = {
"class_type": "StringConcatenate",
"inputs": {"string_a": "kept", "string_b": ["8", 0], "delimiter": ""},
}
graph["2"]["inputs"]["prepend_text"] = ["9", 0]
result = _inspect(graph)
self.assertEqual(result["reason_code"], "runtime-value")
def test_the_main_text_from_a_run_time_node_is_never_left_out(self):
graph = shapes.hires_fix_with_trigger_words()
graph["2"]["inputs"]["prepend_text"] = ""
graph["2"]["inputs"]["text"] = ["8", 0]
result = _inspect(graph)
self.assertEqual(result["reason_code"], "runtime-value")
def test_the_label_survives_nodes_between_the_prompt_manager_and_sampler(self):
# Review C1: encoders, chained PromptManagers and concatenations.
expected = {
"text-encode": shapes.POSITIVE,
"chained": shapes.POSITIVE,
"concat": shapes.POSITIVE + ", extra",
}
for shape, text in expected.items():
with self.subTest(shape=shape):
result = _inspect(shapes.trigger_words_through(shape))
self.assertEqual(result["state"], "resolved")
self.assertEqual(_texts(result)[0], text)
self.assertEqual(result["reason"], resolver.RUNTIME_PARTS_REASON)
self.assertEqual(
result["positive"]["provenance"]["text_source"],
resolver.RUNTIME_PARTS_SOURCE,
)
def test_a_partial_prompt_never_equals_a_complete_one(self):
# Review C2: never collapse a branch that left out trigger words with
# one that did not, nor compare such stages as equal.
result = _inspect(shapes.branches_with_and_without_trigger_words())
self.assertEqual(
(result["state"], result["reason"]),
("ambiguous", "Multiple sampler branches"),
)
graph = shapes.hires_fix_with_trigger_words()
graph["9"] = shapes.prompt_manager(shapes.POSITIVE)
graph["20"]["inputs"]["positive"] = ["9", 0]
result = _inspect(graph, output_node_id="22")
self.assertEqual(
(result["state"], result["reason_code"]),
("ambiguous", "multistage-differs"),
)
# Two PromptManagers leaving out two different toggles differ too.
graph["8b"] = shapes.trigger_toggle("otherword")
graph["9"]["inputs"]["prepend_text"] = ["8b", 0]
result = _inspect(graph, output_node_id="22")
self.assertEqual(result["reason_code"], "multistage-differs")
def test_only_listed_run_time_classes_are_left_out(self):
# Review M1: a JoinStringMulti with a linked list flag is run time
# too, but it is not a listed run-time node: never left out.
graph = shapes.hires_fix()
graph["9"] = {
"class_type": "JoinStringMulti",
"inputs": {
"inputcount": 2,
"delimiter": " ",
"return_list": ["98", 0],
"string_1": "static words",
"string_2": "more",
},
}
graph["98"] = {"class_type": "PrimitiveBoolean", "inputs": {"value": False}}
graph["2"]["inputs"]["prepend_text"] = ["9", 0]
result = _inspect(graph)
self.assertEqual(
(result["state"], result["reason_code"]), ("unresolved", "runtime-value")
)
self.assertEqual(result["node"]["class_type"], "JoinStringMulti")
def test_ingestion_is_unchanged(self):
result = resolve_prompt_pair({"prompt": shapes.hires_fix_with_trigger_words()})
self.assertEqual(result["state"], "ambiguous")
class TestStillAmbiguous(unittest.TestCase):
def test_edit_batch_with_distinct_prompts_stays_ambiguous(self):
result = _inspect(shapes.edit_batch(4))
self.assertEqual(result["state"], "ambiguous")
self.assertEqual(result["reason"], "Multiple sampler branches")
self.assertEqual(len({_texts(item)[0] for item in result["candidates"]}), 4)
if __name__ == "__main__":
unittest.main()
+5 -230
View File
@@ -260,7 +260,7 @@ class TestScanScope(ScanScopeTestCase):
async def test_empty_read_body_asks_for_the_default_scope(self):
"""A chunked POST with no bytes can read a body: still the default."""
from py.api.job_routes import _scan_request
from py.api.job_routes import _scan_scope
class Request:
can_read_body = True
@@ -271,21 +271,10 @@ class TestScanScope(ScanScopeTestCase):
async def text(self):
return self._text
default = ({"files": "new", "roots": None}, None)
self.assertEqual(await _scan_request(Request("")), default)
self.assertEqual(await _scan_request(Request(" \n")), default)
self.assertEqual(
await _scan_request(
Request(
'{"scope": "all", "roots": [2, 0, 2],'
' "paths": ["output/c", "output/a", "output/c"]}'
)
),
({"files": "all", "roots": {0: "output/a", 2: "output/c"}}, None),
)
request, error = await _scan_request(Request("{"))
self.assertIsNone(request)
self.assertIsInstance(error, str)
self.assertEqual(await _scan_scope(Request("")), "new")
self.assertEqual(await _scan_scope(Request(" \n")), "new")
self.assertEqual(await _scan_scope(Request('{"scope": "all"}')), "all")
self.assertIsNone(await _scan_scope(Request("{")))
async def test_invalid_scope_is_refused(self):
for body in ({"scope": "everything"}, {"scope": 1}, ["new"]):
@@ -303,220 +292,6 @@ class TestScanScope(ScanScopeTestCase):
self.assertIsNone(self.api._scan_job)
class TestPerSourceScope(ScanScopeTestCase):
"""``roots``: scan only the selected effective sources (#249 S4 Task 10)."""
async def asyncSetUp(self):
await super().asyncSetUp()
self.a = self.output_dir / "a"
self.b = self.output_dir / "b"
self.a.mkdir()
self.b.mkdir()
_write_png(self.a / "in_a.png", prompt_text="a prompt in a")
_write_png(self.b / "in_b.png", prompt_text="a prompt in b")
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.a), self._root(self.b)]
async def _refused(self, body):
resp = await self.client.request("POST", "/prompt_manager/jobs/scan", json=body)
data = await resp.json()
self.assertEqual(resp.status, 400, data)
self.assertFalse(data["success"])
self.assertNotIn(self.tmpdir, json.dumps(data))
self.assertIsNone(self.api._scan_job)
self.assertEqual(self.api._jobs.list(), [])
return data
async def test_scoped_scan_only_walks_selected_root(self):
walked = []
original = self.api._collect_output_media_sync
def spy(output_dirs):
walked.append([os.path.basename(str(d)) for d in output_dirs])
return original(output_dirs)
self.api._collect_output_media_sync = spy
reads = self._spy_reads()
job = await self._scan({"scope": "all", "roots": [1], "paths": ["output/b"]})
self.assertEqual(walked, [["b"]])
self.assertEqual(reads, ["in_b.png"])
self.assertEqual(
job["scope"], {"files": "all", "roots": [1], "output_folders": 1}
)
self.assertEqual(
job["outcomes"]["added"],
[
{
"path": "in_b.png",
"root": "output/b",
"root_index": 1,
"detail": job["outcomes"]["added"][0]["detail"],
}
],
)
self.assertEqual(job["counters"]["added"], 1)
self.assertEqual(self.api._scan_job.last_event["directories"], ["output/b"])
async def test_duplicate_roots_are_one_selection(self):
job = await self._scan(
{"roots": [1, 0, 1], "paths": ["output/b", "output/a", "output/b"]}
)
self.assertEqual(job["scope"]["roots"], [0, 1])
self.assertEqual(job["scope"]["output_folders"], 2)
self.assertEqual(job["counters"]["added"], 2)
async def test_scope_line_counts_selected_folders(self):
job = await self._scan({"roots": [0], "paths": ["output/a"]})
self.assertTrue(
job["log"][0].endswith(
"Scan started · 1 output folder · new and unresolved files"
),
job["log"][0],
)
self.assertEqual(job["scope"]["output_folders"], 1)
unscoped = await self._scan({"scope": "all"})
self.assertEqual(
unscoped["scope"], {"files": "all", "roots": None, "output_folders": 2}
)
self.assertIn("Scan started · 2 output folders · all files", unscoped["log"][0])
async def _stale(self, body):
resp = await self.client.request("POST", "/prompt_manager/jobs/scan", json=body)
data = await resp.json()
self.assertEqual(resp.status, 409, data)
self.assertEqual(data["code"], "sources-changed")
self.assertFalse(data["success"])
self.assertNotIn(self.tmpdir, json.dumps(data))
self.assertIsNone(self.api._scan_job)
self.assertEqual(self.api._jobs.list(), [])
return data
async def test_index_past_the_sources_is_409_without_job(self):
await self._stale({"roots": [2], "paths": ["output/c"]})
await self._stale({"roots": [0, 64], "paths": ["output/a", "output/x"]})
async def test_stale_index_never_scans_another_source(self):
"""The list changed after the page read it: source 0 was removed, so
old index 1 (b) is past the end and old index 0 now names b."""
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.b)]
await self._stale({"roots": [1], "paths": ["output/b"]})
await self._stale({"roots": [0], "paths": ["output/a"]})
job = await self._scan({"roots": [0], "paths": ["output/b"]})
self.assertEqual(job["outcomes"]["added"][0]["root"], "output/b")
async def test_roots_need_their_paths(self):
for body in (
{"roots": [0]},
{"roots": [0], "paths": None},
{"roots": [0], "paths": "output/a"},
{"roots": [0], "paths": []},
{"roots": [0, 1], "paths": ["output/a"]},
{"roots": [0], "paths": [0]},
{"roots": [0, 0], "paths": ["output/a", "output/b"]},
):
with self.subTest(body=body):
await self._refused(body)
async def test_bool_and_float_roots_rejected(self):
for roots in (
[True],
[False],
[0.0],
[1.5],
["0"],
[None],
[-1],
[0, None],
[],
None,
0,
"0",
{"0": 0},
list(range(65)),
):
with self.subTest(roots=roots):
paths = ["output/a"] * len(roots) if isinstance(roots, list) else []
await self._refused({"roots": roots, "paths": paths})
async def test_legacy_scan_unscoped_unchanged(self):
from py.api.admin import SCAN_COUNT_KEYS
resp = await self.client.request("POST", "/prompt_manager/scan", data="{}")
payloads = _sse_payloads(await resp.text())
final = payloads[-1]
self.assertEqual(set(final), set(SCAN_COUNT_KEYS) | {"type", "directories"})
self.assertEqual(final["directories"], ["output/a", "output/b"])
self.assertEqual(final["added"], 2)
self.assertEqual(
self.api._scan_job.scope,
{"files": "all", "roots": None, "output_folders": 2},
)
async def test_legacy_scan_attaches_to_a_scoped_job_as_is(self):
import threading
from py.api.admin import SCAN_COUNT_KEYS
gate = threading.Event()
original = self.api._collect_output_media_sync
def gated(output_dirs):
gate.wait(5)
return original(output_dirs)
self.api._collect_output_media_sync = gated
# The legacy route has decided to attach once it streams the job.
attached = asyncio.Event()
stream = self.api._stream_scan_job
async def attaching(request, job):
attached.set()
return await stream(request, job)
self.api._stream_scan_job = attaching
resp = await self.client.request(
"POST",
"/prompt_manager/jobs/scan",
json={"roots": [0], "paths": ["output/a"]},
)
self.assertEqual(resp.status, 202)
started = (await resp.json())["job"]
legacy = asyncio.ensure_future(
self.client.request("POST", "/prompt_manager/scan", data="{}")
)
try:
await asyncio.wait_for(attached.wait(), 10)
finally:
gate.set()
payloads = _sse_payloads(await (await legacy).text())
await asyncio.wait_for(self.api._scan_job.task, 10)
self.assertEqual(self.api._scan_job.id, started["id"])
self.assertEqual(len(self.api._jobs.list()), 1)
final = payloads[-1]
self.assertEqual(set(final), set(SCAN_COUNT_KEYS) | {"type", "directories"})
self.assertEqual(final["directories"], ["output/a"])
self.assertEqual(final["added"], 1)
async def test_scoped_scan_uses_the_sources_it_was_started_with(self):
"""Sources saved after the start never change what the job walks."""
resolved = []
original = self.api._get_all_output_dirs
def counting():
resolved.append(1)
return original()
self.api._get_all_output_dirs = counting
job = await self._scan({"roots": [1], "paths": ["output/b"]})
self.assertEqual(len(resolved), 1) # the request's own resolution only
self.assertEqual(job["outcomes"]["added"][0]["root_index"], 1)
class TestKnownResolvedPaths(ScanScopeTestCase):
"""``PromptDatabase.get_known_resolved_paths``: one streamed query."""
-959
View File
@@ -1,959 +0,0 @@
"""System › Sources & scans backend (#249 S4 Task 9).
``GET /prompt_manager/settings`` reports the configured, effective and
watched sources and the monitoring setting; ``POST`` saves
``monitoring_enabled`` (a restart applies it) and refuses a source change
while a record-changing job runs; ``POST /prompt_manager/settings/validate-source``
checks one path and writes nothing. No response carries an absolute path.
"""
import asyncio
import functools
import json
import ntpath
import os
import sys
import types
import unittest
from contextlib import contextmanager
from unittest.mock import patch
try:
from tests.test_api_admin import AdminAPITestCase
except ImportError: # discovered with tests/ as the top-level directory
from test_api_admin import AdminAPITestCase
from py import config as config_module # noqa: E402
from py.api import settings_routes, sources # noqa: E402
from py.config import GalleryConfig # noqa: E402
VALIDATE = "/prompt_manager/settings/validate-source"
SETTINGS = "/prompt_manager/settings"
@contextmanager
def no_filesystem_access_for(marker, test):
"""Fail ``test`` if anything resolves, stats or opens a path holding ``marker``.
Trusted directories (ComfyUI's, the anchors) may still be resolved.
"""
real = {
"realpath": os.path.realpath,
"stat": os.stat,
"lstat": os.lstat,
}
def guard(name):
def call(path, *args, **kwargs):
if isinstance(path, (str, bytes)) and marker in os.fsdecode(path):
test.fail(f"{name}({path!r}) touched an outside path")
return real[name](path, *args, **kwargs)
return call
with (
patch("os.path.realpath", guard("realpath")),
patch("os.stat", guard("stat")),
patch("os.lstat", guard("lstat")),
):
yield
class FakeMonitor:
"""The image monitor as get_settings sees it: status and scheduled roots."""
def __init__(self, scheduled, alive=True):
self.monitored_directories = [str(path) for path in scheduled]
self.alive = alive
def get_status(self):
return {
"running": True,
"monitored_directories": self.monitored_directories,
"handler_active": True,
"observer_alive": self.alive,
}
class SourcesTestCase(AdminAPITestCase):
async def asyncSetUp(self):
await super().asyncSetUp()
self.a = self.output_dir / "a"
self.b = self.output_dir / "b"
self.a.mkdir()
self.b.mkdir()
enabled = GalleryConfig.MONITORING_ENABLED
self.addCleanup(setattr, GalleryConfig, "MONITORING_ENABLED", enabled)
GalleryConfig.MONITORING_ENABLED = True
self._install_monitor(None)
def _install_monitor(self, monitor):
import utils.image_monitor as im_mod
orig = im_mod._monitor_instance
im_mod._monitor_instance = monitor
self.addCleanup(setattr, im_mod, "_monitor_instance", orig)
async def _get(self):
resp = await self.client.request("GET", "/prompt_manager/settings")
body = await resp.text()
self.assertEqual(resp.status, 200, body)
self.assertNotIn(self.tmpdir, body)
return json.loads(body)
async def _post(self, url, payload):
resp = await self.client.request("POST", url, json=payload)
body = await resp.text()
self.assertNotIn(self.tmpdir, body)
return resp.status, json.loads(body)
def _config_bytes(self):
if not os.path.exists(self.config_path):
return None
with open(self.config_path, "rb") as f:
return f.read()
def _states(self, data):
return {row["path"]: row["watch"] for row in data["sources"]["effective"]}
class TestSourceStatus(SourcesTestCase):
async def test_sources_list_configured_effective_and_auto_detected(self):
missing = self.output_dir / "gone"
missing.mkdir()
GalleryConfig.MONITORING_DIRECTORIES = [
self._root(self.a),
self._root(missing),
]
missing.rmdir() # an unmounted drive: configured, no longer a directory
data = await self._get()
self.assertEqual(
data["sources"]["configured"],
[
{
"label": "a",
"path": "output/a",
"valid": True,
"code": None,
"reason": None,
},
{
"label": "gone",
"path": "output/gone",
"valid": False,
"code": "not-found",
"reason": "Gallery root does not exist",
},
],
)
self.assertEqual(
data["sources"]["effective"],
[{"label": "a", "path": "output/a", "root_index": 0, "watch": "manual"}],
)
self.assertFalse(data["sources"]["auto_detected"])
# The legacy keys are unchanged.
self.assertEqual(
data["settings"]["gallery_root_paths"], ["output/a", "output/gone"]
)
async def test_empty_list_means_auto_detect(self):
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.a)]
self._write_config({"gallery": {"monitoring": {"directories": ["x"]}}})
# Detection (the upward search for a ComfyUI tree) finds the output.
self.api._find_comfyui_output_dir = lambda: str(self.output_dir)
status, saved = await self._post(
"/prompt_manager/settings", {"gallery_root_paths": []}
)
self.assertEqual(status, 200, saved)
self.assertEqual(
self._read_config()["gallery"]["monitoring"]["directories"], []
)
data = await self._get()
self.assertEqual(data["sources"]["configured"], [])
self.assertTrue(data["sources"]["auto_detected"])
self.assertEqual(
data["sources"]["effective"],
[
{
"label": "ComfyUI output",
"path": "output",
"root_index": 0,
"watch": "manual",
}
],
)
async def test_watching_requires_live_observer_with_root(self):
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.a), self._root(self.b)]
# No monitor in this process: nothing is watched, a restart cannot help.
data = await self._get()
self.assertEqual(
data["monitor"],
{"present": False, "observer_alive": False, "scheduled": []},
)
self.assertEqual(
self._states(data), {"output/a": "manual", "output/b": "manual"}
)
# A dead observer watches nothing, even the roots it scheduled.
self._install_monitor(FakeMonitor([self.a], alive=False))
data = await self._get()
self.assertEqual(
data["monitor"],
{"present": True, "observer_alive": False, "scheduled": ["output/a"]},
)
self.assertEqual(
self._states(data),
{"output/a": "restart_pending", "output/b": "restart_pending"},
)
# A live observer watches its scheduled root; a root configured since
# it started waits for a restart.
self._install_monitor(FakeMonitor([self.a]))
data = await self._get()
self.assertTrue(data["monitor"]["observer_alive"])
self.assertEqual(
self._states(data), {"output/a": "watching", "output/b": "restart_pending"}
)
# Monitoring switched off: the live observer still watches until the
# restart ("stopping"), and nothing else is pending.
GalleryConfig.MONITORING_ENABLED = False
data = await self._get()
self.assertFalse(data["settings"]["monitoring_enabled"])
self.assertEqual(
self._states(data), {"output/a": "stopping", "output/b": "manual"}
)
async def test_scheduled_root_matches_through_its_spelling(self):
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.a)]
spelled = os.path.join(str(self.output_dir), "b", "..", "a")
self._install_monitor(FakeMonitor([spelled]))
data = await self._get()
self.assertEqual(self._states(data), {"output/a": "watching"})
self.assertEqual(data["monitor"]["scheduled"], ["output/a"])
async def test_status_failure_is_500_without_paths(self):
def boom(*args, **kwargs):
raise OSError(2, "No such file", f"{self.tmpdir}/secret")
with patch.object(sources, "describe_sources", boom):
resp = await self.client.request("GET", "/prompt_manager/settings")
self.assertEqual(resp.status, 500)
self.assertNotIn(self.tmpdir, await resp.text())
class TestMonitoringSetting(SourcesTestCase):
async def test_monitoring_enabled_strict_bool_and_restart_flag(self):
self._write_config({"custom": {"keep": True}})
before = self._config_bytes()
for value in ("false", 0, 1, None, [], {"on": True}):
with self.subTest(value=value):
status, data = await self._post(
"/prompt_manager/settings", {"monitoring_enabled": value}
)
self.assertEqual(status, 400, data)
self.assertFalse(data["success"])
self.assertEqual(self._config_bytes(), before)
self.assertTrue(GalleryConfig.MONITORING_ENABLED)
self._install_monitor(FakeMonitor([self.output_dir]))
status, data = await self._post(
"/prompt_manager/settings", {"monitoring_enabled": False}
)
self.assertEqual(status, 200, data)
self.assertTrue(data["persisted"])
self.assertTrue(data["restart_required"])
self.assertEqual(data["restart_required_fields"], ["monitoring_enabled"])
saved = self._read_config()
self.assertIs(saved["gallery"]["monitoring"]["enabled"], False)
self.assertEqual(saved["custom"], {"keep": True})
self.assertFalse(GalleryConfig.MONITORING_ENABLED)
self.assertFalse((await self._get())["settings"]["monitoring_enabled"])
# Back on while the observer still runs: nothing waits for a restart.
status, data = await self._post(
"/prompt_manager/settings", {"monitoring_enabled": True}
)
self.assertEqual(status, 200, data)
self.assertEqual(data["restart_required_fields"], [])
self.assertIs(self._read_config()["gallery"]["monitoring"]["enabled"], True)
async def test_enabling_without_a_live_observer_needs_a_restart(self):
GalleryConfig.MONITORING_ENABLED = False
status, data = await self._post(
"/prompt_manager/settings", {"monitoring_enabled": True}
)
self.assertEqual(status, 200, data)
self.assertEqual(data["restart_required_fields"], ["monitoring_enabled"])
self.assertTrue(GalleryConfig.MONITORING_ENABLED)
def test_config_file_enabled_must_be_a_bool(self):
GalleryConfig.MONITORING_ENABLED = True
with self.assertLogs("prompt_manager.config", level="WARNING"):
GalleryConfig.update_config({"monitoring": {"enabled": "false"}})
self.assertIs(GalleryConfig.MONITORING_ENABLED, True)
GalleryConfig.update_config({"monitoring": {"enabled": False}})
self.assertIs(GalleryConfig.MONITORING_ENABLED, False)
class TestSourceChangeHold(SourcesTestCase):
async def test_root_save_refused_while_scan_job_runs(self):
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.a)]
self._write_config({"gallery": {"monitoring": {"directories": ["kept"]}}})
before = self._config_bytes()
gate = asyncio.Event()
async def runner(job):
await gate.wait()
scan = self.api._jobs.start(
"scan", "Scan for metadata", record_changing=True, runner=runner
)
try:
status, data = await self._post(
"/prompt_manager/settings",
{"gallery_root_paths": [str(self.b)]},
)
self.assertEqual(status, 409, data)
self.assertEqual(data["code"], "busy")
self.assertEqual(
data["running"], {"id": scan.id, "title": "Scan for metadata"}
)
self.assertEqual(self._config_bytes(), before)
self.assertEqual(GalleryConfig.MONITORING_DIRECTORIES, [self._root(self.a)])
# A save without sources, or one resending the current sources
# (the legacy page always does), is not a source change.
status, data = await self._post(
"/prompt_manager/settings", {"result_timeout": 9}
)
self.assertEqual(status, 200, data)
status, data = await self._post(
"/prompt_manager/settings",
{"result_timeout": 8, "gallery_root_paths": [str(self.a)]},
)
self.assertEqual(status, 200, data)
finally:
gate.set()
await asyncio.wait_for(scan.task, 5)
async def test_root_save_runs_as_a_source_change_hold(self):
seen = []
original = settings_routes.get_config_store
def store():
seen.extend(
(job.kind, job.title, job.admission, job.cancellable)
for job in self.api._jobs.list()
if job.running
)
return original()
with patch.object(settings_routes, "get_config_store", store):
status, data = await self._post(
"/prompt_manager/settings", {"gallery_root_paths": [str(self.b)]}
)
self.assertEqual(status, 200, data)
self.assertIn(("source_change", "Source change", "records", False), seen)
self.assertFalse(any(job.running for job in self.api._jobs.list()))
async def test_failed_root_save_ends_the_hold_failed(self):
os.makedirs(self.config_path) # config.json is a directory: unreadable
status, data = await self._post(
"/prompt_manager/settings", {"gallery_root_paths": [str(self.b)]}
)
self.assertGreaterEqual(status, 400, data)
held = [j for j in self.api._jobs.list() if j.kind == "source_change"]
self.assertEqual([job.state for job in held], ["failed"])
async def test_mixed_case_windows_paths_dedupe(self):
"""Two spellings of one Windows folder are saved once (normcase)."""
with (
patch.object(sources, "source_problem", return_value=(None, "")),
patch.object(
GalleryConfig, "resolve_gallery_root", side_effect=lambda path: path
),
patch.object(settings_routes, "_source_identity", ntpath.normcase),
patch.object(self.api, "_apply_gallery_roots", return_value=True),
):
roots, error = self.api._parse_gallery_roots(
{
"gallery_root_paths": [
"C:\\ComfyUI\\Output\\Renders",
"c:/comfyui/output/renders",
"C:\\ComfyUI\\output\\other",
]
}
)
self.assertIsNone(error)
self.assertEqual(
roots, ["C:\\ComfyUI\\Output\\Renders", "C:\\ComfyUI\\output\\other"]
)
async def test_case_variant_of_one_folder_is_saved_once_natively(self):
variant = str(self.a)[:-1] + "A"
if not os.path.isdir(variant):
self.skipTest("case-sensitive filesystem")
status, data = await self._post(
"/prompt_manager/settings", {"gallery_root_paths": [str(self.a), variant]}
)
self.assertEqual(status, 200, data)
self.assertEqual(len(GalleryConfig.MONITORING_DIRECTORIES), 1)
class TestValidateSource(SourcesTestCase):
async def test_validate_source_writes_nothing(self):
self._write_config({"gallery": {"monitoring": {"directories": []}}})
before = self._config_bytes()
roots = list(GalleryConfig.MONITORING_DIRECTORIES)
status, data = await self._post(VALIDATE, {"path": str(self.a)})
self.assertEqual(status, 200, data)
self.assertEqual(
data,
{
"success": True,
"valid": True,
"label": "a",
"path": "output/a",
"code": None,
"reason": None,
},
)
self.assertEqual(self._config_bytes(), before)
self.assertEqual(GalleryConfig.MONITORING_DIRECTORIES, roots)
self.assertEqual(self.api._jobs.list(), [])
async def test_relative_path_resolves_against_comfyui(self):
status, data = await self._post(VALIDATE, {"path": "output/b"})
self.assertEqual((status, data["valid"], data["path"]), (200, True, "output/b"))
status, data = await self._post(VALIDATE, {"path": "output"})
self.assertEqual(data["label"], "ComfyUI output")
async def test_invalid_sources_answer_a_code(self):
a_file = self.output_dir / "file.png"
a_file.write_bytes(b"x")
cases = {
"": "empty",
" ": "empty",
str(self.output_dir / "missing"): "not-found",
str(a_file): "not-directory",
str(self.outside_dir): "outside-allowed",
os.path.abspath(os.sep): "outside-allowed",
os.path.expanduser("~"): "outside-allowed",
os.path.join(str(self.output_dir), "b", "..", "a"): "traversal",
"output/../output/a": "traversal",
str(self.a) + "\x00": "invalid-form",
"\\\\?\\C:\\ComfyUI\\output": "invalid-form",
"x" * 5000: "invalid-form",
}
if os.name != "nt":
cases["C:\\ComfyUI\\output"] = "invalid-form"
for path, code in cases.items():
with self.subTest(code=code, path=path[:40]):
status, data = await self._post(VALIDATE, {"path": path})
self.assertEqual(status, 200, data)
self.assertFalse(data["valid"])
self.assertEqual(data["code"], code)
self.assertIsInstance(data["reason"], str)
self.assertIsNone(data["label"])
self.assertIsNone(data["path"])
self.assertNotIn(os.path.expanduser("~"), json.dumps(data))
async def test_outside_paths_answer_alike_whether_they_exist(self):
"""No existence oracle: outside the allowed folders every path looks
the same, existing or not, file or folder."""
a_file = self.outside_dir / "file.txt"
a_file.write_bytes(b"x")
answers = []
for path in (
self.outside_dir,
self.outside_dir / "missing",
a_file,
os.path.join(self.tmpdir, "no", "such", "tree"),
os.path.abspath(os.sep),
os.path.expanduser("~"),
):
status, data = await self._post(VALIDATE, {"path": str(path)})
answers.append((status, data))
self.assertEqual(answers[0][1]["code"], "outside-allowed")
self.assertEqual([answer for answer in answers], [answers[0]] * len(answers))
async def test_settings_save_refuses_outside_paths_alike(self):
"""The legacy save answers every outside path alike too: the
filesystem root, the home directory and a link to it included."""
(self.outside_dir / "x").mkdir()
link = os.path.join(self.tmpdir, "link-home")
try:
os.symlink(os.path.expanduser("~"), link, target_is_directory=True)
except (OSError, NotImplementedError):
link = None
paths = [
str(self.outside_dir / "x"),
self.tmpdir + "/nothing/x",
os.path.abspath(os.sep),
os.path.expanduser("~"),
]
if link:
paths.append(link)
answers = []
for path in paths:
resp = await self.client.request(
"POST", SETTINGS, json={"gallery_root_paths": [path]}
)
body = await resp.text()
self.assertNotIn(os.path.expanduser("~"), body)
data = json.loads(body)
# Only the name the caller sent may differ.
answers.append(
(
resp.status,
{k: v for k, v in data.items() if k not in ("source", "error")},
)
)
self.assertEqual(
data["error"],
f"{data['source']} can't be used: "
f"{config_module.OUTSIDE_ALLOWED_REASON}. Remove it, then save.",
)
self.assertEqual(
answers[0],
(
400,
{
"success": False,
"code": "invalid-source",
"index": 0,
"reason": config_module.OUTSIDE_ALLOWED_REASON,
},
),
)
self.assertEqual(answers, [answers[0]] * len(answers))
self.assertEqual(GalleryConfig.MONITORING_DIRECTORIES, [])
def test_every_caller_folds_root_and_home_alike(self):
for path in (os.path.abspath(os.sep), os.path.expanduser("~")):
with self.subTest(path=path):
self.assertEqual(
GalleryConfig.gallery_root_problem(path),
("outside-allowed", config_module.OUTSIDE_ALLOWED_REASON),
)
self.assertEqual(
GalleryConfig.validate_gallery_root(path),
(False, config_module.OUTSIDE_ALLOWED_REASON),
)
self.assertEqual(
sources.validate_source(path)["code"], "outside-allowed"
)
async def test_outside_path_is_never_resolved_or_stat(self):
"""Containment is decided as text first: no realpath, stat or lstat of
an outside path, existing or not, by any caller (I2)."""
probe = self.outside_dir / "probe-x"
probe.mkdir()
(probe / "deep").mkdir()
cases = [str(probe), str(probe / "deep"), str(probe / "missing")]
with no_filesystem_access_for("probe-x", self):
for path in cases:
with self.subTest(path=path):
self.assertEqual(
GalleryConfig.gallery_root_problem(path)[0], "outside-allowed"
)
self.assertEqual(
sources.validate_source(path)["code"], "outside-allowed"
)
_roots, error = self.api._parse_gallery_roots(
{"gallery_root_paths": [path]}
)
self.assertEqual(error["code"], "invalid-source")
status, data = await self._post(VALIDATE, {"path": path})
self.assertEqual(data["code"], "outside-allowed")
status, data = await self._post(
SETTINGS, {"gallery_root_paths": [path]}
)
self.assertEqual((status, data["code"]), (400, "invalid-source"))
# A traversal that normalises outside is refused as text too.
sneaky = os.path.join(
str(self.output_dir), "..", "..", "outside", "probe-x"
)
self.assertEqual(
GalleryConfig.gallery_root_problem(sneaky)[0], "outside-allowed"
)
async def test_settings_save_applies_the_form_checks(self):
"""POST /settings refuses what validate-source refuses, by its form."""
for path, code in {
str(self.a) + "\x00": "invalid-form",
os.path.join(str(self.output_dir), "b", "..", "a"): "traversal",
}.items():
with self.subTest(code=code):
status, data = await self._post(
SETTINGS, {"gallery_root_paths": [path]}
)
self.assertEqual((status, data["code"]), (400, "invalid-source"))
self.assertEqual(data["reason"], sources.FORM_REASONS[code])
windows = functools.partial(sources.source_form_error, windows=True)
with (
patch.object(sources, "source_form_error", windows),
# The probes hold a token no trusted directory does (on Windows the
# temporary ComfyUI tree itself sits on C:).
no_filesystem_access_for("PMProbe", self),
):
for path in (
"\\\\?\\C:\\PMProbe\\output",
"\\\\.\\pipe\\PMProbe",
"C:\\PMProbe\\output\\CON",
"C:\\PMProbe\\output\\a:stream",
"C:PMProbe",
):
with self.subTest(path=path):
_roots, error = self.api._parse_gallery_roots(
{"gallery_root_paths": ["output/a", path]}
)
self.assertEqual(error["code"], "invalid-source")
self.assertEqual(error["index"], 1)
self.assertEqual(
error["reason"], sources.FORM_REASONS["invalid-form"]
)
async def test_inside_paths_say_why_they_are_not_usable(self):
a_file = self.output_dir / "file.png"
a_file.write_bytes(b"x")
status, missing = await self._post(
VALIDATE, {"path": str(self.output_dir / "missing")}
)
status, not_dir = await self._post(VALIDATE, {"path": str(a_file)})
self.assertEqual(
(missing["code"], not_dir["code"]), ("not-found", "not-directory")
)
async def test_malformed_request_is_400(self):
for body in ({}, {"path": 42}, {"path": None}, ["x"]):
with self.subTest(body=body):
status, data = await self._post(VALIDATE, body)
self.assertEqual(status, 400, data)
self.assertEqual(data["code"], "invalid-request")
self.assertFalse(data["success"])
class TestSourceEdges(SourcesTestCase):
async def test_validation_failure_is_500_without_paths(self):
def boom(path):
raise OSError(13, "Permission denied", f"{self.tmpdir}/secret")
with patch.object(sources, "validate_source", boom):
status, data = await self._post(VALIDATE, {"path": str(self.a)})
self.assertEqual(status, 500)
self.assertEqual(data["error"], "Failed to check the source")
async def test_oversized_body_is_413_too_large(self):
resp = await self.client.request(
"POST", VALIDATE, data=json.dumps({"path": "x" * (2 << 20)})
)
self.assertEqual(resp.status, 413)
self.assertEqual((await resp.json())["code"], "too-large")
async def test_unreadable_monitor_counts_as_no_live_observer(self):
class Broken:
def get_status(self):
raise RuntimeError("observer gone")
self._install_monitor(Broken())
status, data = await self._post(
"/prompt_manager/settings", {"monitoring_enabled": True}
)
self.assertEqual(status, 200, data)
self.assertEqual(data["restart_required_fields"], ["monitoring_enabled"])
def test_labels_skip_getters_that_fail_or_are_missing(self):
import sys
import types
def fails():
raise RuntimeError("no input dir")
fake = types.SimpleNamespace(
get_output_directory=lambda: str(self.output_dir),
get_input_directory=fails,
get_temp_directory=lambda: None,
)
with patch.dict(sys.modules, {"folder_paths": fake}):
self.assertEqual(sources.source_label(self.output_dir), "ComfyUI output")
self.assertEqual(sources.source_label(self.a), "a")
with patch.dict(sys.modules, {"folder_paths": None}):
self.assertEqual(sources.source_label(self.output_dir), "output")
class TestOutsideBaseDirectories(SourcesTestCase):
"""ComfyUI folders outside the base directory (``--output-directory``):
their sources round-trip and two folders of one name stay apart (I3)."""
async def asyncSetUp(self):
await super().asyncSetUp()
self.renders = self.outside_dir / "renders"
self.input_dir = self.comfy_dir / "input"
(self.renders / "portraits").mkdir(parents=True)
(self.input_dir / "portraits").mkdir(parents=True)
sys.modules["folder_paths"] = types.SimpleNamespace(
base_path=str(self.comfy_dir),
get_output_directory=lambda: str(self.renders),
get_input_directory=lambda: str(self.input_dir),
)
async def test_source_outside_base_round_trips(self):
status, rendered = await self._post(
VALIDATE, {"path": str(self.renders / "portraits")}
)
self.assertEqual(
(rendered["valid"], rendered["path"]), (True, "renders/portraits")
)
status, data = await self._post(
SETTINGS, {"gallery_root_paths": [rendered["path"]]}
)
self.assertEqual(status, 200, data)
self.assertEqual(
GalleryConfig.MONITORING_DIRECTORIES,
[self._root(self.renders / "portraits")],
)
configured = (await self._get())["sources"]["configured"]
self.assertEqual(
[(row["path"], row["valid"]) for row in configured],
[("renders/portraits", True)],
)
# The page resends what it read: still the same folder, no change.
status, data = await self._post(
SETTINGS, {"gallery_root_paths": ["renders/portraits"]}
)
self.assertEqual((status, data["restart_required_fields"]), (200, []))
async def test_same_name_folders_stay_apart(self):
answers = []
for path in (self.renders / "portraits", self.input_dir / "portraits"):
status, data = await self._post(VALIDATE, {"path": str(path)})
answers.append(data["path"])
self.assertEqual(answers, ["renders/portraits", "input/portraits"])
status, data = await self._post(SETTINGS, {"gallery_root_paths": answers})
self.assertEqual(status, 200, data)
sources_ = (await self._get())["sources"]
self.assertEqual([row["path"] for row in sources_["configured"]], answers)
self.assertEqual([row["path"] for row in sources_["effective"]], answers)
class TestUnusableSourceSave(SourcesTestCase):
async def test_unusable_source_is_named_and_saves_nothing(self):
"""A configured source that went missing blocks the save and is named."""
gone = self.output_dir / "gone"
gone.mkdir()
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.a), self._root(gone)]
self._write_config({"custom": 1})
before = self._config_bytes()
gone.rmdir() # an unplugged drive
status, data = await self._post(
SETTINGS,
{
"gallery_root_paths": ["output/a", "output/gone", "output/b"],
"worker_threads": 2,
},
)
self.assertEqual(status, 400, data)
self.assertEqual(
data,
{
"success": False,
"code": "invalid-source",
"source": "output/gone",
"index": 1,
"reason": "Gallery root does not exist",
"error": "output/gone can't be used: Gallery root does not exist. "
"Remove it or reconnect it, then save.",
},
)
self.assertEqual(self._config_bytes(), before)
async def test_malformed_roots_are_coded_invalid_request(self):
for roots in ("output/a", [1]):
with self.subTest(roots=roots):
status, data = await self._post(SETTINGS, {"gallery_root_paths": roots})
self.assertEqual((status, data["code"]), (400, "invalid-request"))
class TestUnchangedRootsTakeNoHold(SourcesTestCase):
async def test_resent_spelling_of_configured_roots_is_no_change(self):
"""config.json holds a hand-written spelling; the legacy page resends
it resolved: no source change, so no hold and no 409 during a scan (M1)."""
GalleryConfig.MONITORING_DIRECTORIES = ["output/a"]
gate = asyncio.Event()
async def runner(job):
await gate.wait()
job.finish("finished")
self.api._jobs.start(
"scan", "Metadata scan", record_changing=True, runner=runner
)
try:
status, data = await self._post(
SETTINGS, {"gallery_root_paths": [str(self.a)], "result_timeout": 30}
)
self.assertEqual(status, 200, data)
self.assertEqual(data["restart_required_fields"], [])
self.assertEqual([j.kind for j in self.api._jobs.list()], ["scan"])
status, data = await self._post(
SETTINGS, {"gallery_root_paths": [str(self.b)]}
)
self.assertEqual((status, data["code"]), (409, "busy"))
finally:
gate.set()
class TestWindowsContainment(unittest.TestCase):
"""Windows path forms, with ntpath fakes: anything outside the allowed
folders (a UNC share, the verbatim and device namespaces, another drive)
is refused as text, before any path is resolved or opened (I2)."""
ALLOWED = "c:\\comfyui\\output"
NAS = "\\\\nas\\share\\renders"
def setUp(self):
self.touched = []
def realpath(path, *args, **kwargs):
self.touched.append(path)
return ntpath.normcase(ntpath.normpath(path))
def probe(path):
self.touched.append(path)
return True
names = ("normcase", "normpath", "isabs", "join", "dirname", "basename", "sep")
fake_path = types.SimpleNamespace(
**{name: getattr(ntpath, name) for name in names},
realpath=realpath,
exists=probe,
isdir=probe,
expanduser=lambda path: "c:\\users\\someone",
)
fake_os = types.SimpleNamespace(
path=fake_path, environ={}, pathsep=";", name="nt"
)
for target, value in (
(patch.object(config_module, "os", fake_os), None),
(
patch.object(
GalleryConfig,
"_allowed_parent_pairs",
classmethod(
lambda cls: [(self.ALLOWED, self.ALLOWED), (self.NAS, self.NAS)]
),
),
None,
),
(
patch.object(
GalleryConfig,
"path_anchors",
classmethod(lambda cls: ["c:\\comfyui", "\\\\nas\\share"]),
),
None,
),
):
target.start()
self.addCleanup(target.stop)
def test_outside_forms_are_refused_untouched(self):
for path in (
"\\\\attacker\\share\\x",
"//attacker/share/x",
"\\\\?\\C:\\ComfyUI\\output\\a",
"\\\\?\\UNC\\attacker\\share",
"\\\\.\\pipe\\x",
"\\\\.\\C:\\ComfyUI\\output",
"D:\\Renders",
"C:\\Windows",
"C:\\ComfyUI\\output\\..\\..\\Windows",
"C:\\ComfyUI\\output2",
"\\\\nas\\share\\other",
"..\\..\\Windows",
):
with self.subTest(path=path):
self.touched.clear()
self.assertEqual(
GalleryConfig.gallery_root_problem(path),
("outside-allowed", config_module.OUTSIDE_ALLOWED_REASON),
)
self.assertEqual(self.touched, [])
def test_allowed_spellings_are_accepted(self):
for path in (
"C:\\ComfyUI\\output\\a",
"c:/comfyui/OUTPUT/a",
"output\\a",
"\\\\NAS\\share\\renders\\a", # a UNC spelling of an allowed extra root
):
with self.subTest(path=path):
self.assertEqual(GalleryConfig.gallery_root_problem(path), (None, ""))
class TestSourceFormError(unittest.TestCase):
"""The lexical check that runs before any filesystem access."""
def test_windows_forms(self):
err = sources.source_form_error
for path in (
"C:\\ComfyUI\\output",
"C:/ComfyUI/output",
"\\\\server\\share\\output",
"D:\\images",
):
with self.subTest(path=path):
self.assertIsNone(err(path, windows=True))
for path, code in {
"C:output": "invalid-form", # drive-relative
"C:\\out\\a:stream": "invalid-form", # alternate data stream
"C:\\out\\CON": "invalid-form",
"C:\\out\\nul.txt": "invalid-form",
"C:\\out\\folder.": "invalid-form",
"C:\\out\\folder \\sub": "invalid-form",
"\\\\?\\C:\\out": "invalid-form",
"//?/C:/out": "invalid-form",
"\\\\.\\pipe\\x": "invalid-form",
"\\\\server": "invalid-form",
"C:\\out\\..\\in": "traversal",
"output\\..\\input": "traversal",
}.items():
with self.subTest(path=path):
self.assertEqual(err(path, windows=True), code)
def test_posix_forms(self):
err = sources.source_form_error
for path in ("/srv/comfy/output", "output/renders", "out/CON", "a b/c."):
with self.subTest(path=path):
self.assertIsNone(err(path, windows=False))
for path, code in {
"C:\\ComfyUI\\output": "invalid-form",
"C:/ComfyUI/output": "invalid-form",
"\\\\server\\share": "invalid-form",
"out\\renders": "invalid-form",
"/srv/../etc": "traversal",
"../output": "traversal",
"/srv/out\x00": "invalid-form",
"/srv/out\n": "invalid-form",
"": "empty",
}.items():
with self.subTest(path=path):
self.assertEqual(err(path, windows=False), code)
if __name__ == "__main__":
unittest.main()
-57
View File
@@ -416,63 +416,6 @@ class TestStatisticsRoute(SystemAPITestCase):
)
class TestFailedReadsSettleFirst(SystemAPITestCase):
"""A failed read answers only after the route's other reads finished.
Answering on the first failure (``asyncio.gather`` without
``return_exceptions``) left the other executor reads running against a
database the caller may already be closing; on Python 3.10 CI that
crashed the test process inside SQLite.
"""
async def assert_answers_after_reads(self, url, failing, slow, error):
raised, release, finished = (threading.Event() for _ in range(3))
real = getattr(*slow)
def fail(*args, **kwargs):
raised.set()
raise RuntimeError("secret")
def held(*args, **kwargs):
release.wait()
try:
return real(*args, **kwargs)
finally:
finished.set()
with (
patch.object(*failing, side_effect=fail),
patch.object(*slow, side_effect=held),
self.assertLogs("prompt_manager.api", "ERROR"),
):
answer = asyncio.ensure_future(self.json("GET", url, 500))
try:
await asyncio.get_running_loop().run_in_executor(None, raised.wait)
done, _ = await asyncio.wait({answer}, timeout=0.5)
self.assertEqual(done, set(), "answered while a read still ran")
finally:
release.set()
body = await answer
self.assertTrue(finished.is_set())
self.assertEqual(body, {"success": False, "code": None, "error": error})
async def test_statistics(self):
await self.assert_answers_after_reads(
"/prompt_manager/system/statistics",
(self.api.db, "usage_by_category"),
(self.api.db, "get_statistics"),
"Failed to read statistics",
)
async def test_overview(self):
await self.assert_answers_after_reads(
"/prompt_manager/system/overview",
(self.api.db, "library_counts"),
(self.api, "_output_dirs_with_origin"),
"Failed to read the overview",
)
if __name__ == "__main__":
import unittest
-38
View File
@@ -1,38 +0,0 @@
"""The System browser hooks only ever reset their own server's config.json (#249 S3)."""
import os
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from tests.browser.workspace_system_fixture import register_system_commands
class TestWorkspaceSystemFixture(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name) / "server"
self.root.mkdir()
def test_refuses_a_config_path_outside_its_root(self):
elsewhere = Path(self.tmp.name) / "config.json"
elsewhere.write_bytes(b'{"real": true}')
for value in (str(elsewhere), None):
env = {
k: v for k, v in os.environ.items() if k != "PROMPT_MANAGER_CONFIG_PATH"
}
if value is not None:
env["PROMPT_MANAGER_CONFIG_PATH"] = value
with (
self.subTest(value=value),
mock.patch.dict(os.environ, env, clear=True),
):
with self.assertRaisesRegex(RuntimeError, "PROMPT_MANAGER_CONFIG_PATH"):
register_system_commands(None, None, None, self.root)
self.assertEqual(elsewhere.read_bytes(), b'{"real": true}')
if __name__ == "__main__":
unittest.main()
-14
View File
@@ -51,17 +51,3 @@ test('CI retains stock, execution and pack suites in order with uploaded executi
assert.deepEqual(commands, ['test:comfyui', 'test:executions', 'test:compat']);
assert.match(workflow, /path: tools\/vendor\/test-results\//);
});
test('real-host config waits for its own launcher, never for whichever server holds the port', async t => {
const previous = process.env.PM_COMFY_ROOT;
process.env.PM_COMFY_ROOT = '/unused-pinned-checkout';
t.after(() => { if (previous === undefined) delete process.env.PM_COMFY_ROOT; else process.env.PM_COMFY_ROOT = previous; });
const { default: config } = await import('../../tools/vendor/comfyui.config.mjs');
const [server] = config.webServer;
assert.equal(server.url, undefined);
assert.equal(server.port, undefined);
assert.equal(server.reuseExistingServer, false);
const ready = server.wait.stdout.exec('Starting server\nPM_COMFY_HOST_READY base-directory=/tmp/comfyassets-host-a b\r\nlater');
assert.equal(ready.groups.pm_comfy_host_base, '/tmp/comfyassets-host-a b');
assert.equal(server.wait.stdout.exec('To see the GUI go to: http://127.0.0.1:8189'), null);
});
+1 -50
View File
@@ -6,8 +6,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { once } from 'node:events';
import { createServer } from 'node:net';
import { createServer as createHttpServer } from 'node:http';
import { launchComfyHost, assertPortFree, waitForOwnHost, hostBaseDirectory } from '../../tools/vendor/comfyui-host.mjs';
import { launchComfyHost } from '../../tools/vendor/comfyui-host.mjs';
async function fixture(t, program) {
const directory = await mkdtemp(join(tmpdir(), 'pm-host-test-'));
@@ -233,51 +232,3 @@ test('execution launcher refuses an occupied port without touching an existing s
await new Promise(resolve => server.close(resolve));
}
});
// A stand-in ComfyUI that answers /system_stats with its own argv, as the real host does.
const answersOwnArgv = `const args = process.argv.slice(1);
require('node:http').createServer((request, response) => {
response.setHeader('content-type', 'application/json');
response.end(JSON.stringify({ system: { argv: args } }));
}).listen(Number(args[args.indexOf('--port') + 1]), '127.0.0.1');`;
async function listen(t, port, handler = (request, response) => response.end()) {
const server = createHttpServer(handler);
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, '127.0.0.1', resolve); });
t.after(() => new Promise(resolve => server.close(resolve)));
return server;
}
test('real-host launcher refuses a port another run already holds and leaves that server running', async t => {
const server = await listen(t, 8199);
await assert.rejects(assertPortFree(8199), /8199 is already in use.*never shared/s);
assert.equal(server.listening, true);
});
test('real-host launcher is ready only when its own ComfyUI answers the port', async t => {
const options = await fixture(t, answersOwnArgv);
await assertPortFree(options.port);
const host = await launchComfyHost(options);
t.after(() => host.stop());
await waitForOwnHost(host, options.port);
const stats = await (await fetch(`http://127.0.0.1:${options.port}/system_stats`)).json();
assert.equal(hostBaseDirectory(stats), host.stateDirectory);
});
test('real-host launcher refuses a host it did not start that answers before its own binds', async t => {
await listen(t, 8199, (request, response) => {
response.setHeader('content-type', 'application/json');
response.end(JSON.stringify({ system: { argv: ['main.py', '--base-directory', '/other-run-state'] } }));
});
const options = await fixture(t, idle);
const host = await launchComfyHost(options);
t.after(() => host.stop());
await assert.rejects(waitForOwnHost(host, options.port), /did not start \(base directory \/other-run-state\)/);
});
test('real-host launcher reports a host that exits before it answers', async t => {
const options = await fixture(t, 'process.exit(3);');
const host = await launchComfyHost(options);
t.after(() => host.stop());
await assert.rejects(waitForOwnHost(host, options.port), /exited before it answered on port 8199 \(exit=3/);
});
+3 -58
View File
@@ -1,5 +1,4 @@
import { writeFile } from 'node:fs/promises';
import { createServer } from 'node:net';
import { join, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { launchComfyHost as launchSharedHost } from '@comfyassets/toolkit/testing/host';
@@ -8,9 +7,6 @@ import runtime from './comfyui-runtime.json' with { type: 'json' };
export { launchSharedHost };
const repositoryRoot = fileURLToPath(new URL('../../', import.meta.url));
// comfyui.config.mjs waits for this line; its named group becomes PM_COMFY_HOST_BASE in the test workers.
export const HOST_READY_MARKER = 'PM_COMFY_HOST_READY';
const READY_POLL_MS = 250;
export async function preparePromptManagerPlugin({ installed, stateDirectory }) {
const configPath = join(installed, 'config.json');
@@ -31,68 +27,17 @@ export function launchComfyHost({ checkout, repository = repositoryRoot,
});
}
export async function assertPortFree(port) {
await new Promise((resolve, reject) => {
const probe = createServer();
probe.once('error', error => reject(new Error(`ComfyUI host port ${port} is already in use, often by another `
+ 'real-host run. A managed host is never shared: stop that run or set PM_COMFY_URL to a free '
+ 'http://127.0.0.1:PORT/ address.', { cause: error })));
probe.listen({ host: '127.0.0.1', port, exclusive: true }, () => probe.close(error => error ? reject(error) : resolve()));
});
}
export function hostBaseDirectory(stats) {
const argv = stats?.system?.argv;
if (!Array.isArray(argv)) return null;
const index = argv.indexOf('--base-directory');
return index >= 0 ? argv[index + 1] ?? null : null;
}
// Ready means this launcher's own ComfyUI answers: /system_stats reports its base directory.
// Any other answer on the port comes from a host this run did not start, so the run must not use it.
export async function waitForOwnHost(host, port, { fetchImpl = fetch } = {}) {
let exited = null;
host.finished.then(result => { exited = result; });
const url = `http://127.0.0.1:${port}/system_stats`;
for (;;) {
let stats = null;
try { stats = await (await fetchImpl(url)).json(); }
catch { stats = null; }
if (stats) {
const base = hostBaseDirectory(stats);
if (base === host.stateDirectory) return;
throw new Error(`Port ${port} is answered by a ComfyUI host this run did not start `
+ `(base directory ${base ?? 'unknown'}); another run is using the port.`);
}
if (exited) {
throw new Error(`ComfyUI host exited before it answered on port ${port} `
+ `(exit=${exited.code}, signal=${exited.signal || 'none'}).`);
}
await new Promise(resolve => setTimeout(resolve, READY_POLL_MS));
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
try {
const port = Number(process.env.PM_COMFY_PORT || 8189);
await assertPortFree(port);
const host = await launchComfyHost({ checkout: process.env.PM_COMFY_ROOT, port });
const host = await launchComfyHost({ checkout: process.env.PM_COMFY_ROOT,
port: Number(process.env.PM_COMFY_PORT || 8189) });
const stop = () => { host.stop().catch(error => { console.error(error.message); process.exitCode = 1; }); };
process.once('SIGINT', stop);
process.once('SIGTERM', stop);
let refused = false;
try {
await waitForOwnHost(host, port);
console.log(`${HOST_READY_MARKER} base-directory=${host.stateDirectory}`);
} catch (error) {
refused = true;
console.error(error.message);
await host.stop();
}
const result = await host.finished;
process.removeListener('SIGINT', stop);
process.removeListener('SIGTERM', stop);
if (result.code) console.error(`ComfyUI host exited with status ${result.code}; see tools/vendor/test-results/comfyui-host.log`);
process.exitCode = refused ? 1 : result.code;
process.exitCode = result.code;
} catch (error) { console.error(error.message); process.exitCode = 1; }
}
+1 -6
View File
@@ -1,6 +1,5 @@
import { defineConfig } from './node_modules/@playwright/test/index.mjs';
import { fileURLToPath } from 'node:url';
import { HOST_READY_MARKER } from './comfyui-host.mjs';
if (!process.env.PM_COMFY_ROOT) throw new Error('Set PM_COMFY_ROOT to a pinned disposable ComfyUI checkout');
const host = new URL(process.env.PM_COMFY_URL || 'http://127.0.0.1:8189/');
@@ -8,11 +7,7 @@ if (host.protocol !== 'http:' || host.hostname !== '127.0.0.1' || !host.port) th
const servers = [{
command: 'node tools/vendor/comfyui-host.mjs',
cwd: fileURLToPath(new URL('../../', import.meta.url)),
// No URL wait: any server on the port would satisfy it, including another run's host that
// then stops mid-test. The launcher refuses a used port and prints this line only once its
// own ComfyUI answers; the group reaches the tests as PM_COMFY_HOST_BASE.
wait: { stdout: new RegExp(`^${HOST_READY_MARKER} base-directory=(?<pm_comfy_host_base>.+?)\r?$`, 'm') },
timeout: 120000, reuseExistingServer: false,
url: new URL('system_stats', host).href, timeout: 120000, reuseExistingServer: false,
env: { PM_COMFY_PORT: host.port },
gracefulShutdown: { signal: 'SIGTERM', timeout: 10000 },
}];
+11 -29
View File
@@ -7,7 +7,7 @@
},
"api-client.js": {
"lines": 98,
"branches": 96,
"branches": 95,
"functions": 98,
"statements": 98
},
@@ -37,15 +37,15 @@
},
"gallery-settings.js": {
"lines": 98,
"branches": 94,
"branches": 90,
"functions": 98,
"statements": 98
},
"gallery.js": {
"lines": 41,
"lines": 40,
"branches": 63,
"functions": 44,
"statements": 41
"statements": 40
},
"html-escape.js": {
"lines": 98,
@@ -163,7 +163,7 @@
},
"workspace/activity-scan.mjs": {
"lines": 96,
"branches": 78,
"branches": 73,
"functions": 98,
"statements": 96
},
@@ -401,12 +401,6 @@
"functions": 98,
"statements": 98
},
"workspace/preferences.mjs": {
"lines": 98,
"branches": 96,
"functions": 98,
"statements": 98
},
"workspace/prompt-blocks.mjs": {
"lines": 90,
"branches": 92,
@@ -449,12 +443,6 @@
"functions": 98,
"statements": 98
},
"workspace/system-appearance.mjs": {
"lines": 98,
"branches": 93,
"functions": 98,
"statements": 98
},
"workspace/system-controls.mjs": {
"lines": 98,
"branches": 87,
@@ -467,23 +455,17 @@
"functions": 98,
"statements": 96
},
"workspace/system-sources.mjs": {
"lines": 98,
"branches": 88,
"functions": 95,
"statements": 98
},
"workspace/system-state.mjs": {
"lines": 98,
"branches": 94,
"functions": 98,
"branches": 92,
"functions": 92,
"statements": 98
},
"workspace/system.mjs": {
"lines": 98,
"branches": 92,
"functions": 86,
"statements": 98
"lines": 93,
"branches": 85,
"functions": 69,
"statements": 93
},
"workspace/theme.mjs": {
"lines": 98,
+3 -95
View File
@@ -6,28 +6,19 @@ the inspection projection (``project_graph``: strict JSON, the depth and node
bounds, big integers and decimals as text), and only graphs it parsed, through
the shared parser (``extract_generation_params``, the Python twin of the
browser parser the inspector fills from since #251). No separate seed
heuristic and no extra file reads. One parse feeds both the seed and the
positive that confirms a first-line card title. One exception keeps the card
from ever showing a seed the inspector does not: when sampler stages read
different seed inputs and the inspector's resolution names one of them for a
resolved pair (whose facts are that stage's), the card shows no seed when the
named stage's seed differs from the parser's. Only such graphs are resolved,
with the inspector's own profile.
heuristic, no graph resolution and no extra file reads. One parse feeds both
the seed and the positive that confirms a first-line card title.
"""
import json
import re
from collections.abc import Mapping
from .generation_params import (
PLACEHOLDERS,
GenerationParamsError,
_is_sampler,
extract_generation_params,
)
from .inspection_response import project_graph
from .inspection_semantics import SemanticsError, get_semantics
from .prompt_pair_resolver import INSPECTION_PROFILE, resolve_prompt_pair
__all__ = [
"MAX_RECORD_BYTES",
@@ -82,93 +73,10 @@ def header_params(info) -> dict | None:
if not graphs:
return None
try:
params = extract_generation_params(graphs)
return extract_generation_params(graphs)
except (GenerationParamsError, RecursionError):
# Defence in depth: a parser failure gives no card facts, never a 500.
return None
if isinstance(params, dict) and _inspector_names_another_seed(
graphs, card_seed(params)
):
return {**params, "seed": None}
return params
_SEED_INPUTS = ("seed", "noise_seed", "noise")
def _stage_seed(graph: Mapping, node_id: str) -> str | None:
"""The seed the parser reads for one sampler node, or None when unknown.
A literal seed input is that seed. A linked one is read by the parser
itself when it treats the node as a sampler, with every other such node
hidden from it (no ``positive`` input), so linked seeds resolve as they do
on the card; any other linked seed is unknown.
"""
node = graph.get(node_id)
if not isinstance(node, Mapping) or not isinstance(node.get("inputs"), Mapping):
return None
inputs = node["inputs"]
literal = next((inputs[k] for k in _SEED_INPUTS if k in inputs), None)
if not isinstance(literal, (list, tuple)):
# A literal needs no second parse (the cost is measured in TESTING.md).
return card_seed({"seed": literal})
if _is_sampler(node):
only = {
key: (
{**other, "inputs": {**other["inputs"], "positive": None}}
if key != node_id and _is_sampler(other)
else other
)
for key, other in graph.items()
}
try:
return card_seed(extract_generation_params({"prompt": only}))
except (GenerationParamsError, RecursionError):
return None
return None
def _inspector_names_another_seed(graphs: dict, parsed: str | None) -> bool:
"""Whether the inspector shows another stage's seed than the parser's.
True when the prompt graph's sampler stages read different seed inputs, the
inspection resolution names one sampler for a resolved pair (the
inspector's facts are then that stage's, D19), and that stage's seed is
known and differs from ``parsed``. A seed that agrees is kept.
"""
if parsed is None:
return False
graph = graphs.get("prompt")
if not isinstance(graph, Mapping):
return False
try:
samplers = get_semantics().samplers
except SemanticsError:
return False
seeds = set()
for node in graph.values():
if not isinstance(node, Mapping) or not isinstance(node.get("inputs"), Mapping):
continue
if not isinstance(node.get("class_type"), str):
continue
if node["class_type"] not in samplers:
continue
inputs = node["inputs"]
value = next((inputs[key] for key in _SEED_INPUTS if key in inputs), None)
seeds.add(json.dumps(value, sort_keys=True, default=str))
if len(seeds) < 2:
return False
try:
resolution = resolve_prompt_pair(
{"prompt": dict(graph)}, profile=INSPECTION_PROFILE
)
except RecursionError:
return True
named = resolution["sampler_id"]
if resolution["state"] != "resolved" or named is None:
return False
seed = _stage_seed(graph, named)
return seed is not None and seed != parsed
def card_seed(params: dict | None) -> str | None:
+4 -42
View File
@@ -12,10 +12,8 @@ valid result. Importing this module reads nothing, and the default (ingestion)
profile never imports it, so a missing or bad table can never break ingestion.
An invalid table raises ``SemanticsError`` so a bad edit fails loudly instead of
silently changing what the inspector resolves. The file is bounded in size and
nesting before it is validated. Every class appears in exactly one prompt
category; classes that are not listed stay ``unsupported-node``. ``lineage``
(image and latent pass-through outputs for multi-stage chains) describes no
prompt, so its classes are not claimed by a category.
nesting before it is validated. Every class appears in exactly one category;
classes that are not listed stay ``unsupported-node``.
"""
import json
@@ -46,7 +44,6 @@ STRATEGIES: Mapping[str, FrozenSet[str]] = MappingProxyType(
"encoders": frozenset({"single", "parts-equal", "pm-compose", "flag-parts"}),
"strings": frozenset({"literal", "strip-newlines", "join-multi", "concat"}),
"savers": frozenset({"comfy-counter", "vhs-counter"}),
"lineage": frozenset({"continue"}),
}
)
# Shapes of the facts each sampler class provides (consumed by comfy-metadata.js).
@@ -75,7 +72,7 @@ _CATEGORIES = frozenset(
+ _CLASS_CATEGORIES
+ _SLOT_CATEGORIES
+ _SET_CATEGORIES
+ ("lineage", "stage_inputs", "facts")
+ ("stage_inputs", "facts")
)
# Input names that make an unlisted node a possible sampler stage in an ancestry
# walk (``shaped``: pipes, video embeds and custom-sampler parts carry a prompt
@@ -114,7 +111,6 @@ class Semantics:
runtime: FrozenSet[str]
savers: Mapping[str, Mapping[str, Any]]
non_file_outputs: FrozenSet[str]
lineage: Mapping[Tuple[str, int], Mapping[str, Any]]
stage_inputs: Mapping[str, Tuple[str, ...]]
facts: Mapping[str, Any]
all_classes: FrozenSet[str]
@@ -229,12 +225,8 @@ def _check_selector(entry: dict, strategy: str, where: str) -> None:
def _check_composite(entry: dict, strategy: str, where: str) -> None:
# ``additive``: every part stays in effect (combine, concat), so a later
# stage that adds parts to an earlier stage's prompt keeps that prompt.
_fields(entry, where, frozenset({"strategy", "inputs"}), frozenset({"additive"}))
_fields(entry, where, frozenset({"strategy", "inputs"}), frozenset())
_names(entry["inputs"], where, minimum=2)
if "additive" in entry and not isinstance(entry["additive"], bool):
raise SemanticsError(f"{where}: additive must be a boolean")
def _check_saver(entry: dict, strategy: str, where: str) -> None:
@@ -364,35 +356,6 @@ def _slot_category(doc: dict, category: str, owners: _Owners) -> Mapping:
return MappingProxyType(result)
def _lineage(doc: dict) -> Mapping:
"""``(class, slot)`` outputs whose image or latent continues one input's.
These describe an output's image or latent, never its prompt, so they are
not prompt categories: a class listed here is not claimed, and stays
unsupported (or listed elsewhere) for text. Each output is listed once.
"""
rows = doc["lineage"]
if not isinstance(rows, list):
raise SemanticsError("category lineage must be a list")
result = {}
for index, row in enumerate(rows):
where = f"lineage[{index}]"
if not isinstance(row, dict):
raise SemanticsError(f"{where}: entry must be an object")
name = _name(row.get("class"), where, "class")
slot = _slot(row, where)
entry = {
key: value for key, value in row.items() if key not in ("class", "slot")
}
_strategy(entry, "lineage", where)
_fields(entry, where, frozenset({"strategy", "input"}), frozenset())
_name(entry["input"], where)
if (name, slot) in result:
raise SemanticsError(f"lineage: duplicate output key {(name, slot)!r}")
result[(name, slot)] = _freeze(entry)
return MappingProxyType(result)
def _set_category(doc: dict, category: str, owners: _Owners) -> FrozenSet[str]:
rows = doc[category]
if not isinstance(rows, list):
@@ -489,7 +452,6 @@ def _validate(doc: Any) -> Semantics:
for category in _SET_CATEGORIES:
tables[category] = _set_category(doc, category, owners)
return Semantics(
lineage=_lineage(doc),
stage_inputs=_stage_inputs(doc),
facts=_facts(doc),
all_classes=owners.classes(),
-42
View File
@@ -1,42 +0,0 @@
"""Strict JSON output for values that may carry ComfyUI's non-finite numbers.
ComfyUI writes its metadata with Python's default ``json.dumps``, so a graph can
hold ``NaN``, ``Infinity`` and ``-Infinity`` (an ``IS_CHANGED`` that returns
``float("nan")`` puts ``"is_changed": [NaN]`` into every saved prompt). Those
literals are not JSON: browsers' ``JSON.parse`` and ``allow_nan=False`` reject
them. Reading follows the legacy pages (``parse_page_json`` where the browser
parser is mirrored; Python's ``json.loads`` elsewhere). Whatever PromptManager
stores or answers as JSON goes through ``dumps_strict``, which writes each
non-finite number as ``null`` (the legacy pages' own replacement for NaN) and
leaves strings, including the words ``NaN`` or ``Infinity`` inside prompt
text, untouched.
"""
import json
from typing import Any
__all__ = ["dumps_strict"]
def _null(_constant: str) -> None:
return None
def dumps_strict(value: Any, **kwargs: Any) -> str:
"""``json.dumps(value, allow_nan=False, **kwargs)`` with non-finite numbers null.
A value without non-finite numbers serializes byte for byte as the plain
call does. Any other error (a cycle, an unserializable object) still raises.
"""
kwargs.pop("allow_nan", None)
try:
return json.dumps(value, allow_nan=False, **kwargs)
except ValueError as error:
if "Out of range float" not in str(error):
raise
# The non-strict text spells each non-finite number as a bare literal, never
# inside a string, so parsing it back maps exactly those numbers to null.
lenient = json.dumps(value, allow_nan=True, default=kwargs.get("default"))
return json.dumps(
json.loads(lenient, parse_constant=_null), allow_nan=False, **kwargs
)
+23 -282
View File
@@ -191,18 +191,6 @@ EMPTY_POSITIVE_REASON = "Positive prompt is empty"
# Several sampler branches, every one resolved to the same pair (#249).
IDENTICAL_BRANCHES_REASON = "Every sampler branch uses the same prompt pair"
MULTISTAGE_UNATTRIBUTED_REASON = "Multistage sampler ancestry is ambiguous"
# A later stage's positive combines the earlier stages' shared prompt with more
# conditioning (a style trigger): that shared pair is the run's prompt (floor).
ADDED_CONDITIONING_REASON = "A later stage adds conditioning to the base prompt"
# Maintainer option A (2026-10-23): a PromptManager prepend or append fed
# directly by a run-time node (LoRA trigger words) is left out, never guessed;
# the text is the node's own, labelled by this source and reason (D125). A side
# is labelled when any node on its traced path left a part out.
RUNTIME_PARTS_SOURCE = "runtime-parts-omitted"
RUNTIME_PARTS_REASON = "Text added at run time, such as trigger words, is not included"
# The added-conditioning floor's positive (D126), and both labels at once.
ADDED_CONDITIONING_SOURCE = "added-conditioning"
RUNTIME_AND_ADDED_SOURCE = "runtime-parts-omitted-and-added-conditioning"
_INCOMPATIBLE_SLOT = "Output slot has an incompatible type"
# Before 3.2.3 a save patch overwrote every PromptManager node's saved ``text``
# with the last-run node's prompt (comfy-metadata.js ``createContext``).
@@ -322,51 +310,15 @@ def _role_text(side: Any) -> Optional[str]:
return side["text"] if isinstance(side, dict) else None
def _left_out(side: Any) -> tuple:
"""What a side's text leaves out: the PromptManager nodes that omitted a
run-time part, and the composite whose added conditioning is not included.
Empty for a complete text, so a partial prompt never equals a complete one.
"""
provenance = side.get("provenance") if isinstance(side, dict) else None
if not isinstance(provenance, dict):
return ()
return (
tuple(provenance.get("omitted_parts", ())),
provenance.get("added_by"),
)
def _outcome_key(result: Dict[str, Any]) -> tuple:
"""What a reader sees of a result: state, the two role texts, and what
each text leaves out."""
"""What a reader sees of a result: state and the two role texts."""
return (
result["state"],
_role_text(result["positive"]),
_role_text(result["negative"]),
_left_out(result["positive"]),
_left_out(result["negative"]),
)
def _reroute_value(inputs: dict) -> Any:
"""A Reroute's one input (``value``, ``input`` or ``in``), else ``None``."""
keys = [key for key in ("value", "input", "in") if key in inputs]
return inputs[keys[0]] if len(keys) == 1 else None
def _parts_reason(*sides: dict) -> str:
"""The run-time parts reason when a side left out a PromptManager part."""
if any(side["provenance"]["text_source"] == RUNTIME_PARTS_SOURCE for side in sides):
return RUNTIME_PARTS_REASON
return ""
def _combined_positive(result: Dict[str, Any]) -> bool:
"""Whether a stage failed only because its positive combines texts."""
return result["reason_code"] == "composite" and result["positive"] is None
def _legacy_result(result: Dict[str, Any]) -> Dict[str, Any]:
"""The default profile's wire: today's keys plus ``reason_code: None``."""
legacy = {key: value for key, value in result.items() if key != "node"}
@@ -422,8 +374,6 @@ class _GraphResolver:
self.active: set = set()
self.metadata = metadata or {}
self.text_sources: dict = {}
# PromptManager outputs that left out a run-time part (option A).
self.omitting: set = set()
# Forced runtime selector choices (inspection); a result traced through
# one is "tainted" and never memoized, since it depends on the choice.
self.choices: dict = {}
@@ -573,12 +523,11 @@ class _GraphResolver:
if adapter.compose or adapter.storage_input:
storage_text = text
if adapter.compose:
omitted: list = []
before, before_path = self.part(
inputs.get("prepend_text", ""), depth, omitted
before, before_path, _ = self.text(
inputs.get("prepend_text", ""), "string", depth + 1
)
after, after_path = self.part(
inputs.get("append_text", ""), depth, omitted
after, after_path, _ = self.text(
inputs.get("append_text", ""), "string", depth + 1
)
self.scan(
len(before)
@@ -597,8 +546,6 @@ class _GraphResolver:
)
)
path = before_path + path + after_path
if omitted:
self.omitting.add((node_id, slot))
elif (
name in ("StringConcatenate", "Text Concatenate")
and slot == 0
@@ -668,30 +615,6 @@ class _GraphResolver:
self.tainted = outer_tainted or self.tainted
self.active.discard(key)
def part(self, value: Any, depth: int, omitted: list) -> Tuple[str, list]:
"""A PromptManager prepend or append: traced, or left out when run time.
Only with the semantics table (inspection), and only when the input is
a link to a node the table lists as ``runtime`` (such as LoraManager's
TriggerWord Toggle), which raises ``runtime-value`` at itself: that part
is not in the file, so it is left out and recorded in ``omitted``.
Anything else, including a run-time source further upstream or another
``runtime-value`` node (a JoinStringMulti list), keeps its failure.
"""
try:
text, path, _ = self.text(value, "string", depth + 1)
except _Unresolved as error:
if (
self.semantics is None
or error.code != "runtime-value"
or not _link(value)
or self.node(str(value[0]))[0] not in self.semantics.runtime
):
raise
omitted.append(str(value[0]))
return "", []
return text, path
def historical(
self, adapter: OutputAdapter, inputs: dict, depth: int
) -> Tuple[str, list, str]:
@@ -1062,15 +985,6 @@ class _GraphResolver:
if guider_id is not None:
# The conditioning path starts below the guider the sampler reads.
provenance["guider_id"] = guider_id
omitted: list = []
for step in path:
if (step["node_id"], step["output_slot"]) in self.omitting:
if step["node_id"] not in omitted:
omitted.append(step["node_id"])
if omitted:
# Through encoders, concatenations and chained PromptManagers (D125).
provenance["text_source"] = RUNTIME_PARTS_SOURCE
provenance["omitted_parts"] = omitted
return {"text": text, "storage_text": storage_text, "provenance": provenance}
def guider(self, value: Any) -> Tuple[str, dict, Mapping, str, int]:
@@ -1147,18 +1061,19 @@ class _GraphResolver:
reason_code="depth-limit",
)
own = self.sides(sampler_id)
# An unresolved stage is final, unless its positive combines texts: it
# may then add conditioning to its earlier stages' prompt (the floor).
combined = own["state"] == "unresolved" and _combined_positive(own)
if own["state"] == "unresolved" and not combined:
if own["state"] == "unresolved":
return own
name, inputs = self.node(sampler_id)
entry = self.semantics.samplers.get(name)
if entry is None or entry["strategy"] != _DETAILER:
return own
try:
upstream = self.earlier_stages(sampler_id)
upstream = self.upstream_stages(
sampler_id, inputs.get(entry["image_stage"])
)
if any(node_id in later for node_id in upstream):
raise _Unresolved("Cyclic sampler stage ancestry", "malformed")
except _Unresolved as error:
if combined:
return own
return _result(
"unresolved",
sampler_id=sampler_id,
@@ -1171,10 +1086,6 @@ class _GraphResolver:
later = later | {sampler_id}
stages = [self.stage(node_id, later) for node_id in upstream]
blocked = next((item for item in stages if item["state"] == "unresolved"), None)
if combined:
if blocked is not None:
return own
return self.added_conditioning(sampler_id, stages) or own
if blocked is not None:
# A stage that cannot be read cannot be compared: never pick one.
return _result(
@@ -1197,128 +1108,6 @@ class _GraphResolver:
reason_code="multistage-differs",
)
def earlier_stages(self, sampler_id: str) -> list:
"""The stages a sampler continues: a detailer's image ancestry, or the
one sampler its latent descends from through listed lineage nodes.
The latent walk already ran in ``sides`` (its outcome is cached); a
sampler without a latent input, or whose walk found no earlier sampler,
has none.
"""
name, inputs = self.node(sampler_id)
entry = self.semantics.samplers[name]
if entry["strategy"] == _DETAILER:
return self.upstream_stages(sampler_id, inputs.get(entry["image_stage"]))
walked = self.latent_walks.get(sampler_id)
if walked is None or walked[2] is None:
return []
return [walked[2]]
def lineage_predecessor(self, value: Any) -> Optional[str]:
"""The sampler whose output a latent input continues, or ``None``.
Follows only the table's ``lineage`` outputs (decode, image filter,
upscale, scale, encode) and Reroutes, from the input back to a listed
sampler's output slot 0. Any other node ends the lineage: its output is
not known to continue one image or latent. At most ``max_depth`` hops;
each node is read through ``node`` (one scan charge per node).
"""
seen: set = set()
while _link(value) and len(seen) < self.max_depth:
node_id, slot = str(value[0]), value[1]
if node_id in seen:
return None
seen.add(node_id)
name, inputs = self.node(node_id)
if name in self.stage_classes:
return node_id if slot == 0 else None
if name == "Reroute" and slot == 0:
value = _reroute_value(inputs)
continue
entry = self.semantics.lineage.get((name, slot))
if entry is None:
return None
value = inputs.get(entry["input"])
return None
def added_conditioning(self, sampler_id: str, stages: list) -> Optional[dict]:
"""The earlier stages' shared pair when this stage only adds to it.
This stage's positive reaches (through Reroutes and conditioning
pass-throughs) an ``additive`` composite, one of whose parts is exactly
the earlier stages' positive text, and its negative is exactly theirs:
every stage is conditioned on that prompt, the last one with more. Any
other shape, or a part that cannot be traced, is ``None``.
"""
flat: list = []
for item in stages:
flat.extend(item["candidates"] if item["state"] == "ambiguous" else [item])
base = flat[0]
if base["state"] != "resolved" or len({_outcome_key(i) for i in flat}) != 1:
return None
name, inputs = self.node(sampler_id)
entry = self.semantics.samplers[name]
if entry["strategy"] == "custom-advanced":
return None
conditioning = entry["conditioning"]
try:
found = self.composite_of(inputs.get(conditioning["positive"]))
if found is None:
return None
parts, depth, composite = found
texts = [self.text(part, "conditioning", depth)[0] for part in parts]
if base["positive"]["text"] not in texts:
return None
if conditioning["negative"] not in inputs:
return None
negative = self.side(inputs[conditioning["negative"]], sampler_id)
except _Unresolved:
return None
if negative["text"] != base["negative"]["text"]:
return None
# D126: the positive does not include what the later stage added.
provenance = dict(base["positive"]["provenance"])
provenance["text_source"] = (
RUNTIME_AND_ADDED_SOURCE
if provenance["text_source"] == RUNTIME_PARTS_SOURCE
else ADDED_CONDITIONING_SOURCE
)
provenance["added_by"] = composite
positive = {**base["positive"], "provenance": provenance}
return _result(
"resolved",
positive,
base["negative"],
base["sampler_id"],
ADDED_CONDITIONING_REASON,
)
def composite_of(self, value: Any) -> Optional[Tuple[list, int]]:
"""An ``additive`` composite a conditioning input reaches, or ``None``.
Reroutes and conditioning pass-throughs (FluxGuidance and the like) are
followed; returns the composite's part values, the depth reached and
its node ID. ``text`` follows a composite only at output slot 0, so a
stage whose positive failed as ``composite`` reached it there.
"""
depth = 0
while _link(value) and depth < self.max_depth:
node_id, slot = str(value[0]), value[1]
name, inputs = self.node(node_id)
depth += 1
if name == "Reroute" and slot == 0:
value = _reroute_value(inputs)
continue
forward = self.semantics.conditioning.get((name, slot))
if forward is not None and forward["strategy"] == "passthrough":
value = inputs.get(forward["input"])
continue
composite = self.semantics.composites.get(name)
if composite is None or composite.get("additive") is not True:
return None
return [inputs.get(part) for part in composite["inputs"]], depth, node_id
return None
@staticmethod
def walk_from(value: list) -> Tuple[deque, set]:
"""A breadth-first queue holding one start link, and its queued IDs."""
@@ -1394,10 +1183,7 @@ class _GraphResolver:
if self.semantics.samplers[self.graph[node_id]["class_type"]]["strategy"]
== _DETAILER
]
# A sampler whose latent continues another's (hires-fix, refiner) is
# judged with it in ``stage``, like a detailer's upstream samplers.
continued = self.continued_stages(sampler_ids) if len(sampler_ids) > 1 else []
if not detailers and not continued:
if not detailers:
return sampler_ids
try:
consumers = self.consumers()
@@ -1414,36 +1200,9 @@ class _GraphResolver:
absorbed.add(node_id)
except _Unresolved:
continue
for node_id in continued:
self.budget.reset(self.per_candidate_visits)
try:
if not self.reaches_output(node_id, consumers):
absorbed.add(node_id)
except _Unresolved:
continue
kept = [node_id for node_id in sampler_ids if node_id not in absorbed]
return kept or sampler_ids
def continued_stages(self, sampler_ids: list) -> list:
"""Lineage predecessors of the non-detailer candidates, in order, once.
A predecessor equal to its own sampler (a cycle) is left out; a walk
that cannot read a node names none.
"""
found: list = []
for node_id in sampler_ids:
entry = self.semantics.samplers[self.graph[node_id]["class_type"]]
if entry["strategy"] == _DETAILER or entry.get("latent") is None:
continue
try:
_, inputs = self.node(node_id)
earlier = self.lineage_predecessor(inputs.get(entry["latent"]))
except _Unresolved:
continue
if earlier is not None and earlier != node_id and earlier not in found:
found.append(earlier)
return found
def file_stages(self, sampler_ids: list) -> list:
"""Stages not proven preview-only; all of them when every one is.
@@ -1622,13 +1381,7 @@ class _GraphResolver:
reason=absent_reason,
)
negative = self.side(inputs[negative_name], sampler_id, guider_id, depth)
return _result(
"resolved",
positive,
negative,
sampler_id,
_parts_reason(positive, negative),
)
return _result("resolved", positive, negative, sampler_id)
except _Unresolved as error:
return _result(
"unresolved",
@@ -1679,7 +1432,7 @@ class _GraphResolver:
return
raise self.unsupported("Unsupported latent ancestry", node_id)
def latent_checked(self, sampler_id: str, value: Any) -> Optional[str]:
def latent_checked(self, sampler_id: str, value: Any) -> None:
"""``latent_stages`` once per sampler per resolution.
A candidate reusing another candidate's walk is charged its visit cost
@@ -1688,29 +1441,26 @@ class _GraphResolver:
out of work is never reused, since it depends on the remaining budget.
"""
if sampler_id in self.latent_walks:
error, cost, earlier = self.latent_walks[sampler_id]
error, cost = self.latent_walks[sampler_id]
if sampler_id not in self.latent_charged:
self.budget.charge(cost)
self.latent_charged.add(sampler_id)
else:
before = self.budget.allowance
earlier = None
try:
earlier = self.latent_stages(sampler_id, value)
self.latent_stages(sampler_id, value)
error = None
except _Unresolved as raised:
if raised.code == "work-limit":
raise
error = raised
cost = before - self.budget.allowance
self.latent_walks[sampler_id] = (error, cost, earlier)
self.latent_walks[sampler_id] = (error, before - self.budget.allowance)
self.latent_charged.add(sampler_id)
if error is not None:
node = dict(error.node) if error.node is not None else None
raise _Unresolved(str(error), error.code, node)
return earlier
def latent_stages(self, origin: str, value: Any) -> Optional[str]:
def latent_stages(self, origin: str, value: Any) -> None:
"""Semantics table: is there an earlier sampler in the latent ancestry?
Every link input of non-sampler nodes is walked breadth-first (encoders,
@@ -1719,14 +1469,10 @@ class _GraphResolver:
(VAEEncode, EmptyLatentBatch, WanImageToVideo slot 2, ...). An unlisted
node with sampler-shaped inputs could itself be a stage, so it is
``unsupported-node``. Budget or depth exhaustion is never single stage.
One earlier sampler that the latent continues through ``lineage``
nodes (``lineage_predecessor``) is returned, to be compared in
``stage``; any other sampler found leaves the stages unattributed.
"""
if not _link(value):
raise _Unresolved("Unknown latent ancestry", "malformed")
queue, queued = self.walk_from(value)
earlier, lineage, traced = None, None, False
while queue:
node_id, depth = queue.popleft()
if node_id == origin:
@@ -1736,17 +1482,12 @@ class _GraphResolver:
self.budget.spend()
name, inputs = self.node(node_id)
if name in self.stage_classes:
if not traced:
lineage, traced = self.lineage_predecessor(value), True
if node_id != lineage:
# Not the one continued stage: no stage's prompt is chosen.
raise _Unresolved(MULTISTAGE_UNATTRIBUTED_REASON)
earlier = node_id
continue
# ``multistage-differs`` is reserved for when stage texts are
# compared (PR3); until then no stage's prompt is chosen.
raise _Unresolved(MULTISTAGE_UNATTRIBUTED_REASON)
if self.stage_shaped(name, inputs):
raise self.unsupported("Unsupported latent ancestry", node_id)
self.enqueue(queue, queued, inputs, depth + 1)
return earlier
def anchor(self, node_id: str) -> str:
seen = set()
-15
View File
@@ -40,21 +40,6 @@
Number.isSafeInteger(current.tag_revision) && current.tag_revision >= 0) {
this.current = { tags: current.tags.slice(), tag_revision: current.tag_revision };
}
// A busy answer names the running job that holds the slot; an
// invalid-source answer names the source it refused. Only
// well-formed values are kept.
this.running = null;
const running = current && current.running;
if (this.code === "busy" && running && typeof running.id === "string" &&
/^[A-Za-z0-9_-]{1,64}$/.test(running.id) && typeof running.title === "string" &&
running.title.length > 0 && running.title.length <= 200) {
this.running = { id: running.id, title: running.title };
}
this.source = null;
if (this.code === "invalid-source" && current && typeof current.source === "string" &&
current.source.length > 0 && current.source.length <= 4096) {
this.source = current.source;
}
}
}
+1 -1
View File
@@ -462,7 +462,7 @@
"ControlNetApplyAdvanced", "ControlNetApplySD3", "InpaintModelConditioning", "WanImageToVideo", "ConditioningZeroOut",
"CLIPTextEncode", "CLIPTextEncodeSDXL", "CLIPTextEncodeFlux", "CLIPTextEncodeSD3", "TextEncodeQwenImageEdit",
"TextEncodeQwenImageEditPlus", "TextEncodeQwenImage21", "KikoTextEncode", "StringConstant", "StringConstantMultiline",
"JoinStringMulti", "PromptSearchList", "TriggerWord Toggle (LoraManager)", "PreviewImage", "PreviewAny", "SaveImageWebsocket",
"JoinStringMulti", "PromptSearchList", "PreviewImage", "PreviewAny", "SaveImageWebsocket",
"Image Comparer (rgthree)", "DetailerForEach", "UltimateSDUpscale", "UltimateSDUpscaleNoUpscale",
"ControlNetInpaintingAliMamaApply", "LTXVConditioning", "FluxKontextMultiReferenceLatentMethod", "Text to Conditioning",
"TextInput", "DisplayText", "ClownsharKSampler_Beta", "KSampler (Efficient)", "ttN KSampler_v2",
+1 -11
View File
@@ -4,9 +4,6 @@
*
* Settings are per browser (localStorage key "gallerySettings"). Anything
* stored can be stale or hand-edited, so every value is checked before use.
* The workspace shares this object ("One display configuration", #249 System
* S3): it adds workspaceLayout and workspaceDensity, and every key either page
* does not know is kept.
*
* Loadable in the browser (window.GallerySettings) and in Node for tests.
*/
@@ -14,9 +11,6 @@
"use strict";
const VIEW_MODES = ["grid", "list"];
// The workspace Images defaults; the legacy gallery's defaultViewMode has no masonry.
const WORKSPACE_LAYOUTS = ["grid", "list", "masonry"];
const WORKSPACE_DENSITIES = ["comfortable", "large", "compact"];
const LIMIT_OPTIONS = [50, 100, 200, 500];
const MIN_COLUMNS = 2;
const MAX_COLUMNS = 12;
@@ -40,8 +34,6 @@
videoAutoplay: false,
videoMute: true,
videoLoop: true,
workspaceLayout: "grid",
workspaceDensity: "comfortable",
});
function toInt(value) {
@@ -80,8 +72,6 @@
videoAutoplay: bool(merged.videoAutoplay, DEFAULT_SETTINGS.videoAutoplay),
videoMute: bool(merged.videoMute, DEFAULT_SETTINGS.videoMute),
videoLoop: bool(merged.videoLoop, DEFAULT_SETTINGS.videoLoop),
workspaceLayout: WORKSPACE_LAYOUTS.includes(merged.workspaceLayout) ? merged.workspaceLayout : DEFAULT_SETTINGS.workspaceLayout,
workspaceDensity: WORKSPACE_DENSITIES.includes(merged.workspaceDensity) ? merged.workspaceDensity : DEFAULT_SETTINGS.workspaceDensity,
};
}
@@ -91,7 +81,7 @@
return `repeat(${n}, minmax(0, 1fr))`;
}
const api = { DEFAULT_SETTINGS, LIMIT_OPTIONS, VIEW_MODES, WORKSPACE_LAYOUTS, WORKSPACE_DENSITIES, normalizeGallerySettings, gridColumnsStyle };
const api = { DEFAULT_SETTINGS, LIMIT_OPTIONS, VIEW_MODES, normalizeGallerySettings, gridColumnsStyle };
if (typeof module !== "undefined" && module.exports) module.exports = api;
else root.GallerySettings = api;
})(typeof window !== "undefined" ? window : globalThis);
+2 -3
View File
@@ -1768,10 +1768,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
}
saveSettings() {
// The workspace shares this object (its layout and density, #249 System S3), so a Save starts from what is
// stored and keeps every key this dialog does not edit.
const settings = {
...this.getSettings(),
lazyLoading: document.getElementById('lazyLoadingToggle').checked,
imageQuality: document.getElementById('imageQualitySelect').value,
defaultViewMode: document.getElementById('defaultViewModeSelect').value,
@@ -1785,6 +1782,8 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
infiniteScroll: document.getElementById('infiniteScrollToggle').checked,
debugMode: document.getElementById('debugModeToggle').checked,
apiTimeout: parseInt(document.getElementById('apiTimeoutInput').value),
thumbnailsGenerated: this.getSettings().thumbnailsGenerated, // Preserve this
thumbnailPromptDismissed: this.getSettings().thumbnailPromptDismissed,
checkThumbnailsAtStartup: document.getElementById('checkThumbnailsAtStartup').checked,
// Video settings
videoAutoplay: document.getElementById('videoAutoplayToggle').checked,
+3 -17
View File
@@ -124,8 +124,8 @@
}
},
"composites": {
"ConditioningCombine": { "strategy": "combine", "inputs": ["conditioning_1", "conditioning_2"], "additive": true },
"ConditioningConcat": { "strategy": "combine", "inputs": ["conditioning_to", "conditioning_from"], "additive": true },
"ConditioningCombine": { "strategy": "combine", "inputs": ["conditioning_1", "conditioning_2"] },
"ConditioningConcat": { "strategy": "combine", "inputs": ["conditioning_to", "conditioning_from"] },
"ConditioningAverage": { "strategy": "combine", "inputs": ["conditioning_to", "conditioning_from"] }
},
"encoders": [
@@ -175,7 +175,7 @@
}
}
],
"runtime": ["PromptSearchList", "TriggerWord Toggle (LoraManager)"],
"runtime": ["PromptSearchList"],
"savers": {
"SaveImage": {
"strategy": "comfy-counter",
@@ -215,20 +215,6 @@
}
},
"non_file_outputs": ["PreviewImage", "PreviewAny", "SaveImageWebsocket", "Image Comparer (rgthree)"],
"lineage": [
{ "class": "VAEDecode", "slot": 0, "strategy": "continue", "input": "samples" },
{ "class": "VAEDecodeTiled", "slot": 0, "strategy": "continue", "input": "samples" },
{ "class": "VAEEncode", "slot": 0, "strategy": "continue", "input": "pixels" },
{ "class": "VAEEncodeTiled", "slot": 0, "strategy": "continue", "input": "pixels" },
{ "class": "ImageUpscaleWithModel", "slot": 0, "strategy": "continue", "input": "image" },
{ "class": "ImageScale", "slot": 0, "strategy": "continue", "input": "image" },
{ "class": "ImageScaleBy", "slot": 0, "strategy": "continue", "input": "image" },
{ "class": "ImageScaleToTotalPixels", "slot": 0, "strategy": "continue", "input": "image" },
{ "class": "LatentUpscale", "slot": 0, "strategy": "continue", "input": "samples" },
{ "class": "LatentUpscaleBy", "slot": 0, "strategy": "continue", "input": "samples" },
{ "class": "Image Filter", "slot": 0, "strategy": "continue", "input": "images" },
{ "class": "Image Filter", "slot": 1, "strategy": "continue", "input": "latents" }
],
"stage_inputs": {
"shaped": [
"latent_image",
+8 -22
View File
@@ -17,8 +17,6 @@ export const REPORT_DONE = 'Job report downloaded.';
export const REPORT_FAILED = 'The report could not be downloaded. Try again.';
const JOBS_REPORT_BASE = '/prompt_manager/jobs';
export const START_FAILED = 'The scan could not start. Try again.';
// A per-source scan whose sources changed since they were read (S4): the server's 409 says so.
const SOURCES_CHANGED_CODE = 'sources-changed';
const FALLBACK_NAME = 'prompts_backup.db';
const REPORT_FALLBACK_NAME = 'prompt-manager-report.json';
@@ -83,37 +81,25 @@ export function busyToast(running) {
/**
* Start the scan job over `files` ('new': new and unresolved files, or 'all') and open its view; a refused or failed
* start says why and stays on the page. `roots` (System › Sources, S4) limits it to those effective source indexes, and
* `paths` names the public path read for each: when the sources changed since (409 `sources-changed`) nothing starts,
* the toast says the server's reason and `onStale` (which every caller with `roots` passes) reads them again.
* start says why and stays on the page.
*/
export async function startMetadataScan({ store, scope, files = 'new', roots = null, paths = null, onStale,
notify = showWorkspaceToast, navigate = navigateTo }) {
export async function startMetadataScan({ store, scope, files = 'new', notify = showWorkspaceToast, navigate = navigateTo }) {
try {
const result = await store.start('scan', roots ? { scope: files, roots, paths } : { scope: files });
const result = await store.start('scan', { scope: files });
if (scope.signal.aborted) return;
if (result.busy !== undefined) {
notify(busyToast(result.busy));
return;
}
navigate(`#/activity/${result.job.id}`);
} catch (error) {
if (scope.signal.aborted) return;
if (error?.code === SOURCES_CHANGED_CODE) {
notify(error.message);
onStale();
return;
}
notify(START_FAILED);
} catch {
if (!scope.signal.aborted) notify(START_FAILED);
}
}
/**
* Open "Review metadata scan" for `sources` output folders; with `roots` and `paths` (one source's index and public
* path, System › Sources) the scan reads only those, and `onStale` runs when they changed since they were read.
*/
export function openMetadataScan({ scope, store, sources, roots = null, paths = null, onStale, trigger = null }) {
/** Open "Review metadata scan" for `sources` output folders. */
export function openMetadataScan({ scope, store, sources, trigger = null }) {
openScanReviewDialog({ scope, sources, trigger,
onBackup: () => downloadDatabaseBackup(),
onStart: ({ scope: files }) => startMetadataScan({ store, scope, files, roots, paths, onStale }) });
onStart: ({ scope: files }) => startMetadataScan({ store, scope, files }) });
}
+3 -5
View File
@@ -148,20 +148,18 @@ function itemFor(job, scope) {
chip.textContent = state;
detail.textContent = jobDetail(next);
const running = next.state === 'running';
// parentNode, not isConnected: on the first render the item is not in the page yet, so a job that had already
// ended when Activity opened kept its Cancel job (and a running one's progress moved after its actions).
if (running) {
progress.value = next.progress;
progress.textContent = `${next.progress}%`;
progress.setAttribute('aria-label', `${next.title} progress`);
if (!progress.parentNode) item.insertBefore(progress, actions.parentNode ? actions : null);
if (!progress.isConnected) item.insertBefore(progress, actions.isConnected ? actions : null);
} else {
progress.remove();
}
// Truth rule: a job that cannot be cancelled (a held legacy-page job) shows no Cancel job.
if (running && next.cancellable) {
if (!actions.parentNode) item.append(actions);
} else if (actions.parentNode) {
if (!actions.isConnected) item.append(actions);
} else if (actions.isConnected) {
// A focused Cancel job that goes away hands its focus to the item's title link, never to the page body.
if (actions.contains(document.activeElement)) link.focus();
actions.remove();
+4 -21
View File
@@ -6,7 +6,6 @@ import { createWorkspaceIcon } from './icons.mjs';
import { navigateTo } from './state.mjs';
import { showWorkspaceToast } from './toast.mjs';
import { openCopyFallback } from './workspace-dialogs.mjs';
import { displayPreferences } from './preferences.mjs';
const RAW_KEYS = ['prompt', 'workflow', 'parameters', 'comfyassets_prompt_pairs'];
const RAW_LIMIT = 'Raw metadata exceeds the response limit. Download the original file to inspect it externally.';
@@ -156,10 +155,7 @@ function generationFacts(record, listClass, noteClass) {
return [facts, explanation];
}
// D21: the panel badge names the strongest resolved prompt source; any other source shows no badge.
// A prompt that left out a run-time PromptManager part (D125) or a later stage's added conditioning (D126) is still
// inferred from the workflow.
const EVIDENCE_BADGES = [['execution', 'Execution recorded'], ['graph-inferred', 'Workflow inferred'], ['runtime-parts-omitted', 'Workflow inferred'],
['added-conditioning', 'Workflow inferred'], ['runtime-parts-omitted-and-added-conditioning', 'Workflow inferred'], ['embedded-parameters', 'Embedded parameters']];
const EVIDENCE_BADGES = [['execution', 'Execution recorded'], ['graph-inferred', 'Workflow inferred'], ['embedded-parameters', 'Embedded parameters']];
function evidenceBadge(prompts) {
const sources = new Set(Object.values(prompts).filter(role => role?.status === 'resolved').map(role => role.source));
return EVIDENCE_BADGES.find(([source]) => sources.has(source))?.[1] ?? null;
@@ -200,10 +196,7 @@ function prefixedChip(prefix, text) {
* `variant: 'full'` (the viewer's `#viewer-details`, imageDetails(i, true)) leaves out the cover and adds "File &
* provenance" and "Workflow & raw metadata" with the downloads. `inline: true` means `panel`; neither means `full`.
*/
/** D124: the inspector's line while "Load metadata on selection" is off. */
export const DEFERRED_TEXT = 'Metadata loads when you ask for it.';
export function mountFileInspector(container, {api, sender, parentScope, variant, inline = false, promptContext = '', onTagFilter, onRetry, onRefresh, onLoad, onInteraction = () => {}, copyFallback = openCopyFallback}) {
export function mountFileInspector(container, {api, sender, parentScope, variant, inline = false, promptContext = '', onTagFilter, onRetry, onRefresh, onInteraction = () => {}, copyFallback = openCopyFallback}) {
if (parentScope.signal.aborted) return {setState() {}, dispose() {}};
const scope = childScope(parentScope);
let contentScope;
@@ -242,13 +235,6 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
function render(state) {
const inner = el('div', undefined, full ? undefined : 'detail-inner');
if (state.loading || state.error) {container.append(inner); statusView(state, inner); return;}
// D124: "Load metadata on selection" is off, so the file's details wait for an explicit request.
if (state.deferred) {
container.append(inner);
const status = el('p', DEFERRED_TEXT, 'small'); status.setAttribute('role', 'status'); inner.append(status);
if (onLoad) {const load = createButton('Load metadata'); contentScope.listen(load, 'click', onLoad); inner.append(load);}
return;
}
const record = state.record;
if (!record) return;
const {file, library} = record;
@@ -340,10 +326,8 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
section.append(el('p', reported ? 'Current file at the reported location · identity unverified' : local ? 'Selected local file · read in this browser' :
'Current file at this source; historical bytes are not verified.', 'small'));
const path = el('dd', record.identity.path ?? 'Not available');
// System › Appearance "Show file paths" off: the Path row is left out; nothing else changes.
const showPath = !local && displayPreferences()?.showFilePaths !== false;
const facts = file && definitionList([
['Source', file.source_label], ...(showPath ? [['Path', path]] : []), ['Size', formatBytes(file.size)],
['Source', file.source_label], ...(local ? [] : [['Path', path]]), ['Size', formatBytes(file.size)],
['Dimensions', file.width && file.height ? `${file.width} × ${file.height}` : null],
['Format', file.format || `${extensionOf(record)} extension`], ['Media type', file.media_type],
['Modified', file.modified_time !== null ? new Date(file.modified_time * 1000).toLocaleString() : null],
@@ -351,9 +335,8 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
if (facts) {
// D38: "Show in folder" follows the Path value.
const target = api ? revealTarget(record.identity) : null;
if (target) path.append(' ', revealButton(target));
section.append(facts);
if (target && showPath) path.append(' ', revealButton(target));
else if (target) section.append(revealButton(target));
} else section.append(el('p', 'Current file observations are unavailable for this inspection response.', 'small'));
// The concept's "Prompt evidence: execution recorded" line, in the D21 badge's words; no badge, no line.
if (badge) section.append(el('p', `Prompt evidence: ${badge.toLowerCase()}`, 'small'));
-11
View File
@@ -1,17 +1,6 @@
import { noun } from './plural.mjs';
/** Identity is the source and relative path; content hashes may be shared by files. */
/**
* 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).
*/
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' };
}
export function imageKey(image) {
return `${image.root_index}:${image.path}`;
}
+5 -13
View File
@@ -1,5 +1,5 @@
import { createScope, createButton } from '../shared/toolkit/core.js';
import { createImageStream, withItem, imageKey, countLine, captionDate, scopeLabel, activeRefinements, cardTitle, cardSubtitle, mediaRatio, initialImagesView } from './images-state.mjs';
import { createImageStream, withItem, imageKey, countLine, captionDate, scopeLabel, activeRefinements, cardTitle, cardSubtitle, mediaRatio } from './images-state.mjs';
import { layoutImages, visibleImages, gridMetrics, stacksCardInfo } from './image-layout.mjs';
import { mountFileInspector } from './file-inspector.mjs';
import { createInspectionController } from './inspection-state.mjs';
@@ -15,7 +15,6 @@ import { openImportDialog, openRefineDialog } from './workspace-dialogs.mjs';
import { openMetadataScan } from './activity-scan.mjs';
import { showWorkspaceToast } from './toast.mjs';
import { imagesFilters, imagesViewState } from './saved-views.mjs';
import { displayPreferences } from './preferences.mjs';
import { createTagButton, onImageTagsSaved } from './image-tagging.mjs';
import { followImageTagSaves, openCardTagging } from './images-tagging.mjs';
import { favoriteBusy, paintStar, receiveFavorites, setFavoriteVisitTarget, toggleFavorite } from './images-favorites.mjs';
@@ -112,9 +111,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
const { resolveUrl } = window.ApiClient;
const sender = createPromptSender({scope});
let detailInspector;
// System › Appearance: the display defaults this visit starts from (preferences.mjs); null without gallery-settings.js.
const preferences = displayPreferences();
const inspection = createInspectionController({api, autoLoad:preferences?.autoLoadMetadata !== false, cache:preferences?.cacheMetadata !== false});
const inspection = createInspectionController({api});
scope.cleanup(inspection.subscribe(next => detailInspector?.setState(next)));
scope.cleanup(() => inspection.dispose());
scope.cleanup(() => detailInspector?.dispose());
@@ -122,12 +119,9 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
const prior = embedded || saved || favorites ? null : lastVisit;
// A stale visit keeps its filters, layout and size, and lists again instead of restoring (M6).
const visit = prior?.stale ? null : prior;
// 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});
let density = start.density;
let density = prior?.density || 'comfortable';
let metrics = {...INITIAL_METRICS};
let layout = start.layout, selection = visit?.selection || null, frame = 0, loadFrame = 0, restoreFrame = 0, observer;
let layout = prior?.layout || 'grid', selection = visit?.selection || null, frame = 0, loadFrame = 0, restoreFrame = 0, observer;
let state, geometry = { positions: [], columns: 1, height: 0, maxHeight: 0 }, galleryWidth = 0, optionsRequest, categoryRequest, recovering = false, rescanning = false;
// Discovered sources (each with its own folders) and every folder, for "Refine this view" and the scope chip.
let roots = [], folders = [];
@@ -293,7 +287,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
selection = imageKey(image);
for (const video of details.querySelectorAll('video')) releaseVideo(video);
detailInspector = mountFileInspector(details, {sender, parentScope:scope, variant:'panel',
onTagFilter:embedded ? undefined : filterByTag, onRetry:() => inspection.retry(), onLoad:() => inspection.load(), onRefresh:refreshInspectionCollection});
onTagFilter:embedded ? undefined : filterByTag, onRetry:() => inspection.retry(), onRefresh:refreshInspectionCollection});
detailInspector.setState(inspection.state);
inspection.select(image);
for (const [key, card] of rendered) markSelected(card, key === selection);
@@ -337,8 +331,6 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
const text = el('div');
const title = el('h3', cardTitle(image)); title.title = image.path;
text.append(title, el('p', cardSubtitle(image)));
// D97: "Show image details on cards" off hides the title and file line; Info and Tag stay.
if (preferences?.showImageInfo === false) text.hidden = true;
const actions = el('div', undefined, 'image-card-actions');
const inspectButton = createButton('', 'icon-btn');
inspectButton.append(createWorkspaceIcon('info'));
+4 -18
View File
@@ -344,16 +344,9 @@ export function createInspectionLoader(api, onChange, { extractFacts, adapter =
};
}
const emptyState = () => ({ loading: false, error: false, deferred: false, record: null, prompts: undefined, message: '', refreshRequired: false, code: null, recovery: null });
const emptyState = () => ({ loading: false, error: false, record: null, prompts: undefined, message: '', refreshRequired: false, code: null, recovery: null });
/**
* One selection's inspection. #249 System S3 (Appearance): `autoLoad: false` ("Load metadata on selection" off) makes a
* selection publish `deferred` instead of reading, and `load()` reads it once on request; `cache: false` ("Cache
* metadata in this browser" off) re-reads the selected file when it is selected again, where the default reuses the one
* current record. A read in flight is shared either way, and a file whose source changed is never re-read by selecting
* it again (as `retry()` and `refresh()` refuse).
*/
export function createInspectionController({ api, extractFacts, adapter = rootedFileAdapter, autoLoad = true, cache = true }) {
export function createInspectionController({ api, extractFacts, adapter = rootedFileAdapter }) {
let state = Object.freeze(emptyState()), current = null, pending = null, disposed = false;
let attempted, hasAttempt = false;
const listeners = new Set();
@@ -376,19 +369,13 @@ export function createInspectionController({ api, extractFacts, adapter = rooted
try {
adapter.validate(image);
const scope = adapter.scope(image), key = adapter.key(image), path = adapter.inspectionPath(image);
if (current?.key === key && current.scope === scope && current.path === path && (cache || state.loading || state.deferred || state.refreshRequired)) return pending;
if (current?.key === key && current.scope === scope && current.path === path) return pending;
selected = { key, scope, path };
} catch {
// The loader publishes validation errors after invalidating the previous source.
}
loader.dispose(); loader = makeLoader(); current = selected;
attempted = object(image) ? { ...image, ...(adapter !== rootedFileAdapter ? {occurrence:{...image.occurrence}, reference:{...image.reference}} : {}) } : image; hasAttempt = true;
if (!autoLoad && selected) { pending = null; publish({ deferred: true }); return undefined; }
return read(attempted);
},
/** Read a deferred selection (the inspector's "Load metadata"); a read already made or running is reused. */
load() {
if (disposed || !hasAttempt || !state.deferred) return pending ?? undefined;
return read(attempted);
},
retry() {
@@ -397,8 +384,7 @@ export function createInspectionController({ api, extractFacts, adapter = rooted
},
/** Re-read the selected file keeping the shown record until the answer (a retry when none is shown). */
refresh() {
// A deferred selection loads only through `load()` (the user's "Load metadata").
if (disposed || !hasAttempt || state.refreshRequired || state.deferred) return;
if (disposed || !hasAttempt || state.refreshRequired) return;
return read(attempted, { quiet: Boolean(state.record) && !state.loading && !state.error });
},
clear() {
+15 -174
View File
@@ -1,30 +1,17 @@
// Candidate selection for local-resolver.mjs (#249 local Inspect, PR L3 Task 9): ports of
// utils/prompt_pair_resolver.py `_GraphResolver` `stage`, `earlier_stages`, `added_conditioning`, `composite_of`,
// `upstream_stages`, `final_stages`, `continued_stages`, `file_stages`, `non_file_outputs`, `producers_of`,
// `consumers`, `reaches_output`, `lineage_predecessor`, `latent_stages` and `part`, plus `_left_out`, `_outcome_key`,
// `_recipe_key`, `_combined_positive` and `_shared_pair` (#270). Pass-through lineage: a sampler whose latent continues
// another's through `lineage` nodes is compared with it. Each function takes the resolver whose budgets and caches it
// spends, so the order of work, and with it every limit, is the server's. No DOM.
// utils/prompt_pair_resolver.py `_GraphResolver` `stage`, `upstream_stages`, `final_stages`, `file_stages`,
// `non_file_outputs`, `producers_of`, `consumers`, `reaches_output`, plus `_outcome_key`, `_recipe_key` and
// `_shared_pair` (#270). Each function takes the resolver whose budgets and caches it spends, so the order of
// work, and with it every limit, is the server's. No DOM.
import { isDict, pyGet, pyStr } from './py-json.mjs';
import {
ADDED_CONDITIONING_REASON, ADDED_CONDITIONING_SOURCE, DETAILER, IDENTICAL_BRANCHES_REASON,
MULTISTAGE_UNATTRIBUTED_REASON, RUNTIME_AND_ADDED_SOURCE, RUNTIME_PARTS_SOURCE, Unresolved, isLink, rerouteValue, result,
slotKey,
} from './local-resolver-support.mjs';
import { DETAILER, IDENTICAL_BRANCHES_REASON, Unresolved, isLink, result } from './local-resolver-support.mjs';
const roleText = side => side !== null && typeof side === 'object' ? side.text : null;
const storageText = side => side !== null && typeof side === 'object' ? side.storage_text : null;
/** `_left_out`: the omitting PromptManagers and the added-by composite, so a partial text never equals a complete one. */
function leftOut(side) {
const provenance = side !== null && typeof side === 'object' ? side.provenance : null;
if (provenance === null || typeof provenance !== 'object') return [];
return [provenance.omitted_parts ?? [], provenance.added_by ?? null];
}
/** `_outcome_key`: what a reader sees of a result, the state, the two role texts and what each leaves out. */
const outcomeParts = item => [item.state, roleText(item.positive), roleText(item.negative), leftOut(item.positive), leftOut(item.negative)];
export const outcomeKey = item => JSON.stringify(outcomeParts(item));
/** `_outcome_key`: what a reader sees of a result, the state and the two role texts. */
export const outcomeKey = item => JSON.stringify([item.state, roleText(item.positive), roleText(item.negative)]);
/** `_recipe_key`: the outcome plus each side's stored recipe. */
const recipeKey = item => JSON.stringify([...outcomeParts(item), storageText(item.positive), storageText(item.negative)]);
const recipeKey = item => JSON.stringify([item.state, roleText(item.positive), roleText(item.negative), storageText(item.positive), storageText(item.negative)]);
/** `_shared_pair`: the one pair every sampler branch resolved to, or null (#270). */
export function sharedPair(candidates) {
@@ -34,29 +21,26 @@ export function sharedPair(candidates) {
return result('resolved', { positive: first.positive, negative: first.negative, reason: IDENTICAL_BRANCHES_REASON, candidates });
}
/** `_combined_positive`: whether a stage failed only because its positive combines texts. */
const combinedPositive = item => item.reason_code === 'composite' && item.positive === null;
/** `stage`: one sampler's result, compared with the stages it continues (detailer image, latent lineage). */
/** `stage`: one sampler's result; a detailer is compared with its upstream stages. */
export function stage(resolver, samplerId, later) {
if (later.size >= resolver.maxDepth) return result('unresolved', { samplerId, reason: 'Resolution depth limit reached', reasonCode: 'depth-limit' });
const own = resolver.sides(samplerId);
const combined = own.state === 'unresolved' && combinedPositive(own);
if (own.state === 'unresolved' && !combined) return own;
if (own.state === 'unresolved') return own;
const [name, inputs] = resolver.node(samplerId);
const entry = resolver.semantics.samplers.get(name);
if (entry === undefined || entry.strategy !== DETAILER) return own;
let upstream;
try {
upstream = earlierStages(resolver, samplerId);
upstream = upstreamStages(resolver, samplerId, pyGet(inputs, entry.image_stage));
if (upstream.some(nodeId => later.has(nodeId))) throw new Unresolved('Cyclic sampler stage ancestry', 'malformed');
} catch (error) {
if (!(error instanceof Unresolved)) throw error;
if (combined) return own;
return result('unresolved', { samplerId, reason: error.message, reasonCode: error.code, node: error.node });
}
if (!upstream.length) return own;
const deeper = new Set([...later, samplerId]);
const stages = upstream.map(nodeId => stage(resolver, nodeId, deeper));
const blocked = stages.find(item => item.state === 'unresolved');
if (combined) return blocked !== undefined ? own : addedConditioning(resolver, samplerId, stages) ?? own;
// A stage that cannot be read cannot be compared: never pick one.
if (blocked !== undefined) return result('unresolved', { samplerId, reason: blocked.reason, reasonCode: blocked.reason_code, node: blocked.node });
const flat = [];
@@ -65,67 +49,6 @@ export function stage(resolver, samplerId, later) {
return result('ambiguous', { samplerId, reason: 'Sampler stages have different prompts', candidates: flat, reasonCode: 'multistage-differs' });
}
/** `earlier_stages`: a detailer's upstream samplers, or the one sampler a latent continues (cached walk). */
function earlierStages(resolver, samplerId) {
const [name, inputs] = resolver.node(samplerId);
const entry = resolver.semantics.samplers.get(name);
if (entry.strategy === DETAILER) return upstreamStages(resolver, samplerId, pyGet(inputs, entry.image_stage));
const walked = resolver.latentWalks.get(samplerId);
if (walked === undefined || walked[2] === null) return [];
return [walked[2]];
}
/** `added_conditioning`: the earlier stages' shared pair when this stage's positive only adds to it. */
function addedConditioning(resolver, samplerId, stages) {
const flat = [];
for (const item of stages) flat.push(...(item.state === 'ambiguous' ? item.candidates : [item]));
const base = flat[0];
if (base.state !== 'resolved' || new Set(flat.map(outcomeKey)).size !== 1) return null;
const [name, inputs] = resolver.node(samplerId);
const entry = resolver.semantics.samplers.get(name);
if (entry.strategy === 'custom-advanced') return null;
const conditioning = entry.conditioning;
let negative, composite;
try {
const found = compositeOf(resolver, pyGet(inputs, conditioning.positive));
if (found === null) return null;
let parts, depth;
[parts, depth, composite] = found;
const texts = parts.map(part => resolver.text(part, 'conditioning', depth)[0]);
if (!texts.includes(base.positive.text)) return null;
if (!inputs.has(conditioning.negative)) return null;
negative = resolver.side(inputs.get(conditioning.negative), samplerId);
} catch (error) {
if (!(error instanceof Unresolved)) throw error;
return null;
}
if (negative.text !== base.negative.text) return null;
// D126: the positive does not include what the later stage added.
const provenance = { ...base.positive.provenance };
provenance.text_source = provenance.text_source === RUNTIME_PARTS_SOURCE ? RUNTIME_AND_ADDED_SOURCE : ADDED_CONDITIONING_SOURCE;
provenance.added_by = composite;
const positive = { ...base.positive, provenance };
return result('resolved', { positive, negative: base.negative, samplerId: base.sampler_id, reason: ADDED_CONDITIONING_REASON });
}
/** `composite_of`: [part values, depth, node ID] of the `additive` composite a conditioning input reaches, or null. */
function compositeOf(resolver, value) {
let depth = 0;
while (isLink(value) && depth < resolver.maxDepth) {
const nodeId = String(value[0]), slot = value[1];
const [name, inputs] = resolver.node(nodeId);
depth += 1;
if (name === 'Reroute' && slot === 0) { value = rerouteValue(inputs); continue; }
const forward = resolver.semantics.conditioning.get(slotKey(name, slot));
if (forward !== undefined && forward.strategy === 'passthrough') { value = pyGet(inputs, forward.input); continue; }
// `text` follows a composite only at output slot 0, so a failed `composite` positive reached it there.
const composite = resolver.semantics.composites.get(name);
if (composite === undefined || composite.additive !== true) return null;
return [composite.inputs.map(part => pyGet(inputs, part)), depth, nodeId];
}
return null;
}
/** `upstream_stages`: samplers reached from a detailer's image input, breadth first; each node draws a visit. */
function upstreamStages(resolver, origin, value) {
if (!isLink(value)) throw new Unresolved('Expected an image link', 'malformed');
@@ -149,8 +72,7 @@ const strategyOf = (resolver, nodeId) => resolver.semantics.samplers.get(resolve
export function finalStages(resolver, samplerIds) {
samplerIds = fileStages(resolver, samplerIds);
const detailers = samplerIds.filter(nodeId => strategyOf(resolver, nodeId) === DETAILER);
const continued = samplerIds.length > 1 ? continuedStages(resolver, samplerIds) : [];
if (!detailers.length && !continued.length) return samplerIds;
if (!detailers.length) return samplerIds;
let reverse;
try { reverse = consumers(resolver); } catch (error) {
if (!(error instanceof Unresolved)) throw error;
@@ -169,37 +91,10 @@ export function finalStages(resolver, samplerIds) {
if (!(error instanceof Unresolved)) throw error;
}
}
for (const nodeId of continued) {
resolver.budget.reset(resolver.perCandidateVisits);
try {
if (!reachesOutput(resolver, nodeId, reverse)) absorbed.add(nodeId);
} catch (error) {
if (!(error instanceof Unresolved)) throw error;
}
}
const kept = samplerIds.filter(nodeId => !absorbed.has(nodeId));
return kept.length ? kept : samplerIds;
}
/** `continued_stages`: lineage predecessors of the non-detailer candidates, in order, once. */
function continuedStages(resolver, samplerIds) {
const found = [];
for (const nodeId of samplerIds) {
const entry = resolver.semantics.samplers.get(resolver.graph.get(nodeId).get('class_type'));
if (entry.strategy === DETAILER || entry.latent === undefined || entry.latent === null) continue;
let earlier;
try {
const [, inputs] = resolver.node(nodeId);
earlier = lineagePredecessor(resolver, pyGet(inputs, entry.latent));
} catch (error) {
if (!(error instanceof Unresolved)) throw error;
continue;
}
if (earlier !== null && earlier !== nodeId && !found.includes(earlier)) found.push(earlier);
}
return found;
}
/** `file_stages`: stages not proven preview-only; all of them when every one is. */
function fileStages(resolver, samplerIds) {
if (samplerIds.length < 2) return samplerIds;
@@ -277,57 +172,3 @@ function reachesOutput(resolver, nodeId, reverse) {
}
return false;
}
/** `lineage_predecessor`: the sampler whose output a latent input continues through `lineage` nodes, or null. */
function lineagePredecessor(resolver, value) {
const seen = new Set();
while (isLink(value) && seen.size < resolver.maxDepth) {
const nodeId = String(value[0]), slot = value[1];
if (seen.has(nodeId)) return null;
seen.add(nodeId);
const [name, inputs] = resolver.node(nodeId);
if (resolver.stageClasses.has(name)) return slot === 0 ? nodeId : null;
if (name === 'Reroute' && slot === 0) { value = rerouteValue(inputs); continue; }
const entry = resolver.semantics.lineage.get(slotKey(name, slot));
if (entry === undefined) return null;
value = pyGet(inputs, entry.input);
}
return null;
}
/** `latent_stages`: the one earlier sampler a latent continues, null for a single stage; else unattributed. */
export function latentStages(resolver, origin, value) {
if (!isLink(value)) throw new Unresolved('Unknown latent ancestry', 'malformed');
const start = String(value[0]), queue = [[start, 0]], queued = new Set([start]);
let earlier = null, lineage = null, traced = false;
for (let head = 0; head < queue.length; head++) {
const [nodeId, depth] = queue[head];
if (nodeId === origin) throw new Unresolved('Cyclic latent ancestry', 'malformed');
if (depth >= resolver.maxDepth) throw new Unresolved('Resolution depth limit reached', 'depth-limit');
resolver.budget.spend();
const [name, inputs] = resolver.node(nodeId);
if (resolver.stageClasses.has(name)) {
if (!traced) { lineage = lineagePredecessor(resolver, value); traced = true; }
if (nodeId !== lineage) throw new Unresolved(MULTISTAGE_UNATTRIBUTED_REASON);
earlier = nodeId;
continue;
}
if (resolver.stageShaped(name, inputs)) throw resolver.unsupported('Unsupported latent ancestry', nodeId);
resolver.enqueue(queue, queued, inputs, depth + 1);
}
return earlier;
}
/** `part`: a PromptManager prepend or append, traced, or left out when a listed run-time node feeds it directly. */
export function promptPart(resolver, value, depth, omitted) {
try {
const [text, path] = resolver.text(value, 'string', depth + 1);
return [text, path];
} catch (error) {
// A listed run-time node raises at itself, so a deeper run-time source never matches here.
if (!(error instanceof Unresolved) || error.code !== 'runtime-value' || !isLink(value) ||
!resolver.semantics.runtime.has(resolver.node(String(value[0]))[0])) throw error;
omitted.push(String(value[0]));
return ['', []];
}
}
+1 -21
View File
@@ -31,11 +31,6 @@ export const RUNTIME_REASON = 'Text is decided at run time';
export const EMPTY_POSITIVE_REASON = 'Positive prompt is empty';
export const IDENTICAL_BRANCHES_REASON = 'Every sampler branch uses the same prompt pair';
export const MULTISTAGE_UNATTRIBUTED_REASON = 'Multistage sampler ancestry is ambiguous';
export const ADDED_CONDITIONING_REASON = 'A later stage adds conditioning to the base prompt';
export const RUNTIME_PARTS_SOURCE = 'runtime-parts-omitted';
export const RUNTIME_PARTS_REASON = 'Text added at run time, such as trigger words, is not included';
export const ADDED_CONDITIONING_SOURCE = 'added-conditioning';
export const RUNTIME_AND_ADDED_SOURCE = 'runtime-parts-omitted-and-added-conditioning';
export const MALFORMED_EVIDENCE_REASON = 'Prompt evidence is malformed';
export const UNREADABLE_METADATA_REASON = 'Media metadata could not be read';
export const SEMANTICS_UNAVAILABLE_REASON = 'Inspection node semantics are unavailable';
@@ -124,11 +119,6 @@ export function isLink(value) {
const [source, slot] = value;
return (typeof source === 'string' || isInt(source)) && isInt(slot) && (typeof slot === 'bigint' ? slot >= 0n : slot >= 0);
}
/** `_reroute_value`: a Reroute's one input (`value`, `input` or `in`), else null. */
export function rerouteValue(inputs) {
const keys = ['value', 'input', 'in'].filter(key => inputs.has(key));
return keys.length === 1 ? inputs.get(keys[0]) : null;
}
export const nodeRef = (nodeId, classType) => ({ id: pySlice(nodeId, CLASS_NAME_LENGTH), class_type: typeof classType === 'string' ? pySlice(classType, CLASS_NAME_LENGTH) : null });
export function result(state, { positive = null, negative = null, samplerId = null, reason = '', candidates = [], reasonCode = null, node = null } = {}) {
@@ -235,20 +225,10 @@ export function compileSemantics(doc) {
const composites = byClass('composites'), savers = byClass('savers');
const conditioning = bySlot('conditioning'), encoders = bySlot('encoders'), strings = bySlot('strings');
const runtime = bySet('runtime'), nonFileOutputs = bySet('non_file_outputs');
// `lineage` describes an output's image or latent, not its prompt: its classes are not claimed.
if (!Array.isArray(doc.lineage)) fail('category lineage must be a list');
const lineage = new Map();
for (const row of doc.lineage) {
if (!plain(row) || typeof row.class !== 'string' || !Number.isInteger(row.slot) || row.slot < 0 || row.slot > 255) fail('lineage row');
if (row.strategy !== 'continue' || typeof row.input !== 'string' || !row.input) fail('lineage row');
const key = slotKey(row.class, row.slot);
if (lineage.has(key)) fail('lineage: duplicate output key');
lineage.set(key, { strategy: row.strategy, input: row.input });
}
const stageInputs = doc.stage_inputs;
if (!plain(stageInputs) || !Array.isArray(stageInputs.shaped) || !Array.isArray(stageInputs.with_model)) fail('stage_inputs');
return Object.freeze({
samplers, guiders, selectors, composites, savers, conditioning, encoders, strings, runtime, nonFileOutputs, lineage,
samplers, guiders, selectors, composites, savers, conditioning, encoders, strings, runtime, nonFileOutputs,
stageInputs: { shaped: [...stageInputs.shaped], withModel: [...stageInputs.with_model] }, allClasses: new Set(owners.keys()),
});
}
+24 -21
View File
@@ -23,6 +23,7 @@ import {
MAX_JOIN_INPUTS,
MAX_SUBGRAPH_DEPTH,
MAX_WORKFLOW_NODES,
MULTISTAGE_UNATTRIBUTED_REASON,
NO_SAMPLER_NAMES,
OVERWRITTEN_PROMPT_REASON,
PROFILE,
@@ -38,14 +39,12 @@ import {
explained,
isLink,
nodeRef,
RUNTIME_PARTS_REASON,
RUNTIME_PARTS_SOURCE,
readPairExecution,
result,
sideOf,
slotKey,
} from './local-resolver-support.mjs';
import { finalStages, latentStages, outcomeKey, promptPart, sharedPair, stage } from './local-resolver-stages.mjs';
import { finalStages, outcomeKey, sharedPair, stage } from './local-resolver-stages.mjs';
import { parameters } from './local-parameters.mjs';
export { SemanticsError, compileSemantics, readPairExecution, outcomeMessage, isLink } from './local-resolver-support.mjs';
@@ -70,7 +69,6 @@ class GraphResolver {
this.active = new Set();
this.metadata = metadata ?? new Map();
this.textSources = new Map();
this.omitting = new Set();
this.choices = new Map();
this.tainted = false;
this.latentWalks = new Map();
@@ -169,13 +167,11 @@ class GraphResolver {
[text, path, storageText] = this.text(pyGet(inputs, adapted.inputName), 'string', depth + 1);
if (adapted.compose || adapted.storageInput) storageText = text;
if (adapted.compose) {
const omitted = [];
const [before, beforePath] = promptPart(this, pyGet(inputs, 'prepend_text', ''), depth, omitted);
const [after, afterPath] = promptPart(this, pyGet(inputs, 'append_text', ''), depth, omitted);
const [before, beforePath] = this.text(pyGet(inputs, 'prepend_text', ''), 'string', depth + 1);
const [after, afterPath] = this.text(pyGet(inputs, 'append_text', ''), 'string', depth + 1);
this.scan(pyLen(before) + pyLen(after) + beforePath.length + path.length + afterPath.length);
text = this.built(this.bounded([pyStrip(before), text, pyStrip(after)].filter(part => part).join(' ')));
path = [...beforePath, ...path, ...afterPath];
if (omitted.length) this.omitting.add(slotKey(nodeId, slot));
}
}
} else if ((name === 'StringConcatenate' || name === 'Text Concatenate') && slot === 0 && kind === 'string') {
@@ -451,10 +447,6 @@ class GraphResolver {
text_source: this.textSources.get(slotKey(origin.node_id, origin.output_slot)) ?? 'graph-inferred',
};
if (guiderId !== null) provenance.guider_id = guiderId;
const omitted = [];
for (const step of path) if (this.omitting.has(slotKey(step.node_id, step.output_slot)) && !omitted.includes(step.node_id)) omitted.push(step.node_id);
// Through encoders, concatenations and chained PromptManagers (D125).
if (omitted.length) { provenance.text_source = RUNTIME_PARTS_SOURCE; provenance.omitted_parts = omitted; }
return { text, storage_text: storageText, provenance };
}
@@ -535,8 +527,7 @@ class GraphResolver {
return result('absent', { positive, samplerId, reason: absentReason });
}
negative = this.side(inputs.get(negativeName), samplerId, guiderId, depth);
const omitted = [positive, negative].some(side => side.provenance.text_source === RUNTIME_PARTS_SOURCE);
return result('resolved', { positive, negative, samplerId, reason: omitted ? RUNTIME_PARTS_REASON : '' });
return result('resolved', { positive, negative, samplerId });
} catch (error) {
if (!(error instanceof Unresolved)) throw error;
return result('unresolved', { positive, negative, samplerId, reason: error.message, reasonCode: error.code, node: error.node });
@@ -552,13 +543,11 @@ class GraphResolver {
}
}
/** `latent_checked`: `latent_stages` once per sampler; returns the continued earlier stage, or null. */
latentChecked(samplerId, value) {
let error, earlier = null;
let error;
if (this.latentWalks.has(samplerId)) {
const [walked, cost, found] = this.latentWalks.get(samplerId);
const [walked, cost] = this.latentWalks.get(samplerId);
error = walked;
earlier = found;
if (!this.latentCharged.has(samplerId)) {
this.budget.charge(cost);
this.latentCharged.add(samplerId);
@@ -566,17 +555,16 @@ class GraphResolver {
} else {
const before = this.budget.allowance;
try {
earlier = latentStages(this, samplerId, value);
this.latentStages(samplerId, value);
error = null;
} catch (raised) {
if (!(raised instanceof Unresolved) || raised.code === 'work-limit') throw raised;
error = raised;
}
this.latentWalks.set(samplerId, [error, before - this.budget.allowance, earlier]);
this.latentWalks.set(samplerId, [error, before - this.budget.allowance]);
this.latentCharged.add(samplerId);
}
if (error !== null) throw new Unresolved(error.message, error.code, error.node === null ? null : { ...error.node });
return earlier;
}
enqueue(queue, queued, inputs, depth) {
@@ -594,6 +582,21 @@ class GraphResolver {
if (shaped.some(key => isLink(pyGet(inputs, key)))) return true;
return isLink(pyGet(inputs, 'model')) && withModel.some(key => isLink(pyGet(inputs, key)));
}
latentStages(origin, value) {
if (!isLink(value)) throw new Unresolved('Unknown latent ancestry', 'malformed');
const start = String(value[0]), queue = [[start, 0]], queued = new Set([start]);
for (let head = 0; head < queue.length; head++) {
const [nodeId, depth] = queue[head];
if (nodeId === origin) throw new Unresolved('Cyclic latent ancestry', 'malformed');
if (depth >= this.maxDepth) throw new Unresolved('Resolution depth limit reached', 'depth-limit');
this.budget.spend();
const [name, inputs] = this.node(nodeId);
if (this.stageClasses.has(name)) throw new Unresolved(MULTISTAGE_UNATTRIBUTED_REASON);
if (this.stageShaped(name, inputs)) throw this.unsupported('Unsupported latent ancestry', nodeId);
this.enqueue(queue, queued, inputs, depth + 1);
}
}
}
+14 -42
View File
@@ -2,14 +2,14 @@ import {createScope, createButton} from '../shared/toolkit/core.js';
import {createViewerSession} from './viewer-state.mjs';
import {rootedFileAdapter} from './inspection-state.mjs';
import {mountFileInspector} from './file-inspector.mjs';
import {displayPreferences, readPreferences} from './preferences.mjs';
let viewerId = 0;
// Upper bound on waiting for a closed Viewer's in-flight image view before destroying it.
const PENDING_VIEW_DESTROY_MS = 10000;
/** The video defaults, read through the shared display defaults (preferences.mjs, #249 System S3). */
export function readViewerPreferences(getStorage = () => window.localStorage, normalize = globalThis.GallerySettings.normalizeGallerySettings) {
const {videoAutoplay,videoMute,videoLoop} = readPreferences(getStorage,normalize).values;
let stored;
try { stored = JSON.parse(getStorage().getItem('gallerySettings')); } catch { stored = null; }
const {videoAutoplay,videoMute,videoLoop} = normalize(stored);
return {videoAutoplay,videoMute,videoLoop};
}
function element(tag, className, text) {
@@ -35,7 +35,7 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
if (scope.signal.aborted) return;
active?.close(false);
const {items,selectedKey,trigger = document.activeElement} = options;
let mounted = false, initial, entry, currentKey, viewer = null, withoutViewer = false, mediaScope = null, closed = false;
let mounted = false, initial, entry, currentKey, viewer = null, mediaScope = null, closed = false;
let originalDownload = null, inspectionState = inspectionController.state;
const session = createViewerSession({adapter,items,initialKey:selectedKey,onChange:(state,selected) => {
if (!mounted) { initial = [state,selected]; return; }
@@ -74,31 +74,15 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
local.listen(download,'click',event => {session.stop();if (download.getAttribute('aria-disabled') === 'true') event.preventDefault();});
controls.append(download);
const next = button('Next media','Next →',() => session.navigate(1));
// D97: "Collapse viewer details by default" opens the viewer with its details hidden behind one toggle.
const collapsed = displayPreferences()?.sidebarCollapsedByDefault === true;
let detailsToggle = null;
if (collapsed) {
detailsToggle = createButton('Show details');detailsToggle.setAttribute('aria-expanded','false');
detailsToggle.setAttribute('aria-controls','viewer-details');controls.append(detailsToggle);
}
const position = element('p','media-position muted');position.id = `media-viewer-position-${++viewerId}`;
position.setAttribute('role','status');
const reason = element('p','media-reason muted');reason.id = `media-viewer-reason-${viewerId}`;
const slideshowReason = element('p','media-slideshow-reason muted');slideshowReason.id = `media-viewer-slideshow-${viewerId}`;
controls.append(position,reason,slideshowReason);left.append(stage,controls);
const details = element('aside');details.id = 'viewer-details';details.setAttribute('aria-label','Image metadata');
if (detailsToggle) {
details.hidden = true;
local.listen(detailsToggle,'click',() => {
details.hidden = !details.hidden;
detailsToggle.textContent = details.hidden ? 'Show details' : 'Hide details';
detailsToggle.setAttribute('aria-expanded',String(!details.hidden));
});
}
body.append(left,details);dialog.append(header,body);container.append(dialog);
const localSender = {open(roles,trigger) {session.stop();sender.open(roles,trigger);}};
const inspector = mountFileInspector(details,{api,sender:localSender,parentScope:local,variant:'full',onInteraction:() => session.stop(),onRetry:() => inspectionController.retry(),
onLoad:() => inspectionController.load?.(),
onRefresh:onRefresh ? () => { closeViewer();onRefresh(); } : undefined});
function closeViewer(restore = true) {
if (closed) return;
@@ -131,9 +115,7 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
mediaScope?.dispose();mediaScope = createScope();viewer = null;
for (const child of [...stage.children]) if (child !== failure) child.remove();
failure.hidden = true;
const mediaLocal = mediaScope, key = session.state.key, ViewerJs = window.Viewer;
// Transforms follow the Viewer.js this media was built with, which builds once the image has loaded.
withoutViewer = !ViewerJs;
const mediaLocal = mediaScope, key = session.state.key;
const current = () => !local.signal.aborted && !mediaLocal.signal.aborted && session.state.key === key;
// An adapter's own media URL (a session-owned blob:) is used exactly, so a retry reuses it; a server
// original gets a fresh cache-buster on every build.
@@ -160,23 +142,15 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
},{once:true});
} else {
node.alt = selected.filename;node.decoding = 'async';
node.hidden = !!ViewerJs;
node.hidden = !!window.Viewer;
// With Viewer.js the src is released after the viewer (see release below).
if (!ViewerJs) {
if (!window.Viewer) {
mediaLocal.listen(node,'load',() => {if (current()) session.ready(key);});
mediaLocal.cleanup(() => node.removeAttribute('src'));
}
}
node.src = src;stage.append(node);
if (!selected.is_video && ViewerJs) {
// Viewer.js is built on an image that has loaded. Its own cancel removes its image listeners only
// while `image.complete` is false, so an image left with its load event still queued would build a
// viewer into the released image after destroy() (#293). Until then no viewer exists to cancel.
let mountedViewer = false;
mediaLocal.listen(node,'load',() => { if (current()) { mountedViewer = true;mountViewer(); } },{once:true});
mediaLocal.cleanup(() => { if (!mountedViewer) node.removeAttribute('src'); });
}
function mountViewer() {
if (!selected.is_video && window.Viewer) {
let fitFrame = 0, fitWidth, fitHeight, viewSettled = false, released = false, fallback = 0;
const release = () => {
if (released) return;
@@ -192,7 +166,7 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
fitWidth = width;fitHeight = height;instance.resize();
});
};
const instance = new ViewerJs(node,{inline:true,toolbar:false,navbar:false,title:false,button:false,
const instance = new window.Viewer(node,{inline:true,toolbar:false,navbar:false,title:false,button:false,
keyboard:false,fullscreen:false,transition:false,viewed() {
viewSettled = true;
if (mediaLocal.signal.aborted) { release(); return; }
@@ -202,12 +176,10 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
const observer = new ResizeObserver(scheduleFit);observer.observe(stage);
viewer = instance;mediaLocal.cleanup(() => {
observer.disconnect();cancelAnimationFrame(fitFrame);
// Before Viewer.js has built (its zero-delay build is pending), destroy() cancels that build;
// deferring it would let the build run into the image the stage has just dropped.
// Once built, inline Viewer.js keeps viewing a still-loading image after destroy(), and that
// view rebuilds its list from images that still have a src; keep both until the view settles,
// fails or times out.
if (viewSettled || !instance.ready) { release(); return; }
// Inline Viewer.js keeps viewing a still-loading image after destroy(), and that view
// rebuilds its list from images that still have a src; keep both until the view
// settles, fails or times out.
if (viewSettled) { release(); return; }
node.addEventListener('error',release,{once:true});
fallback = setTimeout(release,PENDING_VIEW_DESTROY_MS);
});
@@ -216,7 +188,7 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
}
function updateControls(state) {
const unavailable = inspectionState.refreshRequired ? inspectionState.message : state.phase === 'error' ? 'Media could not be loaded. Retry this file.' : entry.is_video ?
'Image transforms are unavailable for video.' : withoutViewer ?
'Image transforms are unavailable for video.' : !viewer ?
'Image transforms are unavailable because Viewer.js could not load.' : !state.mediaReady ? 'Image transforms are available when the image is ready.' : '';
reason.textContent = unavailable;reason.hidden = !unavailable;
for (const node of transforms) {
-112
View File
@@ -1,112 +0,0 @@
/**
* Browser display defaults (#249 System S3 Task 7). The workspace keeps them in the legacy gallery's own
* `gallerySettings` object (Q3, "One display configuration"), so the gallery, the workspace and the viewer read one
* configuration: the new `workspaceLayout` and `workspaceDensity` beside the legacy keys they share (metadata, paths,
* card details, viewer details, image quality and the video defaults). The legacy paging keys (`defaultLimit`,
* `gridColumns`, `infiniteScroll`, …) belong to the legacy gallery and are never read here (README continuous-browsing
* ruling).
*
* - `readPreferences(getStorage, normalize)` → `{values, persisted}`: the workspace keys, normalized by `gallery-settings.js`;
* `persisted` is false when this browser refused its storage.
* - `writePreferences(dirty, getStorage, {baseline})` → `{persisted, conflicts}`: re-reads the stored object and writes
* back only the dirty keys, every other key byte-for-byte as stored. A write the browser refused (denied storage, a
* full quota) leaves the stored bytes as they were and keeps the change for this tab only, so every reader in this
* tab still sees it; the next write storage accepts stores those changes too. `conflicts` lists the dirty keys another page changed since `baseline` was read; the explicit
* save still wins.
* - `subscribePreferences(win, onChange, getStorage)` → unsubscribe: `onChange(readPreferences())` after another page
* changed the settings (a `storage` event).
* - `reconcilePreferences({baseline, drafts, next})` → `{conflicts}`: which drafts such a change overtook.
*
* Every storage access is in try/catch: a private window, blocked site data or a malformed value never breaks a page.
*/
export const PREFERENCES_KEY = 'gallerySettings';
export const WORKSPACE_PREFERENCE_KEYS = Object.freeze(['workspaceLayout', 'workspaceDensity', 'autoLoadMetadata', 'cacheMetadata',
'imageQuality', 'videoAutoplay', 'videoMute', 'videoLoop', 'showFilePaths', 'showImageInfo', 'sidebarCollapsedByDefault']);
// The changes this tab could not store, overlaid on every read until storage accepts them.
const tabOnly = new Map();
const defaultStorage = () => globalThis.localStorage;
const normalizer = () => globalThis.GallerySettings.normalizeGallerySettings;
const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value);
/** Forget what this tab kept without storage (tests, and nothing else). */
export function resetTabPreferences() {
tabOnly.clear();
}
const pick = settings => Object.fromEntries(WORKSPACE_PREFERENCE_KEYS.map(key => [key, settings[key]]));
/** The gallery's defaults for the workspace keys. */
export function preferenceDefaults() {
return pick(normalizer()(null));
}
/** The stored object (`{}` when absent or malformed), or null when storage is unavailable. */
function storedObject(storage) {
let text;
try { text = storage.getItem(PREFERENCES_KEY); } catch { return null; }
try {
const parsed = text ? JSON.parse(text) : null;
return isObject(parsed) ? parsed : {};
} catch {
return {};
}
}
function storageOf(getStorage) {
try { return getStorage() ?? null; } catch { return null; }
}
/** `normalize` is gallery-settings.js's `normalizeGallerySettings` (the page's `window.GallerySettings` by default). */
export function readPreferences(getStorage = defaultStorage, normalize = normalizer()) {
const storage = storageOf(getStorage);
const stored = storage ? storedObject(storage) : null;
const values = pick(normalize({ ...(stored ?? {}), ...Object.fromEntries(tabOnly) }));
return { values, persisted: stored !== null };
}
/** A change is only a workspace key with a value the gallery's normalizer keeps as given. */
function validate(dirty) {
if (!isObject(dirty)) throw new TypeError('Preferences need an object of changes');
for (const [key, value] of Object.entries(dirty)) {
if (!WORKSPACE_PREFERENCE_KEYS.includes(key)) throw new TypeError(`Not a workspace display preference: ${key}`);
if (normalizer()({ [key]: value })[key] !== value) throw new TypeError(`Invalid value for ${key}`);
}
}
export function writePreferences(dirty, getStorage = defaultStorage, { baseline = null } = {}) {
validate(dirty);
const keep = () => { for (const [key, value] of Object.entries(dirty)) tabOnly.set(key, value); };
const storage = storageOf(getStorage);
const stored = storage ? storedObject(storage) : null;
if (stored === null) { keep(); return { persisted: false, conflicts: [] }; }
const current = pick(normalizer()(stored));
const conflicts = baseline ? Object.keys(dirty).filter(key => Object.hasOwn(baseline, key) && current[key] !== baseline[key]) : [];
try {
// The changes this tab could not store before are stored with this one, so "Saved" is true for all of them.
storage.setItem(PREFERENCES_KEY, JSON.stringify({ ...stored, ...Object.fromEntries(tabOnly), ...dirty }));
} catch {
keep();
return { persisted: false, conflicts };
}
tabOnly.clear();
return { persisted: true, conflicts };
}
/** The current values for a display consumer, or null on a page without gallery-settings.js (a unit test). */
export function displayPreferences(getStorage = defaultStorage) {
try { return readPreferences(getStorage).values; } catch { return null; }
}
export function subscribePreferences(win, onChange, getStorage = defaultStorage) {
const listener = event => {
if (event.key !== null && event.key !== PREFERENCES_KEY) return;
if (event.storageArea && event.storageArea !== storageOf(getStorage)) return;
onChange(readPreferences(getStorage));
};
win.addEventListener('storage', listener);
return () => win.removeEventListener('storage', listener);
}
export function reconcilePreferences({ baseline, drafts, next }) {
return { conflicts: Object.keys(drafts).filter(key => next[key] !== baseline[key] && next[key] !== drafts[key]) };
}
-11
View File
@@ -15,16 +15,6 @@ const statuses = {
ambiguous: 'Multiple possible prompts. Source could not be determined.',
unresolved: 'Prompt could not be resolved.',
};
// D125 (maintainer option A): a PromptManager prepend or append that a run-time node decided (LoRA trigger words) is
// not in the file, so the shown text leaves it out and says so. D126: the conditioning a later stage combined with
// the base prompt is not in the shown text either. Each source shows its muted line under the text.
export const RUNTIME_PARTS_NOTE = 'Text added at run time, such as trigger words, is not included.';
export const ADDED_CONDITIONING_NOTE = 'Conditioning added at a later stage is not included.';
const SOURCE_NOTES = Object.freeze({
'runtime-parts-omitted': [RUNTIME_PARTS_NOTE],
'added-conditioning': [ADDED_CONDITIONING_NOTE],
'runtime-parts-omitted-and-added-conditioning': [RUNTIME_PARTS_NOTE, ADDED_CONDITIONING_NOTE],
});
const ROLE_LABELS = Object.freeze({positive: '+ Positive prompt', negative: '\u2212 Negative prompt'});
function el(tag, text, className) {
const node = document.createElement(tag);
@@ -124,7 +114,6 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role
// Production keeps a confirmed prompt keyboard-reachable for reading and selecting long text.
text.tabIndex = 0;
block.append(text);
for (const note of Object.hasOwn(SOURCE_NOTES, record?.source) ? SOURCE_NOTES[record.source] : []) block.append(el('p', note, 'small muted'));
} else block.append(el('p', typeof record?.message === 'string' && record.message ? record.message : statuses[record?.status] || statuses.unresolved, 'prompt-text muted'));
container.append(block);
}
-143
View File
@@ -1,143 +0,0 @@
/**
* System › Appearance (#249 System S3 Task 8): the concept's `renderSystem` "appearance" body (app.js 676–678), one
* `section.surface` "Browsing & display" of `.setting-row`s whose values are the drafts of the one Save row.
*
* - The browser rows are the display defaults this browser keeps in the shared `gallerySettings` object
* (preferences.mjs, Q3): Default layout and Thumbnail size (`workspaceLayout`, `workspaceDensity`), the metadata,
* quality (Optimized→`medium`, Original→`high`, Fast→`low`), video and path rows. Images, the inspector and the
* viewer read them on their next visit; a toolbar change still applies to the current view only.
* - "Open from ComfyUI" (New tab→`newtab`, Popup window→`popup`) and "Node search auto-hide (seconds)" (a whole number
* 0–300) are the server's `webui_display_mode` and `result_timeout`, saved with the settings revision (#220).
* - "Continuous browsing" is the concept's fixed row: there is no paging or column control (README ruling).
* - D97: added rows for the legacy gallery settings the concept leaves out: "Show image details on cards"
* (`showImageInfo`), "Collapse viewer details by default" (`sidebarCollapsedByDefault`) and the operation "Reset
* display defaults", which drafts the gallery's defaults for these browser rows and touches no other key.
*
* Controls follow the store: a value read later (the host settings, another tab's change) repaints them. A host row
* whose value could not be read stays disabled and says so ("Server settings could not be read.").
*/
import { settingRow, operationRow } from './system-controls.mjs';
import { preferenceDefaults } from './preferences.mjs';
export const RESULT_TIMEOUT_MAX = 300;
/** A host row's line while the server settings could not be read (its control stays disabled). */
export const HOST_UNAVAILABLE = 'Server settings could not be read.';
const BOOLEAN = Object.freeze({ type: 'toggle' });
/**
* The concept's rows in its order, then the D97 rows: `[id, label, description, domain, key, control]`. A select's
* options are `[stored value, concept label]`.
*/
export const APPEARANCE_ROWS = Object.freeze([
['default-view', 'Default layout', 'Choose your starting image layout.', 'browser', 'workspaceLayout',
{ type: 'select', options: [['grid', 'Grid'], ['list', 'List'], ['masonry', 'Masonry']] }],
['density', 'Thumbnail size', 'Readable previews by default; no fixed column-count setting.', 'browser', 'workspaceDensity',
{ type: 'select', options: [['comfortable', 'Comfortable'], ['large', 'Large'], ['compact', 'Compact']] }],
'continuous',
['metadata-auto', 'Load metadata on selection', 'Use the same inspector in the gallery and image viewer.', 'browser', 'autoLoadMetadata', BOOLEAN],
['metadata-cache', 'Cache metadata in this browser', 'Reuse fetched metadata until it changes or you clear this cache.', 'browser', 'cacheMetadata', BOOLEAN],
['image-quality', 'Image loading quality', 'Preserve original / optimized / fast image-loading preferences.', 'browser', 'imageQuality',
{ type: 'select', options: [['medium', 'Optimized'], ['high', 'Original'], ['low', 'Fast']] }],
['video-auto', 'Autoplay videos', 'Videos stay within the same gallery and use native playback controls.', 'browser', 'videoAutoplay', BOOLEAN],
['video-muted', 'Mute videos by default', 'Keep playback quiet while browsing.', 'browser', 'videoMute', BOOLEAN],
['video-loop', 'Loop videos', 'Repeat a selected clip until you navigate away.', 'browser', 'videoLoop', BOOLEAN],
['paths', 'Show file paths', 'Paths appear under File & provenance, not across every card.', 'browser', 'showFilePaths', BOOLEAN],
['open-mode', 'Open from ComfyUI', 'Keep the node pack’s popup / new-tab preference.', 'host', 'webui_display_mode',
{ type: 'select', options: [['newtab', 'New tab'], ['popup', 'Popup window']] }],
['timeout', 'Node search auto-hide (seconds)', 'Node search behavior only. Use 0 to keep results visible.', 'host', 'result_timeout',
{ type: 'number' }],
// D97: the legacy gallery's card and viewer settings.
['card-details', 'Show image details on cards', 'Show each card’s title and file details under the preview.', 'browser', 'showImageInfo', BOOLEAN],
['viewer-details', 'Collapse viewer details by default', 'Open the image viewer with its details panel hidden.', 'browser',
'sidebarCollapsedByDefault', BOOLEAN],
]);
/** The browser keys "Reset display defaults" restores (D97). */
export const DISPLAY_KEYS = Object.freeze(APPEARANCE_ROWS.filter(row => Array.isArray(row) && row[3] === 'browser').map(row => row[4]));
/** A whole number of seconds from 0 to 300 (the server's `validate_result_timeout`), else null. */
export function resultTimeout(text) {
const value = String(text).trim();
if (!/^\d+$/.test(value)) return null;
const seconds = Number(value);
return seconds <= RESULT_TIMEOUT_MAX ? seconds : null;
}
function el(tag, text, className) {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
if (className) node.className = className;
return node;
}
/** The concept's fixed "Continuous browsing" row: `div > h3 + p` and the "Infinite scroll" chip. */
function continuousRow() {
const row = el('div', undefined, 'setting-row');
const text = el('div');
text.append(el('h3', 'Continuous browsing'),
el('p', 'Images load automatically as you scroll in Grid, List and Masonry. Batch sizing is handled internally.'));
row.append(text, el('span', 'Infinite scroll', 'chip blue'));
return row;
}
/** The row's control (`data-setting`), wherever the Toolkit switch nests it. */
function controlOf(node, id) {
if (node.dataset?.setting === id) return node;
for (const child of node.children ?? []) {
const found = controlOf(child, id);
if (found) return found;
}
return null;
}
/** Show a stored value in its control; a value not read yet leaves the control disabled. */
function paint(input, control, value) {
input.disabled = value === undefined;
if (value === undefined) return;
if (control.type === 'toggle') input.checked = value === true;
else if (document.activeElement !== input || control.type === 'select') input.value = String(value);
}
/** Mount Appearance in `container` (System's `.settings-layout`); returns the section's nodes. */
export function mountAppearance(container, { scope, drafts }) {
const surface = el('section', undefined, 'surface');
surface.append(el('h2', 'Browsing & display'), el('p', 'One set of defaults; toolbar changes apply to the current view.'));
const controls = [];
for (const row of APPEARANCE_ROWS) {
if (row === 'continuous') { surface.append(continuousRow()); continue; }
const [id, label, description, domain, key, control] = row;
const onChange = value => {
if (key === 'result_timeout') {
const seconds = resultTimeout(input.value);
if (seconds === null) { drafts.reject(domain, key); return; }
value = seconds;
}
drafts.set(domain, key, value);
};
const node = settingRow({ id, label, description, control, scope, onChange });
surface.append(node);
const input = controlOf(node, id);
input.dataset.key = key;
controls.push({ input, control, domain, key, text: node.children[0], note: null });
}
surface.append(operationRow({ title: 'Reset display defaults',
description: 'Restore this browser’s display defaults, then save. Other settings are kept.', label: 'Reset', scope,
onClick: () => {
const defaults = preferenceDefaults();
for (const key of DISPLAY_KEYS) drafts.set('browser', key, defaults[key]);
} }));
const repaint = () => {
const unavailable = drafts.hostState?.() === 'failed';
for (const row of controls) {
const value = drafts.get(row.domain, row.key);
paint(row.input, row.control, value);
if (row.domain !== 'host') continue;
if (unavailable && value === undefined) {
if (!row.note) { row.note = el('p', HOST_UNAVAILABLE, 'small'); row.text.append(row.note); }
} else if (row.note) { row.note.remove(); row.note = null; }
}
};
scope.cleanup(drafts.subscribe(repaint));
repaint();
return [surface];
}
-395
View File
@@ -1,395 +0,0 @@
/**
* System › Sources & scans (#249 System S4 Task 11): the concept's `renderSystem` "sources" body (app.js 679–681).
*
* - The optional `.notice` "Bring older images into your prompt history" shows until this browser dismissed the 3.2.0
* update notice (`pm_update_notice_dismissed`, the legacy admin page's own key). Later dismisses it without scanning;
* Review scan dismisses it, as the legacy page's Scan does, and reviews every source.
* - "Image sources" lists the server's sources (GET /settings, S4 Task 9) in `#source-list`, one `.operation` each:
* its label, "<path> · watching | watching until restart | manual scan | restart pending" and "Scan changes"
* (watched) or "Scan folder", which opens the shared "Review metadata scan" (D55) for that one source ("1 output
* folder") and starts the session job over it (`roots` with its public path, S4 Task 10). The job opens in Activity;
* the toast only says it started. When the sources changed since they were read, nothing starts: the toast says so
* and the list is read again.
* - "+ Add source" asks the server whether a folder can be a source (`validate-source`, which writes nothing), then adds
* 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.
* - "Thumbnail generation → Review options" opens the existing gallery's thumbnail tool until its review dialog (S5),
* the D94 hand-off for the one capability not native yet.
*
* D98: per-source Remove; an added source reads "not saved yet" until Save; the line under the list says when
* ComfyUI's output folder is used instead; "restart pending" for a source the running monitor does not watch yet; the
* "Refresh files" operation (`/gallery/rescan`) closes the surface. D127: the add dialog's field is "Folder path",
* starts empty and says the server's reason in place; its action is "Add", and Enter adds too.
* D128: "watching until restart" for a source the running monitor still watches after monitoring was switched off;
* "same folder as another source" for a configured source the effective list already holds under another spelling; a
* save refused for one source (`invalid-source`) marks that row and the status says the server's reason.
*/
import { createButton } from '../shared/toolkit/core.js';
import { closeWorkspaceDialog, field, openContentDialog } from './workspace-dialogs.mjs';
import { openMetadataScan } from './activity-scan.mjs';
import { settingRow, operationRow } from './system-controls.mjs';
import { HOST_UNAVAILABLE } from './system-appearance.mjs';
import { showWorkspaceToast } from './toast.mjs';
import { noun } from './plural.mjs';
const SETTINGS_URL = '/prompt_manager/settings';
const VALIDATE_URL = '/prompt_manager/settings/validate-source';
const RESCAN_URL = '/prompt_manager/gallery/rescan';
const GALLERY_URL = '/prompt_manager/gallery';
const ROOTS_KEY = 'gallery_root_paths';
export const NOTICE_KEY = 'pm_update_notice_dismissed';
export const NOTICE_VERSION = '3.2.0';
export const WATCH_COPY = Object.freeze({ watching: 'watching', stopping: 'watching until restart', manual: 'manual scan',
restart_pending: 'restart pending' });
// The states whose observer runs now: their scan only has to catch up on changes.
const WATCHED = new Set(['watching', 'stopping']);
const NOT_SAVED = 'not saved yet';
export const DUPLICATE_SOURCE = 'same folder as another source';
/** D98: the line under the source list when it does not say everything by itself. */
export const NOTES = Object.freeze({
removedAll: 'No source is configured. After saving, ComfyUI’s output folder is used.',
autoDetected: 'No source is configured; ComfyUI’s output folder is used.',
fallback: 'No configured source is available; ComfyUI’s output folder is used.',
none: 'No source is available.',
});
export const LOADING_TEXT = 'Loading sources…';
export const LOAD_FAILED_TEXT = 'Sources could not be loaded. Your saved data has not changed.';
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.';
// Labels of the sources added in this page session (validate-source answers them), for the rows not saved yet.
const addedLabels = new Map();
const defaultStorage = () => window.localStorage;
/** Whether this browser dismissed the 3.2.0 notice; storage that cannot be read shows it. */
export function noticeDismissed(getStorage = defaultStorage) {
try {
return getStorage().getItem(NOTICE_KEY) === NOTICE_VERSION;
} catch {
return false;
}
}
/** Dismiss the notice for this browser; false when storage refused (it stays dismissed for this view). */
export function dismissNotice(getStorage = defaultStorage) {
try {
getStorage().setItem(NOTICE_KEY, NOTICE_VERSION);
return true;
} catch {
return false;
}
}
const sameRoots = (a, b) => Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((path, index) => path === b[index]);
/** The roots to draft: the saved list itself when they are equal, so the draft is clean again. */
export function draftRoots(next, saved) {
return sameRoots(next, saved) ? saved : [...next];
}
const folderName = path => path.split('/').filter(Boolean).at(-1) ?? path;
function effectiveRow(source, remove) {
const watched = WATCHED.has(source.watch);
return { key: remove ?? `auto:${source.root_index}`, title: source.label,
description: `${source.path} · ${WATCH_COPY[source.watch] ?? WATCH_COPY.manual}`,
scan: { label: watched ? 'Scan changes' : 'Scan folder', root: source.root_index, path: source.path }, remove };
}
/**
* The source list for `sources` (GET /settings' `sources`), the saved `gallery_root_paths` and the drafted ones
* (`current`): `{rows: [{key, title, description, scan: {label, root, path} | null, remove: path | null}], note}`.
* A valid configured source missing from the effective list is another spelling of a folder already listed.
*/
export function sourceRows({ sources, saved, current, labels = addedLabels }) {
const configured = sources.configured ?? [];
const effective = sources.effective ?? [];
const savedRoots = saved ?? configured.map(source => source.path);
const roots = current ?? savedRoots;
const dirty = !sameRoots(roots, savedRoots);
const rows = [];
for (const path of new Set(roots)) {
const entry = configured.find(source => source.path === path);
const live = entry?.valid ? effective.find(source => source.path === path) : undefined;
if (live) rows.push(effectiveRow(live, path));
else if (entry) {
const why = entry.valid ? DUPLICATE_SOURCE : entry.reason ?? WATCH_COPY.manual;
rows.push({ key: path, title: entry.label, description: `${path} · ${why}`, scan: null, remove: path });
} else rows.push({ key: path, title: labels.get(path) ?? folderName(path), description: `${path} · ${NOT_SAVED}`, scan: null, remove: path });
}
if (dirty) return { rows, note: roots.length ? null : NOTES.removedAll };
if (sources.auto_detected) {
rows.push(...effective.map(source => effectiveRow(source, null)));
return { rows, note: configured.length ? NOTES.fallback : NOTES.autoDetected };
}
return { rows, note: rows.length ? null : NOTES.none };
}
/** The Refresh files toast for the rescan's `{total, roots}`. */
export function refreshedText(result) {
const { total, roots } = result ?? {};
if (![total, roots].every(value => Number.isSafeInteger(value) && value >= 0)) return 'Files refreshed.';
return `Files refreshed. ${total.toLocaleString('en-US')} ${noun(total, 'file')} in ${roots.toLocaleString('en-US')} output ${noun(roots, 'folder')}.`;
}
function el(tag, text, className) {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
if (className) node.className = className;
return node;
}
/** The concept's `.notice` with Later and Review scan. */
function noticeNode({ scope, onLater, onReview }) {
const notice = el('div', undefined, 'notice');
const text = el('div');
text.append(el('h2', 'Bring older images into your prompt history'),
el('p', 'The schema upgrades automatically. An optional metadata scan links existing positives and negatives without regenerating all thumbnails.'));
const actions = el('div', undefined, 'row-actions');
const later = createButton('Later');
const review = createButton('Review scan', 'ca-primary');
scope.listen(later, 'click', onLater);
scope.listen(review, 'click', onReview);
actions.append(later, review);
notice.append(text, actions);
return notice;
}
/**
* "Add image source" (the concept's `simpleInput`, D127): the server checks the folder; a refused one keeps the
* dialog open with the server's reason under the field. Enter in the field adds, as the Add button does.
*/
function openAddSource({ api, scope, trigger, listed, onAdd }) {
const input = el('input');
const reason = el('p', undefined, 'small');
reason.setAttribute('role', 'alert');
const cancel = createButton('Cancel');
const add = createButton('Add', 'ca-primary');
let open = true;
const refuse = text => {
reason.textContent = text;
if (!reason.isConnected) actions.before(reason);
input.setAttribute('aria-invalid', 'true');
input.focus();
};
scope.listen(input, 'input', () => {
reason.remove();
input.removeAttribute('aria-invalid');
});
scope.listen(input, 'keydown', event => {
if (event.key !== 'Enter' || event.isComposing) return;
event.preventDefault();
submit();
});
scope.listen(cancel, 'click', () => closeWorkspaceDialog());
scope.listen(add, 'click', () => submit());
async function submit() {
const path = input.value.trim();
if (!path) { input.focus(); return; }
if (add.getAttribute('aria-disabled') === 'true') return;
add.setAttribute('aria-disabled', 'true');
try {
const result = await api.post(VALIDATE_URL, { path }, { signal: scope.signal });
if (!open) return;
if (!result.valid) refuse(result.reason ?? VALIDATE_FAILED_TEXT);
else if (listed().includes(result.path)) refuse(DUPLICATE_TEXT);
else {
closeWorkspaceDialog();
onAdd(result);
}
} catch {
if (open) refuse(VALIDATE_FAILED_TEXT);
} finally {
add.removeAttribute('aria-disabled');
}
}
const actions = el('div', undefined, 'dialog-actions');
actions.append(cancel, add);
openContentDialog({ scope, title: 'Add image source', trigger, nodes: [field('Folder path', input, 'action-input'), actions],
onClose: () => { open = false; } });
input.focus();
}
/** The row's control (`data-setting`), wherever the Toolkit switch nests it. */
const controlOf = (row, id) => row.querySelector(`[data-setting="${id}"]`);
/**
* Mount Sources in `container` (System's `.settings-layout`); returns the section's nodes. `activity` is the page's
* Activity store (scans start through it), `drafts` the System drafts.
*/
export function mountSources(container, { api, activity, scope, drafts, notify = showWorkspaceToast, storage = defaultStorage,
openWindow = (url) => window.open(url, '_blank', 'noopener'), latestOnly = window.ApiClient.latestOnly,
STALE = window.ApiClient.STALE }) {
const statusRead = latestOnly({ lifetime: scope.signal });
// undefined while read, null when the read failed, else GET /settings' `sources`.
let sources;
const nodes = [];
const scanAll = trigger => openMetadataScan({ scope, store: activity, trigger, sources: sources?.effective?.length ?? 0 });
if (!noticeDismissed(storage)) {
const notice = noticeNode({ scope,
onLater: () => { dismissNotice(storage); notice.remove(); },
onReview: () => {
dismissNotice(storage);
// The pressed button leaves with the notice, so focus returns to the list's Add source.
notice.remove();
scanAll(addButton);
} });
nodes.push(notice);
}
const surface = el('section', undefined, 'surface');
const heading = el('div', undefined, 'panel-heading');
const addButton = createButton('+ Add source');
heading.append(el('h2', 'Image sources'), addButton);
const list = el('div');
list.id = 'source-list';
surface.append(heading, el('p', 'The same folders power browsing, monitoring and metadata enrichment.'), list);
const roots = () => drafts.get('host', ROOTS_KEY) ?? sources?.configured?.map(source => source.path) ?? [];
const setRoots = next => drafts.set('host', ROOTS_KEY, draftRoots(next, drafts.saved('host')[ROOTS_KEY]));
scope.listen(addButton, 'click', () => openAddSource({ api, scope, trigger: addButton, listed: roots,
onAdd: ({ path, label }) => {
addedLabels.set(path, label);
setRoots([...roots(), path]);
} }));
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 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']]
.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,
operationRow({ scope, title: 'Enrich existing metadata', description: 'Resolve both prompts, deduplicate recipes, preserve uncertain links.',
label: 'Review scan', onClick: scanAll }),
// D94: the gallery keeps thumbnail generation until its review dialog (S5).
operationRow({ scope, title: 'Thumbnail generation', description: 'Generate missing previews; rebuilding all is a separate explicit option.',
label: 'Review options', onClick: () => openWindow(window.ApiClient.resolveUrl(GALLERY_URL)) }),
// D98: the legacy gallery's Refresh, which rereads every source's file list.
operationRow({ scope, title: 'Refresh files', description: 'Read every source again to find files added or removed outside ComfyUI.',
label: 'Refresh', onClick: refresh }));
nodes.push(surface);
async function refresh(button) {
if (button.getAttribute('aria-disabled') === 'true') return;
button.setAttribute('aria-disabled', 'true');
try {
const result = await api.post(RESCAN_URL, {}, { signal: scope.signal });
if (!scope.signal.aborted) notify(refreshedText(result));
} catch {
if (!scope.signal.aborted) notify(REFRESH_FAILED_TEXT);
} finally {
button.removeAttribute('aria-disabled');
}
}
function sourceNode(row) {
const actions = [];
if (row.scan) {
const scan = createButton(row.scan.label);
scope.listen(scan, 'click', () => openMetadataScan({ scope, store: activity, trigger: scan, sources: 1,
roots: [row.scan.root], paths: [row.scan.path], onStale: reread }));
actions.push(scan);
}
if (row.remove !== null) {
// D98: a plain button; nothing is removed until Save.
const remove = createButton('Remove');
scope.listen(remove, 'click', () => setRoots(roots().filter(path => path !== row.remove)));
actions.push(remove);
}
const node = el('div', undefined, 'operation');
// D128: the source the last save was refused for; the status says why.
if (row.remove !== null && row.remove === drafts.invalidSource?.()) node.setAttribute('aria-invalid', 'true');
const text = el('div');
text.append(el('h3', row.title), el('p', row.description));
const group = el('div', undefined, 'row-actions');
group.append(...actions);
node.append(text, group);
return node;
}
function paintList() {
if (sources === undefined) {
const loading = el('p', LOADING_TEXT, 'small');
loading.setAttribute('role', 'status');
list.replaceChildren(loading);
return;
}
if (sources === null) {
const alert = el('p', LOAD_FAILED_TEXT);
alert.setAttribute('role', 'alert');
const retry = createButton('Retry');
scope.listen(retry, 'click', () => {
load();
if (drafts.hostState?.() === 'failed') drafts.reloadHost();
});
list.replaceChildren(alert, retry);
return;
}
const { rows, note } = sourceRows({ sources, saved: drafts.saved('host')[ROOTS_KEY], current: drafts.get('host', ROOTS_KEY) });
list.replaceChildren(...rows.map(sourceNode), ...(note ? [el('p', note, 'small')] : []));
}
function paintControls() {
const unavailable = drafts.hostState?.() === 'failed';
for (const row of hostRows) {
const value = drafts.get('host', row.key);
row.input.disabled = value === undefined;
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');
if (Number.isSafeInteger(cpus) && cpus >= 1) row.input.max = String(cpus);
if (value !== undefined) row.input.value = String(value);
} else if (value !== undefined) row.input.checked = value === true;
if (unavailable && value === undefined) {
if (!row.note) { row.note = el('p', HOST_UNAVAILABLE, 'small'); row.text.append(row.note); }
} else if (row.note) { row.note.remove(); row.note = null; }
}
}
// The sources changed since this page read them (a refused per-source scan): read them and the saved values again.
function reread() {
load();
drafts.reloadHost?.();
}
async function load() {
sources = undefined;
paintList();
let answer;
try {
answer = await statusRead(signal => api.get(SETTINGS_URL, { signal, cache: 'no-store' }));
} catch {
answer = null;
}
if (answer === STALE || scope.signal.aborted) return;
sources = answer?.sources ?? null;
paintList();
}
// A save (or a reread after a conflict) changed the saved sources or monitoring: read their state again. The first
// host read on mount is not a change.
const savedState = () => {
const saved = drafts.saved('host');
return saved[ROOTS_KEY] === undefined ? undefined : JSON.stringify([saved[ROOTS_KEY], saved.monitoring_enabled]);
};
let state = savedState();
scope.cleanup(drafts.subscribe(() => {
const previous = state;
state = savedState();
paintControls();
if (previous !== undefined && state !== previous) load();
else if (sources) paintList();
}));
paintControls();
load();
return nodes;
}
+8 -72
View File
@@ -15,12 +15,9 @@
* the page session (Q10a): a section left and opened again shows them, and a reload of its values keeps them.
*
* Adapters: `readBrowser()` and `readRuntime()` return the values; `readHost()` returns `{values, revision}`.
* `writeBrowser(changes)` returns `{persisted}` (false: kept for this tab only) and throws an error with `code:
* 'settings-changed'` when another page changed a drafted value (a conflict: the browser values are read again),
* `writeHost(changes, {expected_revision})` the settings response (`{success, persisted, revision}`, and the
* `restart_required_fields` the status names), `writeRuntime(changes)` anything; a write that throws failed. A host
* write refused as `invalid-source` (its error's `source` and server message) makes that message the status and
* `invalidSource()` that source, so Sources marks its row (S4).
* `writeBrowser(changes)` returns `{persisted}` (false: kept for this tab only), `writeHost(changes,
* {expected_revision})` the settings response (`{success, persisted, revision}`), `writeRuntime(changes)` anything;
* a write that throws failed.
*/
export { SYSTEM_SECTIONS, isSystemSection } from './state.mjs';
@@ -34,35 +31,21 @@ export const SETTINGS_STATUS = Object.freeze({
partial: 'Some changes were not saved. Review the highlighted settings.',
tabOnly: 'Saved for this tab only; browser storage is unavailable.',
conflict: 'Settings changed elsewhere. Reload this section to see them.',
// D128: a saved setting the running server applies only after a restart.
restart: 'Restart ComfyUI to apply:',
});
/** D128: the settings a host save can report in `restart_required_fields`, by their row label. */
export const RESTART_LABELS = Object.freeze({ monitoring_enabled: 'Watch for new outputs' });
export const DRAFT_DOMAINS = Object.freeze(['browser', 'host', 'runtime']);
const CONFLICT_CODE = 'settings-changed';
const INVALID_SOURCE_CODE = 'invalid-source';
const ADAPTERS = ['readBrowser', 'readHost', 'readRuntime', 'writeBrowser', 'writeHost', 'writeRuntime'];
/**
* The status copy of one `save()` outcome (`{browser, host, runtime}`, each clean/saved/tab-only/applied/failed/conflict).
* `restart` lists the host fields the server applies at its next start, `invalid` the source a refused host save named
* (`{source, message}`): its message is the status (D128).
*/
/** The status copy of one `save()` outcome (`{browser, host, runtime}`, each clean/saved/tab-only/applied/failed/conflict). */
export function statusText(outcome) {
const results = DRAFT_DOMAINS.map(domain => outcome?.[domain]);
if (results.includes('conflict')) return SETTINGS_STATUS.conflict;
if (results.includes('failed')) return outcome?.invalid?.message || SETTINGS_STATUS.partial;
if (results.includes('failed')) return SETTINGS_STATUS.partial;
const saved = [];
if (outcome?.browser === 'saved') saved.push(SETTINGS_STATUS.browser);
if (outcome?.browser === 'tab-only') saved.push(SETTINGS_STATUS.tabOnly);
if (outcome?.host === 'saved') {
saved.push(SETTINGS_STATUS.host);
const restart = (outcome.restart ?? []).map(field => RESTART_LABELS[field]).filter(Boolean);
if (restart.length) saved.push(`${SETTINGS_STATUS.restart} ${restart.join(', ')}.`);
}
if (outcome?.host === 'saved') saved.push(SETTINGS_STATUS.host);
if (outcome?.runtime === 'applied') saved.push(SETTINGS_STATUS.runtime);
return saved.join(' ') || SETTINGS_STATUS.clean;
}
@@ -81,11 +64,8 @@ export function createSystemDrafts(adapters) {
const failedKeys = { browser: new Set(), host: new Set(), runtime: new Set() };
const listeners = new Set();
let revision = null;
let hostRead = 'pending';
let outcome = null;
let saving = null;
// What the last host save said beyond its result: the restart fields, or the source it refused.
let hostDetail = {};
const notify = () => { for (const listener of [...listeners]) listener(); };
const changes = domain => Object.fromEntries(drafts[domain]);
@@ -98,7 +78,6 @@ export function createSystemDrafts(adapters) {
const read = await adapters.readHost();
values = read?.values ?? {};
revision = read?.revision ?? null;
hostRead = 'loaded';
} else {
values = await (domain === 'browser' ? adapters.readBrowser() : adapters.readRuntime());
}
@@ -168,8 +147,6 @@ export function createSystemDrafts(adapters) {
if (result?.code === CONFLICT_CODE) { fail(domain, written); return 'conflict'; }
if (!result?.success || result.persisted !== true) { fail(domain, written); return 'failed'; }
if (typeof result.revision === 'string') revision = result.revision;
const restart = result.restart_required_fields;
if (Array.isArray(restart)) hostDetail.restart = restart.filter(field => typeof field === 'string');
commit(domain, written);
return 'saved';
}
@@ -178,30 +155,21 @@ export function createSystemDrafts(adapters) {
return 'applied';
} catch (error) {
fail(domain, written);
if (domain === 'host' && error?.code === INVALID_SOURCE_CODE && typeof error.message === 'string') {
hostDetail.invalid = { source: typeof error.source === 'string' ? error.source : null, message: error.message };
}
return error?.code === CONFLICT_CODE ? 'conflict' : 'failed';
}
}
/**
* Read the host values again; a failed read leaves host saves refused until one succeeds, and `hostState()` says
* so (System S3: the host rows show that the server settings could not be read).
*/
const reloadHost = () => load('host').then(() => true, () => { hostRead = 'failed'; notify(); return false; });
/** Read the host values again; a failed read leaves host saves refused until one succeeds. */
const reloadHost = () => load('host').then(() => true, () => false);
/** Save the dirty domains in order; a Save pressed while one runs joins it. */
function save() {
saving ??= (async () => {
for (const domain of DRAFT_DOMAINS) failedKeys[domain].clear();
hostDetail = {};
const result = {};
for (const domain of DRAFT_DOMAINS) result[domain] = await saveDomain(domain);
Object.assign(result, hostDetail);
// Settings changed elsewhere: read them (and their revision), keeping the drafts (Q10).
if (result.host === 'conflict') await reloadHost();
if (result.browser === 'conflict') await load('browser').catch(() => {});
return result;
})().then(result => {
saving = null;
@@ -212,39 +180,9 @@ export function createSystemDrafts(adapters) {
return saving;
}
/**
* A value the control could not take (System S3: a timeout outside 0–300): no draft, the control is highlighted and
* the status says a change was not saved.
*/
function reject(domain, key) {
requireDomain(domain);
drafts[domain].delete(key);
failedKeys[domain].add(key);
outcome = { [domain]: 'failed' };
notify();
}
/**
* Another page changed saved values this tab has drafts for (a browser `storage` event, System S3): the drafts stay
* and are highlighted, and the status says the settings changed elsewhere.
*/
function conflict(domain, keys) {
requireDomain(domain);
if (!keys.length) return;
for (const key of keys) failedKeys[domain].add(key);
outcome = { [domain]: 'conflict' };
notify();
}
return {
load,
reloadHost,
/** Whether the host values were read: `pending`, `loaded`, or `failed` (the last read failed). */
hostState: () => hostRead,
reject,
conflict,
/** The saved values of a domain (its last read or save), not the drafts. */
saved: domain => { requireDomain(domain); return { ...baseline[domain] }; },
get,
set,
dirty,
@@ -253,8 +191,6 @@ export function createSystemDrafts(adapters) {
changes,
saving: () => saving !== null,
failed: domain => { requireDomain(domain); return new Set(failedKeys[domain]); },
/** The source the last save was refused for (its public path), until the next edit; else null. */
invalidSource: () => outcome?.invalid?.source ?? null,
/** The Save row's text: the last save's outcome until the next edit, else unsaved or clean. */
statusText: () => (outcome ? statusText(outcome) : dirty() ? SETTINGS_STATUS.dirty : SETTINGS_STATUS.clean),
subscribe(listener) {
+10 -60
View File
@@ -4,8 +4,7 @@
* `aria-pressed`; a press navigates to its hash), then `.settings-layout` with the section's body and the Save row
* (`span.small#settings-status` + "Save preferences"). D92: no "Sample configuration" chip, the status copy is
* truthful and "Save preferences" is aria-disabled while nothing is unsaved. A section not delivered yet shows the
* per-section hand-off to its legacy tool (D94), never mock placeholders. Overview (system-overview.mjs),
* Appearance (system-appearance.mjs) and Sources & scans (system-sources.mjs) are native.
* per-section hand-off to its legacy tool (D94), never mock placeholders. Overview is native (system-overview.mjs).
*
* Switching sections keeps the shell (and focus on the pressed button) and disposes the old section's scope, so its
* listeners and timers never outlive it. The drafts are module-level (Q10): they live for the page session.
@@ -15,11 +14,6 @@ import { SYSTEM_SECTIONS, navigateTo } from './state.mjs';
import { createSystemDrafts, DRAFT_DOMAINS } from './system-state.mjs';
import { sectionNav } from './system-controls.mjs';
import { healthNotice, mountOverview } from './system-overview.mjs';
import { mountAppearance } from './system-appearance.mjs';
import { mountSources } from './system-sources.mjs';
import { busyToast } from './activity-scan.mjs';
import { showWorkspaceToast } from './toast.mjs';
import { displayPreferences, readPreferences, reconcilePreferences, subscribePreferences, writePreferences } from './preferences.mjs';
const SETTINGS_URL = '/prompt_manager/settings';
const ADMIN = Object.freeze({ href: '/prompt_manager/admin', label: 'Open existing administration' });
@@ -30,6 +24,8 @@ const GALLERY = Object.freeze({ href: '/prompt_manager/gallery', label: 'Open ex
* settings, thumbnails, duplicates and cache tools.
*/
export const SECTION_HANDOFFS = Object.freeze({
appearance: [GALLERY, ADMIN],
sources: [ADMIN, GALLERY],
integrations: [ADMIN],
tagging: [ADMIN],
maintenance: [ADMIN, GALLERY],
@@ -39,15 +35,6 @@ export const SECTION_HANDOFFS = Object.freeze({
export const NOT_DELIVERED = 'This setting is not available here yet.';
let drafts = null;
// The Activity store of the System page shown last: a refused host save that does not name its job reads the list.
let jobs = null;
/** The running job a 409 `busy` named, else the first running one from a fresh list read (the load never throws). */
async function runningJob(error) {
if (error?.running) return error.running;
await jobs.load();
return jobs.running()[0];
}
function el(tag, text, className) {
const node = document.createElement(tag);
@@ -59,38 +46,19 @@ function el(tag, text, className) {
/** A domain whose settings no delivered section edits yet: nothing can be drafted, and a write would say it failed. */
const undelivered = async () => { throw new Error(NOT_DELIVERED); };
/**
* The page session's drafts (Q10). The browser domain is this browser's display defaults (preferences.mjs): a write
* says whether storage kept it, so the status never claims "Saved in this browser." for a change only this tab holds.
* The host domain reads and saves the server settings with their revision (#220).
*/
/** The page session's drafts (Q10); the host domain reads and saves the server settings with their revision (#220). */
export function systemDrafts(api) {
drafts ??= createSystemDrafts({
readBrowser: () => readPreferences().values,
readBrowser: () => ({}),
readRuntime: () => ({}),
writeBrowser: changes => {
// Another page changed a drafted value since this tab read it, and no storage event told it yet: refuse,
// so the store keeps the draft, flags it and reads the values again; the next Save is an explicit choice.
const baseline = drafts.saved('browser');
const { conflicts } = reconcilePreferences({ baseline, drafts: changes, next: readPreferences().values });
if (conflicts.length) throw Object.assign(new Error('Settings changed elsewhere'), { code: 'settings-changed' });
return { persisted: writePreferences(changes, undefined, { baseline }).persisted };
},
writeBrowser: undelivered,
writeRuntime: undelivered,
async readHost() {
const result = await api.get(SETTINGS_URL, { cache: 'no-store' });
// The store reads a missing value as none and a missing revision as unreadable (no host save).
return { values: result.settings, revision: result.revision };
},
async writeHost(changes, { expected_revision }) {
try {
return await api.post(SETTINGS_URL, { ...changes, expected_revision });
} catch (error) {
// A sources change waits for a running record-changing job (S4): name it, as a refused start does (D57).
if (error?.code === 'busy') showWorkspaceToast(busyToast(await runningJob(error)));
throw error;
}
},
writeHost: (changes, { expected_revision }) => api.post(SETTINGS_URL, { ...changes, expected_revision }),
});
return drafts;
}
@@ -112,20 +80,17 @@ function handoff(section) {
function markFailed(layout, store) {
const failed = new Set(DRAFT_DOMAINS.flatMap(domain => [...store.failed(domain)]));
for (const control of layout.querySelectorAll('[data-setting]')) {
// A row's control names its stored key when that differs from the row id (Appearance's `data-key`).
if (failed.has(control.dataset.key ?? control.dataset.setting)) control.setAttribute('aria-invalid', 'true');
if (failed.has(control.dataset.setting)) control.setAttribute('aria-invalid', 'true');
else control.removeAttribute('aria-invalid');
}
}
/**
* The sections delivered so far: Overview (S2 Task 6), Appearance (S3 Task 8), Sources & scans (S4 Task 11), and Maintenance's notice with "Run health checks" above the
* The sections delivered so far: Overview (S2 Task 6), and Maintenance's notice with "Run health checks" above the
* hand-off to the maintenance tools that are not native yet (D123).
*/
export const DELIVERED_SECTIONS = Object.freeze({
overview: mountOverview,
appearance: mountAppearance,
sources: mountSources,
maintenance: (container, { api, activity, scope }) => [healthNotice({ api, activity, scope }), ...handoff('maintenance')],
});
@@ -136,26 +101,11 @@ export const DELIVERED_SECTIONS = Object.freeze({
* Activity store, which follows the jobs a section starts.
*
* The host values are read on mount (and by the store again after a conflict), so a save carries the current revision;
* until a read returned one, a host save fails without a request (system-state.mjs). The browser values are read on
* mount and again whenever another page changes them (a `storage` event). Either way a value another page changed
* under one of this tab's drafts flags that draft (D92's conflict status), including a change made while System was
* not mounted (the drafts outlive it, Q10).
* until a read returned one, a host save fails without a request (system-state.mjs).
*/
export function mountSystem(main, api, scope, { section, activity, sections = DELIVERED_SECTIONS, navigate = navigateTo }) {
const store = systemDrafts(api);
jobs = activity;
store.reloadHost();
// Another tab or the legacy gallery changed the display defaults: clean values follow it; a draft it overtook stays
// and is flagged (D92's conflict status). `next` is null when the values cannot be read (no gallery-settings.js).
const reconcile = next => {
const baseline = store.saved('browser');
// The first mount has read nothing yet, so nothing can have been overtaken.
const conflicts = next && Object.keys(baseline).length
? reconcilePreferences({ baseline, drafts: store.changes('browser'), next }).conflicts : [];
store.load('browser').then(() => store.conflict('browser', conflicts), () => {});
};
reconcile(displayPreferences());
scope.cleanup(subscribePreferences(window, next => reconcile(next.values)));
const nav = sectionNav({ label: 'System sections', sections: SYSTEM_SECTIONS, active: section, scope,
onSelect: id => navigate(`#/system/${id}`) });
const layout = el('div', undefined, 'settings-layout');
-6
View File
@@ -89,14 +89,8 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
not save is highlighted, as its "Review the highlighted settings" status says. */
.save-row button[aria-disabled="true"] { opacity: .55; cursor: not-allowed; /* D92 */ }
.setting-row [aria-invalid="true"] { outline: 2px solid var(--red); outline-offset: 2px; /* D92 */ }
/* D92: a source the last save was refused for (System › Sources) is highlighted the same way. */
#source-list > .operation[aria-invalid="true"] { outline: 2px solid var(--red); outline-offset: 2px; /* D92 */ }
/* D93: an Overview row whose action is not available yet (the review queue, Q6) keeps its button, disabled. */
.operation .row-actions button[aria-disabled="true"] { opacity: .55; cursor: not-allowed; /* D93 */ }
/* D97: Appearance's legacy display rows. "Show image details on cards" off keeps a card's Info and Tag at the end of
its row; "Collapse viewer details by default" gives the viewer's stage the whole width while its details are hidden. */
.media-info > div[hidden] + .image-card-actions { margin-inline-start: auto; /* D97 */ }
.viewer-layout:has(> #viewer-details[hidden]) { grid-template-columns: minmax(0, 1fr); /* D97 */ }
@media (max-width: 700px) {
.stat-grid { gap: 8px; }
.stat { padding: 16px; }