Compare commits

..
Author SHA1 Message Date
Vito Sansevero e3576f49a5 test(shell): split the shell concept comparison per route and motion
"shell geometry, tokens and controls match the concept" compared all three
routes (Images, Library, Tags) under both motions in one test per size and
theme: 12 page loads, 12 captures and 36 hovers under the 30 s default
budget. Over the last 40 Viewer.js runs it took 22 s median and up to
33 s on CI WebKit, and timed out 6 times (main run 39886719087 among them).
The CI traces show steady progress with no stalled step (the longest one
is a 0.8 s navigation), so the cause is the amount of work in one test,
not a hang or a race.

Each route and motion is now its own test, still covering both sizes and
both themes, with the same assertions and captures.

Under run-pw-ci.sh on WebKit at 2 CPUs, --repeat-each=10:
before 18/40 timed out (median 26.9 s, max 32.5 s);
after 0/240 (median 6.5 s, p90 9.8 s, max 17.2 s).
2026-10-22 23:50:05 -07:00
121 changed files with 448 additions and 15593 deletions
+27 -453
View File
@@ -50,13 +50,6 @@ stdin open and the server (`--exit-on-stdin-eof`) shuts down cleanly, removing i
temp root, when that pipe closes, so a worker killed without teardown leaves no
server behind. A test that fails gets the stderr its server wrote during the test
attached to the report as `server-stderr` (an aiohttp traceback behind a 500).
A `/__test__/.../reset` still running after 2 s writes a report to the job log,
passing test or not: every thread's stack taken while it waits, the default
executor's threads and queue, the open connections and WAL size, then the total
and how long each executor call queued and ran. Its lines start with
`pm-slow-reset| ` (`tests/browser/slow_reset_watch.py`). The report never reads a
connection another thread is using: one that is busy inside SQLite would block
that read with the GIL held and freeze the whole server.
The coverage run can be split the way CI splits it, and each shard keeps the raw
V8 files it writes under `coverage/js/raw`:
@@ -121,13 +114,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 +378,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,78 +389,15 @@ 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
reaches its own sampler is `malformed`; running out of budget is `work-limit` or
`depth-limit`, never single stage. Each sampler's walk runs once per resolution;
selector branch runs share it and other candidates are charged its visit cost.
A UI workflow node whose `type` is a list or an object (read only for the overwritten
PromptManager check) is skipped like a non-object node, since it can be neither a
subgraph instance nor a PromptManager node, so a prompt that resolves still resolves
and the other nodes still feed the check; a list or object `class_type` is never a
preview output. Before #286 both raised and the inspect route answered 500
(`test_unhashable_*` in `tests/test_api_prompt_inspector.py`, the `workflow-type-*`
golden cases on both sides).
Text encoders: `single` encoders (the Qwen image encoders, `TextEncodeQwenImage21`
slot 0 `prompt` and slot 1 `negative_prompt`, `KikoTextEncode`) read one string
input unchanged. `parts-equal` encoders (CLIPTextEncodeSDXL, Flux, SD3) resolve when
@@ -1659,43 +1581,7 @@ AVI/WMV preview only), chosen or dropped, previews inline. The heading's one act
the shared viewer with the full inspector on a record read entirely in this browser
(`web/js/workspace/inspect.mjs`, `local-session.mjs`, `local-inspection.mjs` and the L1
readers `png-metadata.js`, `local-video.mjs`, `local-projection.mjs`). Nothing is
uploaded, imported or stored. PRs L1 and L2 change only HTML and JS, so they need no
ComfyUI restart; PR L3 does (below).
Local roles (#249 PR L3, decision LI-1): the prompts and resolution of a local record are
the server inspector's for the same records. `web/js/workspace/local-resolver.mjs` (with
`local-resolver-support.mjs`, `local-resolver-stages.mjs`, `local-parameters.mjs` and
`py-json.mjs`) ports `inspect_prompt_evidence` with `INSPECTION_PROFILE`: execution
evidence, graph tracing, every bound, several sampler candidates with the
identical-branch rule (#270), preview-only stages, detailer stages (multistage) and A1111
`parameters`. It reads the server's own node table, `web/js/inspection-semantics.json`,
which the page fetches once through the API client as it mounts, before any selection
(`local-semantics.mjs`; `/prompt_manager/js/inspection-semantics.json` is the one JSON
file the static route serves, by exact path). A table that cannot be fetched answers
"The inspector's node table is unavailable." as the server does. The legacy fill in
`projectInspectionPayload` then applies to a local record exactly where it applies to the
Images answer for the same bytes. Python is the oracle:
`tests/test_local_inspect_parity.py` `LocalResolverParityTests` compares the local
evidence byte for byte with `inspect_prompt_evidence` for every parity PNG and inspection
vector (prompts, resolution and facts), the golden and selection record sets
(`tests/local_resolver_cases.py`, `tests/local_resolver_selection_cases.py`), every work
bound and 1,200 seeded fuzz cases; `tests/fixtures/local-inspect/resolver-vectors.json`
holds the committed answers the Node unit test reads. The browser spec `chooser shows the
Images roles and resolution for every parity PNG` compares the rail with Images for all
25 committed parity PNGs. The static route change and the resolver fixes (Python) need a
ComfyUI restart.
Main thread (M4): the resolver runs on the page's thread, not a worker. On a desktop
(Node 26) `resolveLocalEvidence` on maximal 2 MiB graphs (a 19,990-sampler chain, 6,600
independent samplers, a consumer-dense scan, a 100,000-node PromptManager workflow) took
130–455 ms; the server took 80–905 ms on the same inputs. `localFileAdapter.read` runs in
bounded steps (read, table, parse and resolve, project, fit) with a turn of the event
loop between them, every wait ends with the selection's signal (a stalled table request
included), and the inspector carries `aria-busy="true"` with "Loading file details…"
until the answer lands. `tests/js/workspace-local-inspection.test.js` pins a 2,500 ms
budget per step on maximal independent-sampler and stage-chain graphs, checks that the
stepped read answers byte for byte as one synchronous build, and that an abort at any
step ends the read.
uploaded, imported or stored. Only HTML and JS change, so no ComfyUI restart is needed.
Bounds (the server's own budgets, ported by PR L1; see "Follow-up: local Inspect parity"
under "Chronological Library history" for the known divergences):
@@ -1714,8 +1600,7 @@ under "Chronological Library history" for the known divergences):
Privacy, asserted by `local inspection makes no content request or storage write` and
`local roles send exactly` in `tests/browser/workspace-inspect.spec.mjs`, and by the
managed-host spec below: the only static file Inspect reads is its node table, once per
page and before any selection; no request carries the file's bytes, name or text (fetch, XHR,
managed-host spec below: no request carries the file's bytes, name or text (fetch, XHR,
beacon, WebSocket, EventSource); Inspect writes nothing to `localStorage`,
`sessionStorage` or IndexedDB; only an explicit Send crosses the host bridge, and its
payload carries neither the `blob:` URL nor the filename; no console message names the
@@ -1742,40 +1627,19 @@ mapped differences"):
`image/png,image/jpeg,image/webp` plus `video/*,.mp4,.mov,.m4v,.webm,.mkv,.avi,.wmv`. A
video previews in the same `#local-preview` slot as `video.local-file-preview` with
native `controls`, `playsInline`, `preload="metadata"` and `muted`, never autoplays
inline. On replacement or dispose it is paused, has its `src` removed, is reset with
`load()` (which frees its decoder) and is removed from the page, before its URL is
revoked. Each preview owns its listeners, so the page never pins a released node.
A video replaced while still starting (a source, but neither metadata nor an error yet)
stays where it is, inert and `aria-hidden`, with the new preview inserted after it, and
the session's revoke of its URL waits; once it reports metadata or an error (or after
15 s) it is released as above and only then is its URL revoked. For that moment (the
time to metadata, or at most 15 s for a video that never settles) the old video and the
new preview both show, the new one below it: a known, brief overlap. Stacking them in one
slot instead hung again, because it changes the video's own box: the old video
absolutely positioned in its own box hung 5 of 120 pages (8 of 120 with `opacity: 0`),
and a one-cell grid 4 of 120. Only `opacity: 0` on the video, left in the flow, did not
hang (0 of 120), and alone it would leave an empty hole above the new preview. A route
change within the workspace waits the same way: the shell renders the next route once a
video still starting on Inspect settles (`leaveGuard`; the latest route wins). Route
changes at `loadstart` + 0–7 ms hung 4–5 of 120 pages before and 0 of 120 after.
A real page leave cannot wait. With no page code releasing anything, navigating
away hung 8 of 120 pages and reload 22 of 120 (3 and 8 of 120 with the page's own
release), so that WebKit teardown stays a known limitation; dispose still releases at
once. Cause, found with native stacks of the hung WPE web process in the
pinned image: WebKit (GStreamer) deadlocks when a video leaves the page or the viewport
before its pipeline has started. The main thread changes the pipeline state and waits
for the source's streaming thread, which is itself waiting on the main thread in a
synchronous bus handler. Removing the video, hiding it, or only pushing it below the
fold with a taller preview inserted before it all hung (7 to 21 of 120 fresh pages each,
replaced at `loadstart` + 0–7 ms), and none of the earlier steps (`pause()`, removing
`src`, `load()`, revoking the URL) was the cause on its own; pushing it out after
`loadedmetadata` hung 0 of 180. Specs: `replacing a video that just started loading
never blocks the page`, and `a video replaced while still starting is reset once it
settles`, which now runs on WebKit too (15 fresh pages, replaced at `loadstart` + 0–4
ms), and `leaving for another route while a video starts waits for it to settle`. The
viewer's own video teardown was checked the same way (`closing the viewer on a video
that just started never blocks the page`, 15 fresh pages on WebKit) and needed no
change.
inline, and is paused, has its `src` removed and is removed from the page on replacement
or dispose, before its URL is revoked. Each preview owns its listeners, so the page never
pins a released node. A settled video (metadata read, or failed) is then reset with
`load()` at once; one still starting is reset only once it reports metadata or an error,
because WebKit (GStreamer) blocked the page when such a pipeline was reset (`replacing a
video that just started loading never blocks the page`, `a video replaced while still
starting is reset once it settles`). A WebKit load cut off by the revoked URL usually
stalls and never settles; a fallback then only drops the waiting listeners. The viewer's
own video teardown was checked the same way (`closing the viewer on a video that just
started never blocks the page`, 15 fresh pages on WebKit) and needed no change.
Follow-up: on WebKit, a video released exactly at its `loadstart` sometimes blocks the
page, with or without any reset and also before the deferred reset (2 of 24 runs in the
pinned image), so the `loadstart` spec is skipped on WebKit until that is investigated.
- **D63** (status line): one `p.small.spaced-16` in `#local-preview`, for states the
concept never shows: "Drop one file at a time.", "Folders cannot be inspected. Choose
one image or video file.", "This file type cannot be previewed or inspected here.
@@ -1800,29 +1664,6 @@ mapped differences"):
Modified with no Path row and no Show in folder, and ends with "Local file; no saved
library association was checked." and Download original. There is no Prompt history
action and no category or tag chips.
- **D117** (Copy settings summary, LI-4): the legacy metadata
page's COPY ALL, consolidated into the shared full inspector (the viewer's
`#viewer-details`, so Images, the recorded history's current file and Inspect all have
it). The concept has no such action. One plain button "Copy settings summary" follows
the six facts (before "File & provenance"); the Images side panel keeps the concept's
footer and has none. Its text is the legacy `copyAllData` template, line for line:
`Checkpoint`, `Positive Prompt`, `Negative Prompt`, `Steps`, `CFG Scale`, `Sampler`,
`Seed`, with the legacy placeholders ("Unknown", "No prompt found", "No negative prompt
found"). The values are the ones the inspector shows: a confirmed prompt and a known
fact verbatim, anything else (an explicitly empty prompt included, as the legacy parse
treats '') the placeholder. Against the legacy page, for the same file, only these
differ: numbers keep the server's spelling ("8.0" where the legacy page wrote 8); a fact
the legacy parse could not read can be known; and a negative the #249 ZeroOut guard
zeroed reads "No negative prompt found" where the legacy parse read the positive's text
through the zero-out. `workspace-file-inspector.test.js` runs the template taken from
`web/metadata.html` itself on the legacy parse of every parity PNG: 22 of 25 are
byte-equal, f01b and f23 differ by the ZeroOut guard and f17 by facts the legacy parse
missed. Copying works as the concept's prompt copy: the toast "Settings summary
copied.", or, without a clipboard or when it is refused, the select-to-copy dialog
titled "Copy settings summary". `.ca-tools #viewer-details > div > button` wraps
(`white-space: normal`) in the narrow rail at 200% zoom, as D20's buttons do. The concept
comparisons add the button to the concept's viewer at runtime (a copy of its own plain
button) and to the Inspect rail's section order.
The chooser keeps naming the chosen file, as the concept's does: it is cleared as the
picker opens (so choosing the same file again is a new selection), not after `change`,
@@ -1836,8 +1677,9 @@ Legacy floor (`web/metadata.html`), dispositions:
- Full text (the old "Show more"): the prompt blocks show the whole text.
- Raw workflow view and Download workflow JSON: kept, in "Workflow & raw metadata".
- Copy all summary and filename copy (LI-4): the filename is selectable text in the rail
and Download original saves the file under its name. COPY ALL is the shared full
inspector's "Copy settings summary" (D117), with the legacy text.
and Download original saves the file under its name. The ruled "Copy settings summary"
button after the six facts of the shared full inspector is not part of PR L2; until it
lands, the legacy page keeps COPY ALL and stays reachable from Overview.
- Fully browser-local reading: kept and asserted (privacy above).
- Case-variant keys `Workflow|comfy|ComfyUI|Prompt|Parameters` (LI-5): a separate
backend issue; Inspect follows the server's readers, so the two never disagree.
@@ -1880,229 +1722,6 @@ npm run --prefix tools/vendor test:workflows -- inspect-concept.spec.mjs workspa
npm run --prefix tools/vendor test:comfyui -- inspect-sender.spec.mjs
```
### Native System (#249 System)
`#/system/<section>` is a native workspace route rendered by `web/js/workspace/system.mjs`
on the concept's `renderSystem` markup (app.js a698–706; `concept.css` 709–895). `#/system`
is Overview under the sidebar's own hash; `#/system/overview|appearance|sources|
integrations|tagging|maintenance|backups|diagnostics` select a section, and an unknown
child (the concept's `#/system/general`) is replaced by `#/system/overview`. The
production hub `#/overview` is unchanged (D28); its System card opens
`#/system/overview` (Q12). Switching sections keeps the shell, the drafts and focus on
the pressed section button and disposes the previous section's scope, so its reads,
listeners and dialogs never outlive it.
Drafts (`system-state.mjs`) live for the page session (Q10). Save writes the dirty
browser fields, then the host fields (`POST /prompt_manager/settings` with
`expected_revision`, #220), then the runtime fields. A failed domain keeps its drafts and
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.
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
paints nothing. Until it answers, the section shows "Loading the overview…"; a failure
shows "The overview could not be loaded. Your saved data has not changed." and Retry,
never zeros. The thumbnail row is a separate read of `/images/thumbnails/status` (a
walk of every source), so the other values never wait for it: "Checking thumbnails…"
until it answers. The health check (`POST /prompt_manager/system/health`) is a `read`
registry job of kind `health`: one at a time, beside a scan, not cancellable and with
no file outcomes. The page tracks the started job in the Activity store (its event
stream; the store marks it lost if the server stops listing it), never by polling. A press while one runs (another tab) waits
for that one. The last report lives for this server session (`GET
/prompt_manager/system/health`) and describes the database it was taken from: a
restore that replaces the database forgets it, and a check that started before the
swap is not kept. The check's own connection opens a `mode=ro` URI that keeps a Windows
UNC path (`file:////server/share/prompts.db`; pathlib's `file://server/share/...` is a
remote authority SQLite refuses). A check that raises fails only itself, and the
missing-files check reads its rows inside the snapshot but stats the files after it
ended, so a slow source never pins the WAL. Statistics
read `GET /prompt_manager/system/statistics` (`by_category`, the legacy `/stats` totals
and `rated_images`).
Read-only standard for a check or preview (S2's health check; S6 and S8 previews use
the same): the database and `-wal` bytes are unchanged, the `-shm` size is unchanged,
the connection's `total_changes` is 0 at close and the data reads back unchanged. The
`-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–D65 renumbered D92–D97; D121, D122, D123, D124, D125 and D126 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
configuration." / "Applied until the server restarts.", "Some changes were not saved.
Review the highlighted settings.", "Saved for this tab only; browser storage is
unavailable." and "Settings changed elsewhere. Reload this section to see them."
"Save preferences" is `aria-disabled` (opacity .55, `not-allowed`) while clean or
saving; a setting the last save could not save gets a red outline. CSS under D92.
- **D93** (Overview): real values. The metrics are the Library's eligible positives
and active saved negatives and the indexed `generated_images` rows (Q2). "Database
integrity" is "Not checked" until a check ran this session, then "N issues" (checks
that found issues, even beside a check that could not run), "Passed" or "Incomplete".
"Ambiguous image links" reads "Not available yet." with its Review metadata action
`aria-disabled` (CSS under D93) until the Needs review queue reports a count (Q6).
"Backup status" reads "No retained backup record." until verified backups exist (S8).
The thumbnail row is "Checking thumbnails…" until its own read answers, then "No
missing thumbnails", "N missing thumbnails" (the pending count of
`/images/thumbnails/status`) or "Thumbnail status unavailable"; Review thumbnails
opens Sources until the thumbnail dialog (S5). "One display configuration" says "Grid
and density settings are shared across views." (no paging). The sources row is
"N output sources" with "Monitoring and enrichment share one source list.", prefixed
by "M of N configured sources available." or "None configured is available; ComfyUI's
output folder is used." when that is the case.
- **D94** (all sections): a section not delivered yet shows the hand-off panel "Your
current tools are ready" with a link to each legacy tool that still owns it (the
plan's legacy coverage table); retired section by section, gone after S12.
- **D95** (health report): the concept's `health-check` dialog titled "Health report",
with no sample note. It lists every check in the server's order: Integrity check,
Foreign keys & pair references, Canonical hashes, Pair uniqueness (added), Missing
files, Ambiguous metadata, Consistency (added). Missing files has one row per image
source when there are several ("Missing files · output"). Each chip is the real
result: "Passed", a count with what was found ("2 broken references", "1 link needs
review", the concept's "N links need review" and "N images need review"; "100+
problems found" when SQLite's integrity check stopped at its limit), "Not
available" (an unavailable source), "Not available yet" (Ambiguous metadata, Q6) or
"Could not check". A `p.small` after the list says when it was checked and whether
every check passed, some could not run, or issues need review. Close / Review missing
links (to Maintenance, its hand-off until S7). Nothing is changed by a check.
- **D96** (Statistics): "Usage statistics" without the sample note: the concept's
"Runs by category" table (Category / Runs / Avg. rating) with the real runs and
average rating of eligible parents per category ("Uncategorized", "Not rated"),
then a second table "Library totals" (Total / Count: Prompts, Tags, Images from the
legacy statistics, and Rated images: indexed images whose linked prompt is rated).
The Overview row says "Runs and ratings by category, with library totals." because
that is what the dialog shows (the concept's own dialog shows only "Runs by
category"); per-prompt run counts, prompt tags and dated runs are recorded, but no
table of them exists yet. A failed read toasts "Statistics could not
be loaded. Your saved data has not changed."
- **D121** (production hub): the Overview hub's System card reads the concept's System
heading and "Open System"; the concept has no hub card.
- **D122** (shell): the section nav's `aria-label` is "System sections"; the concept's
literal label is its prototype action name "system-tab sections".
- **D123** (Maintenance): the concept's Maintenance notice
("Check first. Review changes. Then apply." + Run health checks) is delivered ahead of
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.
- **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,
the difference is exactly the D92, D93, D96 and D122 paths. Each surface's `top` is
measured, which pins the concept's `.surface + .surface` 18px offset that concept.css
also applies inside `.two-col` (copied as is). `statistics and health dialogs match
the concept` maps D95 from the concept's own five rows (labels verbatim): it inserts
Pair uniqueness after Canonical hashes and Consistency last, splits Missing files per
source when there are several, replaces only the chips with the fixture's expected
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. 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`) 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. Its hooks are
`tests/browser/workspace_system_fixture.py` (`/__test__/system/*`: reset, injected
failures, an absent source, a deleted linked file, unthumbnailed files) 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`).
```sh
python3 -m unittest tests.test_database_health tests.test_system_api tests.test_library \
tests.test_workspace_system_fixture -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
npm run --prefix tools/vendor test:workflows -- system-concept.spec.mjs workspace-system.spec.mjs \
--project chromium --project touch --project firefox --project webkit
```
## Workspace data fields for the concept pages (#249)
These fields are additive. The legacy `/prompt_manager/images/output` listing (no
@@ -2144,24 +1763,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
@@ -2669,41 +2272,12 @@ skip without ffmpeg). The remaining known divergences, checked against ffprobe 9
`unreadable`); ffprobe reads such files.
- An EBML DocType other than `matroska`/`webm` is an invalid container locally; ffmpeg
only warns and reads it.
- Deep JSON (local roles, PR L3, and video envelopes): both browser CPython-JSON
parsers (`py-json.mjs`, `local-video.mjs`) share one cap, `JSON_MAX_DEPTH` = 1,000
nested arrays or objects (each counts, an empty one too); past it a record is invalid
JSON locally. The server's `json.loads` raises RecursionError at a limit that depends
on the Python, measured on Linux x86-64: 993 levels from a bare script on 3.10 and
3.11 (the 1,000 recursion limit minus the caller's frames, so about 990 inside the
server), 9,997 on 3.12 and 9,998 on 3.13 (the C recursion counter; lower on Windows
builds), and about 52,150 on 3.14 (stack-based, varies with the thread's stack).
The record kinds answer: a deep `prompt` graph (or `comfyassets_prompt_pairs`
envelope) is malformed; a deep `workflow` is ignored, so the overwritten PromptManager
check (R1) does not run and a prompt can resolve; a deep video `comment` or
`description` envelope makes the file unreadable on both sides (the server's
RecursionError reaches `read_inspection_file`). That gives two directions:
- 3.10/3.11, between the server's limit and 1,000: the server refuses and the local
page reads, so a deep prompt resolves locally while the server says malformed, and
a deep workflow can make R1 refuse locally where the server resolves.
- 3.12 and newer, from 1,001 up to the server's limit: the local page refuses and
the server reads, so a deep prompt is malformed locally while the server resolves,
and a deep workflow is ignored locally while the server can refuse it under R1.
Past both limits they agree. `test_local_json_depth_cap_at_its_boundary` checks the
local answer at 999, 1,000 and 1,001 for both kinds and that any server difference
is only one of the two directions above.
- Local roles (PR L3): `\d` in the A1111 settings footer is any Unicode decimal
digit on both sides, but the browser's Unicode version can be newer than the server
Python's, so a footer whose step count uses a digit added after Unicode 15 can differ.
`str.lower()` and `toLowerCase()` differ only for characters no node class name uses.
- A duplicate key in an object enclosing a nest deeper than the running
- Envelope JSON deeper than 1,000 levels is refused locally as Python's default
recursion limit refuses it; the server's real limit depends on the interpreter and
call stack. A duplicate key in an object enclosing a nest deeper than the running
Python's json limit is `malformed` locally and `limited` on that Python.
- Decimal exponent limits assume CPython's C `_decimal`; on a `_pydecimal` interpreter
the server would read the over-limit exponents the local projection refuses.
- Follow-up (two CPython-JSON emulations, review M6): `local-video.mjs`
(`pythonJsonLoads`/`pythonFloatRepr`, numbers kept as lexemes) and `py-json.mjs`
(`pyJsonLoads`/`pyFloatRepr`, Python value kinds) both emulate `json.loads`; only the
depth cap is shared so far. A fix to one parser can miss the other, so the video
reader should move onto `py-json.mjs` in a later PR.
Preserve raw logs, failed traces, paired PNGs, geometry, runtime versions and
source hashes before later managed commands clear their output directories.
-354
View File
@@ -1,354 +0,0 @@
"""The read-only health check behind System › Overview and Maintenance.
``run_health_checks`` opens its own ``mode=ro`` connection, reads one
snapshot and closes the connection in ``finally``; it never writes. Each
check reports ``passed``, ``issues`` (with an exact ``count``; the
integrity check stops at ``INTEGRITY_LIMIT`` problems and then says
``capped``),
``unavailable`` (it could not be checked here, so ``count`` is None, or
``missing_files`` checked only some sources and found nothing) or ``failed``
(it raised; the other checks still run). ``missing_files`` also reports each
source and whether the check was ``partial``; it reads the image rows inside
the snapshot and checks their files after the snapshot ended, so a slow source
never holds the WAL. At most ``MAX_DETAIL`` detail strings are returned per
check, and none of them carries a server path.
"""
import datetime
import logging
import ntpath
import os
import sqlite3
from pathlib import PurePosixPath, PureWindowsPath
from urllib.parse import quote
try:
from ..utils.hashing import generate_prompt_hash
from .models import normalize_image_path
except ImportError:
from utils.hashing import generate_prompt_hash
from database.models import normalize_image_path
logger = logging.getLogger("prompt_manager.database.health")
MAX_DETAIL = 10 # detail strings returned per check
INTEGRITY_LIMIT = 100 # PRAGMA integrity_check(N): problems SQLite reports
BATCH_ROWS = 100 # image rows read per fetch
REVIEW_UNAVAILABLE = "Needs review is not available yet."
CHECK_FAILED = "The check could not finish."
# Check ids and the labels of the concept's health report, in its order;
# pair uniqueness and consistency are the added rows (D63, now D95).
CHECKS = (
("integrity", "Integrity check"),
("foreign_keys", "Foreign keys & pair references"),
("canonical_hashes", "Canonical hashes"),
("pair_uniqueness", "Pair uniqueness"),
("missing_files", "Missing files"),
("ambiguous_metadata", "Ambiguous metadata"),
("consistency", "Consistency"),
)
_DATABASE_CHECKS = (
"integrity",
"foreign_keys",
"canonical_hashes",
"pair_uniqueness",
"missing_files",
"consistency",
)
def iter_consistency_issues(conn):
"""The legacy consistency issues: tag and image rows of a missing prompt."""
for ref in conn.execute(
"SELECT pt.prompt_id FROM prompt_tags pt "
"LEFT JOIN prompts p ON pt.prompt_id = p.id WHERE p.id IS NULL"
):
yield f"prompt_tags entry references non-existent prompt {ref[0]}"
for ref in conn.execute(
"SELECT gi.id, gi.prompt_id FROM generated_images gi "
"LEFT JOIN prompts p ON gi.prompt_id = p.id WHERE p.id IS NULL"
):
yield f"Image {ref[0]} references non-existent prompt {ref[1]}"
def _result(count, detail, *, status=None):
return {
"status": status or ("issues" if count else "passed"),
"count": count,
"detail": detail[:MAX_DETAIL],
}
def _counted(lines):
"""(count, first MAX_DETAIL lines) of an iterable, consumed once."""
count, detail = 0, []
for line in lines:
count += 1
if len(detail) < MAX_DETAIL:
detail.append(line)
return _result(count, detail)
def readonly_uri(db_path, *, windows=None):
"""The ``mode=ro`` SQLite URI of *db_path*, on this platform or the given one.
pathlib spells a UNC path ``\\\\server\\share\\x.db`` as
``file://server/share/x.db``, a remote authority SQLite refuses to open.
With an empty authority (``file:////server/share/x.db``) SQLite hands
Windows the ``//server/share`` path, which it opens as the UNC path.
"""
if windows is None:
windows = os.name == "nt"
db_path = os.path.abspath(db_path)
if not windows:
return PurePosixPath(db_path).as_uri() + "?mode=ro"
path = PureWindowsPath(db_path)
if ntpath.splitdrive(str(path))[0].startswith("\\\\"):
return "file:" + quote("//" + path.as_posix()) + "?mode=ro"
return path.as_uri() + "?mode=ro"
def _integrity(conn, _roots):
rows = [
row[0] for row in conn.execute(f"PRAGMA integrity_check({INTEGRITY_LIMIT})")
]
if rows == ["ok"]:
return {**_result(0, []), "capped": False}
# SQLite stops listing at the limit: there may be more problems than that.
return {**_result(len(rows), rows), "capped": len(rows) >= INTEGRITY_LIMIT}
def _foreign_keys(conn, _roots):
return _counted(
f"{table} row {rowid} references a missing {parent} row"
for table, rowid, parent, _fkid in conn.execute("PRAGMA foreign_key_check")
)
def _prompt_hash(text):
return generate_prompt_hash(text) if isinstance(text, str) else None
def _canonical_hashes(conn, _roots):
conn.create_function("pm_prompt_hash", 1, _prompt_hash, deterministic=True)
return _counted(
f"Prompt {row[0]}: the stored hash does not match its text"
for row in conn.execute(
"SELECT id FROM prompts WHERE hash IS NOT pm_prompt_hash(text) ORDER BY id"
)
)
def _pair_uniqueness(conn, _roots):
groups = conn.execute(
"SELECT positive_prompt_id,negative_prompt_id,COUNT(*) FROM prompt_pairs "
"GROUP BY positive_prompt_id,negative_prompt_id HAVING COUNT(*)>1 "
"ORDER BY positive_prompt_id,negative_prompt_id"
).fetchall()
detail = [
f"Positive prompt {positive} with "
+ ("no negative" if negative is None else f"negative {negative}")
+ f": {rows} pair rows"
for positive, negative, rows in groups
]
# The surplus rows: what keeping one row per pair would leave out.
return _result(sum(rows - 1 for _p, _n, rows in groups), detail)
def _consistency(conn, _roots):
return _counted(iter_consistency_issues(conn))
def _readable(root):
return os.path.isdir(root) and os.access(root, os.R_OK | os.X_OK)
def _root_prefix(root):
return normalize_image_path(root).rstrip("\\/") + os.sep
def _source_name(root, index):
"""A source's folder name for the report (never its server path)."""
return os.path.basename(str(root).rstrip("\\/")) or f"Source {index + 1}"
def _owner(path, prefixes):
"""Index of the longest source prefix *path* lies under, or None."""
owner, longest = None, -1
for index, prefix in enumerate(prefixes):
if path.startswith(prefix) and len(prefix) > longest:
owner, longest = index, len(prefix)
return owner
def _image_owner(image_path, file_path, prefixes):
spelled = file_path or normalize_image_path(image_path)
owner = _owner(spelled, prefixes)
if owner is None:
owner = _owner(normalize_image_path(os.path.realpath(image_path)), prefixes)
return owner
def _unavailable_line(unavailable, *, partial):
noun = "source is" if unavailable == 1 else "sources are"
effect = "its files were" if unavailable == 1 else "their files were"
if not partial:
effect = "missing files were"
return f"{unavailable} image {noun} unavailable, so {effect} not counted."
def _missing_files(conn, roots):
"""Links whose file is gone, counted per source (only readable ones).
Each source reports ``checked`` with its own count, or ``unavailable``
(an unmounted drive) with no count: its links are never turned into a
count, while the readable sources still are. The check's ``count`` is the
readable sources' total (None when none is readable) and ``partial`` says
some sources were not checked. Links outside every source are named in the
detail, not counted.
Inside the snapshot this only reads the image rows; it returns the step
that checks their files, which runs after the snapshot ended.
"""
if not roots:
result = _result(None, ["No image source is available."], status="unavailable")
return {**result, "partial": False, "sources": []}
rows = []
cursor = conn.execute(
"SELECT id,image_path,filename,file_path FROM generated_images ORDER BY id"
)
while batch := cursor.fetchmany(BATCH_ROWS):
rows.extend(batch)
return lambda: _check_files(rows, roots)
def _check_files(rows, roots):
"""The ``missing_files`` result of the image *rows*, from the file system."""
readable = [_readable(root) for root in roots]
sources = [
{
"name": _source_name(root, index),
"status": "checked" if ok else "unavailable",
"count": 0 if ok else None,
}
for index, (root, ok) in enumerate(zip(roots, readable))
]
unavailable = readable.count(False)
if not any(readable):
result = _result(
None, [_unavailable_line(unavailable, partial=False)], status="unavailable"
)
return {**result, "partial": False, "sources": sources}
prefixes = [_root_prefix(root) for root in roots]
outside, missing = 0, []
for image_id, image_path, filename, file_path in rows:
owner = _image_owner(image_path, file_path, prefixes)
if owner is None:
outside += 1
elif readable[owner] and not os.path.exists(image_path):
sources[owner]["count"] += 1
if len(missing) < MAX_DETAIL:
name = filename or os.path.basename(image_path)
missing.append(f"Image {image_id}: {name} is missing")
count = sum(source["count"] or 0 for source in sources)
detail = [_unavailable_line(unavailable, partial=True)] if unavailable else []
detail += missing
if outside:
links = "link" if outside == 1 else "links"
verb = "was" if outside == 1 else "were"
detail.append(
f"{outside} {links} outside the image sources {verb} not checked."
)
status = None if count or not unavailable else "unavailable"
result = _result(count, detail, status=status)
return {**result, "partial": bool(unavailable), "sources": sources}
def _ambiguous(review_count):
if review_count is None:
return _result(None, [REVIEW_UNAVAILABLE], status="unavailable")
return _result(review_count, [])
def overall_status(checks) -> str:
"""``passed`` only when every check passed; any check not run is incomplete."""
statuses = {check["status"] for check in checks}
if statuses & {"unavailable", "failed"} or any(
check.get("partial") for check in checks
):
return "incomplete"
return "issues" if "issues" in statuses else "passed"
def _failed():
return _result(None, [CHECK_FAILED], status="failed")
def _run_check(check_id, step, *args):
"""One check's result, or None (reported as failed) when it raised."""
try:
return step(*args)
except Exception: # any error fails this check only; the others still run
logger.exception(f"Health check {check_id} failed")
return None
def _run_database_checks(db_path, roots):
"""Each database check's result (None when it raised), from one snapshot.
A check may return a step instead of its result: the step runs after the
snapshot ended and the connection closed (``missing_files``' file checks).
"""
results = dict.fromkeys(_DATABASE_CHECKS)
try:
conn = sqlite3.connect(readonly_uri(db_path), uri=True)
except sqlite3.Error:
logger.exception("Health check could not open the database")
return results
try:
conn.execute("BEGIN") # one read snapshot for every check
for check_id in _DATABASE_CHECKS:
runner = globals()["_" + check_id] # looked up per run (tests patch it)
results[check_id] = _run_check(check_id, runner, conn, roots)
except sqlite3.Error:
logger.exception("Health check could not read the database")
finally:
conn.close()
for check_id, result in results.items():
if callable(result):
results[check_id] = _run_check(check_id, result)
return results
class HealthOperations:
"""Mixin for PromptDatabase: the read-only System health check."""
def run_health_checks(self, roots, *, review_count=None) -> dict:
"""Run every check and return a ``HealthReport``.
Args:
roots: The image source directories to judge missing files under:
the effective sources plus any configured source that is not
available (reported as an unavailable source of ``missing_files``).
review_count: The Needs-review pending count, or None while no
review queue exists (``ambiguous_metadata`` is then unavailable).
"""
if review_count is not None and (
type(review_count) is not int or review_count < 0
):
raise ValueError("review_count must be a non-negative integer or None")
roots = [str(root) for root in roots]
database = _run_database_checks(self.model.db_path, roots)
checks = []
for check_id, label in CHECKS:
if check_id == "ambiguous_metadata":
result = _ambiguous(review_count)
else:
result = database[check_id] or _failed()
checks.append({"id": check_id, "label": label, **result})
return {
"success": True,
"checked_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"status": overall_status(checks),
"checks": checks,
}
-51
View File
@@ -628,54 +628,3 @@ class LibraryOperations:
"kind": "prompt-manager-library",
"positives": [owners[key] for key in order],
}
def library_counts(self) -> dict:
"""System › Overview counts from one snapshot, writing nothing.
``positive_prompts`` and ``negative_variants`` are the unfiltered
Library's ``total`` and ``variant_total``: eligible parents and active
pairs (every pair's positive is eligible). ``images`` counts indexed
``generated_images`` rows, not files on disk. An image has no rating
of its own (it shows its linked prompt's), so ``rated_images`` counts
the indexed images whose linked prompt is rated.
"""
with self.library_snapshot() as (conn, _epoch, _ceiling):
row = conn.execute(
"SELECT (SELECT COUNT(*) FROM prompts p WHERE " + _ELIGIBLE + "),"
"(SELECT COUNT(*) FROM prompt_pairs WHERE archived=0),"
"(SELECT COUNT(*) FROM generated_images),"
"(SELECT COUNT(*) FROM generated_images gi JOIN prompts r "
"ON r.id=gi.prompt_id WHERE r.rating IS NOT NULL)"
).fetchone()
return {
"positive_prompts": row[0],
"negative_variants": row[1],
"images": row[2],
"rated_images": row[3],
}
def usage_by_category(self) -> list[dict]:
"""Runs and average rating of the Library's parents, per category.
Only eligible parents count, so a negative-only prompt's runs (which
repeat its pairs' runs) are never added twice. A blank category groups
with no category as ``None``. Unrated parents are left out of the
average, which is ``None`` when no parent is rated. Most runs first.
"""
with self.library_snapshot() as (conn, _epoch, _ceiling):
rows = conn.execute(
"SELECT * FROM (SELECT NULLIF(TRIM(p.category),'') AS category,"
"COALESCE(SUM(p.run_count),0) AS runs,AVG(p.rating) AS rating "
"FROM prompts p WHERE " + _ELIGIBLE + " GROUP BY 1) "
"ORDER BY runs DESC,category IS NULL,category"
).fetchall()
return [
{
"category": row["category"],
"runs": row["runs"],
"average_rating": (
None if row["rating"] is None else round(row["rating"], 2)
),
}
for row in rows
]
+31 -16
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
@@ -21,13 +20,11 @@ from .execution_history import ExecutionHistoryOperations
from .execution_contexts import ExecutionContextOperations, LIVE_HINT_CTES
from .execution_outputs import ExecutionOutputOperations
from .history_reads import HistoryReadOperations
from .health import HealthOperations, iter_consistency_issues
# Import logging system
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 +32,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
@@ -346,7 +342,6 @@ class PromptDatabase(
ImageFavoriteOperations,
AnnotationOperations,
SavedViewOperations,
HealthOperations,
):
"""Database operations class for managing prompts."""
@@ -1700,9 +1695,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 +1902,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
@@ -2575,13 +2570,33 @@ class PromptDatabase(
return cursor.rowcount
def check_consistency(self) -> List[str]:
"""Run consistency checks and return a list of issue descriptions.
The same queries back the System health check's Consistency row
(database/health.py).
"""
"""Run consistency checks and return a list of issue descriptions."""
issues: List[str] = []
with self.model.get_connection() as conn:
return list(iter_consistency_issues(conn))
# Check for orphaned prompt_tags entries
cursor = conn.execute("""
SELECT pt.prompt_id, pt.tag_id FROM prompt_tags pt
LEFT JOIN prompts p ON pt.prompt_id = p.id
WHERE p.id IS NULL
""")
for ref in cursor.fetchall():
issues.append(
"prompt_tags entry references non-existent prompt "
f"{ref['prompt_id']}"
)
# Check for orphaned image entries
cursor = conn.execute("""
SELECT gi.id, gi.prompt_id FROM generated_images gi
LEFT JOIN prompts p ON gi.prompt_id = p.id
WHERE p.id IS NULL
""")
for ref in cursor.fetchall():
issues.append(
f"Image {ref['id']} references non-existent prompt "
f"{ref['prompt_id']}"
)
return issues
def _image_row_to_dict(self, row: sqlite3.Row) -> Dict[str, Any]:
"""
+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"),
+13 -40
View File
@@ -8,7 +8,6 @@ Split into domain-specific mixins for maintainability:
- AutotagRoutesMixin: auto-tagging model management and tagging
- JobRoutesMixin: the session job registry behind Activity (py/api/jobs.py)
- ImageTagJobsMixin: bulk Add tags / Auto-tag as image-tags jobs
- SystemRoutesMixin: System health check, Overview and usage statistics
"""
import asyncio
@@ -16,7 +15,6 @@ import datetime
import functools
import gzip as gzip_module
import json
import math
import ntpath
import os
import re
@@ -37,7 +35,6 @@ from .admin import AdminRoutesMixin
from .settings_routes import SettingsRoutesMixin
from .job_routes import JobRoutesMixin
from .image_tag_jobs import ImageTagJobsMixin
from .system import SystemRoutesMixin
from .jobs import JobRegistry
from .logging_routes import LoggingRoutesMixin
from .autotag_routes import AutotagRoutesMixin
@@ -111,20 +108,16 @@ STATIC_MIME_TYPES = {
".html": "text/html",
}
_STATIC_SEPARATORS = re.compile(r"[\\/]+")
# Served by exact request path beside the extension allow-list: the browser's
# copy of the inspection node table (local Inspect resolves with it, #249).
STATIC_EXACT_FILES = {("js", "inspection-semantics.json"): "application/json"}
def _resolve_static_file(root, filepath, allowed=STATIC_MIME_TYPES):
def _resolve_static_file(root, filepath):
"""Real path of ``filepath`` under ``root``, or None when it must not be served.
The request path is untrusted. It is rejected when it is empty, carries a
``..`` segment, is absolute on any platform (a leading slash or backslash,
a drive letter such as ``C:`` or a UNC ``\\\\server\\share`` prefix all
re-anchor ``os.path.join`` on Windows), has an extension outside
``allowed`` (STATIC_MIME_TYPES), or resolves (symlinks included) outside
``root``.
STATIC_MIME_TYPES, or resolves (symlinks included) outside ``root``.
"""
if not isinstance(filepath, str) or not filepath:
return None
@@ -137,7 +130,7 @@ def _resolve_static_file(root, filepath, allowed=STATIC_MIME_TYPES):
segments = _STATIC_SEPARATORS.split(filepath)
if any(segment in ("", ".", "..") for segment in segments):
return None
if os.path.splitext(filepath)[1].lower() not in allowed:
if os.path.splitext(filepath)[1].lower() not in STATIC_MIME_TYPES:
return None
try:
root_real = Path(os.path.normcase(os.path.realpath(root)))
@@ -272,7 +265,6 @@ class PromptManagerAPI(
SettingsRoutesMixin,
JobRoutesMixin,
ImageTagJobsMixin,
SystemRoutesMixin,
LoggingRoutesMixin,
AutotagRoutesMixin,
LoraIntegrationMixin,
@@ -329,11 +321,6 @@ class PromptManagerAPI(
self._scan_job = None
# The current duplicate scan (a py/api/jobs.Job outside the registry).
self._duplicate_job = None
# The last System health report this session and the database
# generation it describes, bumped when a restore replaces self.db
# (py/api/system.py).
self._health_report = None
self._db_generation = 0
# Run cleanup on initialization to remove any existing duplicates
try:
@@ -491,7 +478,6 @@ class PromptManagerAPI(
self._register_admin_routes(routes)
self._register_settings_routes(routes)
self._register_job_routes(routes)
self._register_system_routes(routes)
self._register_logging_routes(routes)
self._register_autotag_routes(routes)
self._register_lora_routes(routes)
@@ -513,20 +499,15 @@ class PromptManagerAPI(
def _serve_static_file(self, subdir, filepath):
"""Serve ``web/<subdir>/<filepath>``; 403 outside it, 404 when missing."""
root = os.path.join(_get_project_root(), "web", subdir)
exact = STATIC_EXACT_FILES.get((subdir, filepath))
# An exact file passes the same containment check, its extension only.
allowed = STATIC_MIME_TYPES if exact is None else {".json"}
file_path = _resolve_static_file(root, filepath, allowed)
file_path = _resolve_static_file(root, filepath)
if file_path is None:
return web.Response(text="Forbidden", status=403)
content_type = (
exact or STATIC_MIME_TYPES[os.path.splitext(file_path)[1].lower()]
)
if not os.path.isfile(file_path):
return web.Response(text="Not Found", status=404)
ext = os.path.splitext(file_path)[1].lower()
with open(file_path, "rb") as f:
content = f.read()
return web.Response(body=content, content_type=content_type)
return web.Response(body=content, content_type=STATIC_MIME_TYPES[ext])
def _serve_html_page(self, filename, title, cache=True):
"""Serve ``web/<filename>``; 404 when missing, 500 when unreadable."""
@@ -610,12 +591,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
@@ -738,13 +719,6 @@ class PromptManagerAPI(
Returns:
List[Path]: Validated directory paths, possibly empty.
"""
return self._output_dirs_with_origin()[0]
def _output_dirs_with_origin(self):
"""``(dirs, auto_detected)``: the ``_get_all_output_dirs`` list, and
whether it comes from detection or ComfyUI's output rather than from
a valid configured root (False when there is no directory at all).
"""
from ..config import GalleryConfig
configured = list(GalleryConfig.MONITORING_DIRECTORIES)
@@ -763,16 +737,15 @@ class PromptManagerAPI(
output_dirs.append(Path(real))
if output_dirs:
return output_dirs, False
return output_dirs
if configured:
# Every configured root was rejected: never let auto-detection
# resurrect it, serve ComfyUI's own output directory instead.
fallback_dirs = self._comfyui_output_dir_list()
else:
fallback = self._find_comfyui_output_dir()
fallback_dirs = [Path(fallback)] if fallback else []
return fallback_dirs, bool(fallback_dirs)
return self._comfyui_output_dir_list()
fallback = self._find_comfyui_output_dir()
return [Path(fallback)] if fallback else []
def _comfyui_output_dir_list(self):
"""ComfyUI's output directory as a one-element list, or [] if unknown."""
+4 -11
View File
@@ -41,7 +41,7 @@ try:
PLACEHOLDERS,
GenerationParamsError,
extract_generation_params,
parse_page_json,
parse_js_json,
)
from ...utils.prompt_pair_resolver import resolve_prompt_pair
except ImportError:
@@ -51,7 +51,7 @@ except ImportError:
PLACEHOLDERS,
GenerationParamsError,
extract_generation_params,
parse_page_json,
parse_js_json,
)
from utils.prompt_pair_resolver import resolve_prompt_pair
@@ -804,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,
@@ -1274,7 +1271,6 @@ class AdminRoutesMixin:
self.logger.info(f"Removed {len(removed)} old safety backups")
previous = self.db
self.db = PromptDatabase(model.db_path)
self._database_replaced() # a health report described the old one
# Its connections would otherwise linger (a file lock on Windows)
previous.close_all()
prompt_count = self.db.model.get_database_info().get("total_prompts", 0)
@@ -1606,14 +1602,11 @@ class AdminRoutesMixin:
@staticmethod
def _graph_prompt_text(metadata):
"""The positive prompt the browser parser traces from the graphs, or None.
Records parse as the legacy pages parsed them (NaN reads as null).
"""
"""The positive prompt the browser parser traces from the graphs, or None."""
records = {}
for key in ("prompt", "workflow"):
value = metadata.get(key)
records[key] = parse_page_json(value) if isinstance(value, str) else value
records[key] = parse_js_json(value) if isinstance(value, str) else value
try:
positive = extract_generation_params(records)["positivePrompt"]
except GenerationParamsError:
+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}")
-3
View File
@@ -88,9 +88,6 @@ DUPLICATE_CLEANUP_JOB_KIND = "duplicate_cleanup"
THUMBNAILS_JOB_KIND = "thumbnails"
LORA_IMPORT_JOB_KIND = "lora_import"
AUTOTAG_BATCH_JOB_KIND = "autotag"
# The System health check (py/api/system.py): a read-only ``read`` job with
# no file outcomes, so it may run beside a scan.
HEALTH_JOB_KIND = "health"
def resolve_admission(admission=None, record_changing=None) -> str:
-220
View File
@@ -1,220 +0,0 @@
"""System routes: the read-only health check, the Overview and usage statistics.
- ``POST /prompt_manager/system/health`` starts the health check
(``database/health.py``) off-loop as a ``read`` job of kind ``health``: one
at a time, beside a scan, not cancellable and with no file outcomes. The
API keeps the last report for this session; ``GET`` returns it or null.
A report describes the database it was taken from: replacing the database
(a restore) forgets it, and a check that started before the swap is not
kept.
- ``GET /prompt_manager/system/overview`` is the System › Overview aggregate.
Every value is real or null: there is no backup status until backups report
one, no health result until a check ran, and no Needs-review count until
the review queue exists. The missing-thumbnail count is not part of it:
the page reads ``/images/thumbnails/status``, which walks every source.
- ``GET /prompt_manager/system/statistics`` lists runs and ratings per
category, with the legacy ``/stats`` totals and the Overview's count of
rated images (images whose linked prompt is rated).
New routes answer errors with ``{success:false, code, error}``.
"""
import asyncio
import os
from collections import namedtuple
from aiohttp import web
from .job_routes import busy_response
from .jobs import HEALTH_JOB_KIND, JobBusy
HEALTH_JOB_TITLE = "Health check"
_HEALTH_STATUS_LINES = {
"passed": "Health check passed",
"issues": "Health check found issues",
"incomplete": "Health check incomplete",
}
# The last health report and the database generation it was taken from.
_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
)
def health_summary(report):
"""What the Overview shows of a report: when, the outcome, checks with issues."""
if report is None:
return None
return {
"checked_at": report["checked_at"],
"status": report["status"],
"issues": sum(1 for check in report["checks"] if check["status"] == "issues"),
}
class SystemRoutesMixin:
"""Mixin providing the /prompt_manager/system endpoints."""
def _register_system_routes(self, routes):
@routes.post("/prompt_manager/system/health")
async def start_health_check_route(request):
return await self.start_health_check(request)
@routes.get("/prompt_manager/system/health")
async def health_report_route(request):
return await self.get_health_report(request)
@routes.get("/prompt_manager/system/overview")
async def system_overview_route(request):
return await self.get_system_overview(request)
@routes.get("/prompt_manager/system/statistics")
async def system_statistics_route(request):
return await self.get_system_statistics(request)
# ── health ────────────────────────────────────────────────────────
def _health_roots(self):
"""The sources missing files are judged under.
The effective sources, plus every configured source that is not an
existing directory (an unmounted drive): the check then reports
missing files as unavailable instead of counting that source's links.
"""
from ..config import GalleryConfig
roots = [str(path) for path in self._get_all_output_dirs()]
for configured in GalleryConfig.MONITORING_DIRECTORIES:
try:
resolved = GalleryConfig.resolve_gallery_root(configured)
except (OSError, TypeError, ValueError):
continue
if not os.path.isdir(resolved):
roots.append(resolved)
return roots
def _database_replaced(self):
"""Forget what described the previous database (call after swapping ``db``)."""
self._db_generation += 1
self._health_report = None
def _current_health_report(self):
"""The last report, or None when none was kept for the current database."""
stored = self._health_report
generation = self._db_generation
if isinstance(stored, _StoredReport) and stored.generation == generation:
return stored.report
return None
async def start_health_check(self, request):
"""Start the health check job (202), or 409 naming the running check."""
generation = self._db_generation
db = self.db
try:
roots = await self._run_in_executor(self._health_roots)
except Exception:
self.logger.exception("Health check could not resolve the image sources")
return _failure("Failed to start the health check")
async def run(job):
job.update(status_line="Checking the database and image links")
report = await self._run_in_executor(db.run_health_checks, roots)
if generation == self._db_generation:
self._health_report = _StoredReport(generation, report)
else:
self.logger.info("Health report dropped: the database was replaced")
job.update(progress=100, status_line=_HEALTH_STATUS_LINES[report["status"]])
try:
job = self._jobs.start(
HEALTH_JOB_KIND,
HEALTH_JOB_TITLE,
admission="read",
cancellable=False,
scope={},
runner=run,
)
except JobBusy as busy:
return busy_response(busy)
return web.json_response({"success": True, "job": job.summary()}, status=202)
async def get_health_report(self, request):
return web.json_response(
{"success": True, "report": self._current_health_report()}
)
# ── overview ──────────────────────────────────────────────────────
async def get_system_overview(self, request):
from ..config import GalleryConfig
try:
(output_dirs, auto_detected), counts = await _gather_settled(
self._run_in_executor(self._output_dirs_with_origin),
self._run_in_executor(self.db.library_counts),
)
except Exception:
self.logger.exception("System overview failed")
return _failure("Failed to read the overview")
return web.json_response(
{
"success": True,
"library": {
"positive_prompts": counts["positive_prompts"],
"negative_variants": counts["negative_variants"],
},
"images": {
"indexed": counts["images"],
"rated": counts["rated_images"],
},
"health": health_summary(self._current_health_report()),
"backup": None, # no verified backup status exists yet (S8)
"needs_review": {"pending": None}, # no review queue yet (Q6)
"sources": {
"configured": len(GalleryConfig.MONITORING_DIRECTORIES),
"effective": len(output_dirs),
"auto_detected": auto_detected,
},
},
headers={"Cache-Control": "no-store"},
)
# ── statistics ────────────────────────────────────────────────────
async def get_system_statistics(self, request):
try:
by_category, totals, counts = await _gather_settled(
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),
)
except Exception:
self.logger.exception("System statistics failed")
return _failure("Failed to read statistics")
return web.json_response(
{
"success": True,
"by_category": by_category,
"totals": totals,
"rated_images": counts["rated_images"],
},
headers={"Cache-Control": "no-store"},
)
-11
View File
@@ -78,17 +78,6 @@ export async function seedConcept(request,data={}) {
return response.json();
}
/**
* Seed the concept fixture and fix the browser's clock at the fixture's own instant (`manifest.now`), so the page and
* the fixture read one "now": a prompt used now reads "Used today" and the caption names the fixture's day even when
* the run crosses midnight between seeding and rendering. Timers keep running; only `Date` stands still.
*/
export async function seedConceptAt(page,request,data={}) {
const manifest=await seedConcept(request,data);
await page.clock.setFixedTime(new Date(manifest.now*1000));
return manifest;
}
/**
* Save `tags` as the current image tag set of the listed file at `path` (root 0) through the real observe and tag
* routes (#249 PR C), releasing the capture afterwards; returns the receipt's saved names.
+2 -5
View File
@@ -237,12 +237,9 @@ const viewerStructure=details=>details.evaluate(node=>{
test('viewer details hold provenance, raw records and downloads',async({page,request})=>{
await page.setViewportSize({width:1600,height:1100});const reads=await patchInspection(page);
await fullFixture(page,request);let details=await viewerDetails(page,'complete.png');
// The panel content without the cover, D117's Copy settings summary after the facts, then the two disclosures,
// then the pair send and history buttons (D20).
// The panel content without the cover, then the two disclosures, then the pair send and history buttons (D20).
expect(await viewerStructure(details)).toEqual({outer:['div','button.send','button'],
inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','div.tags','dl.meta-grid','button.settings-summary','details','details']});
await expect(details.locator(':scope > div > button')).toHaveText('Copy settings summary');
await expect(details.locator(':scope > div > button.settings-summary')).toHaveCSS('white-space','normal');
inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','div.tags','dl.meta-grid','details','details']});
await expect(details.locator('.detail-cover,.detail-inner,.detail-footer')).toHaveCount(0);
await expect(details.locator('.panel-heading h2')).toHaveText(POSITIVE_TITLE);await expect(details.locator('.panel-heading .chip')).toHaveText('PNG');
await expect(details.locator(':scope > div > p.small')).toHaveText('complete.png');
+7 -7
View File
@@ -1,7 +1,7 @@
import {writeFile} from 'node:fs/promises';
import {test,expect} from './fixtures.mjs';
import {captureCanonicalState} from './history-canonical-comparison.mjs';
import {SIZES,THEMES,MOTIONS,seedConceptAt,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact,countCopy,reflow,withoutItems} from './concept-inventory.mjs';
import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact,countCopy,reflow,withoutItems} from './concept-inventory.mjs';
// Task C7 of #249: the production Images page against the immutable concept, on the concept-shaped real fixture (24
// images of the concept's six prompts, its seeds, sizes and favorites). Heading, toolbar, filter row, caption, grid,
@@ -85,7 +85,7 @@ function listInfo(card,actionsWidth) {
/** Recorded Images deviations only (spec §8); everything else in the concept inventory must equal production. */
const nocase=(a,b)=>{const x=a.toLowerCase(),y=b.toLowerCase();return x<y?-1:x>y?1:a<b?-1:a>b?1:0;};
export function mapImagesConcept(reference,{manifest,now=new Date(manifest.now*1000)}) {
export function mapImagesConcept(reference,{manifest,now=new Date()}) {
const paths=new Map(manifest.images.map(image=>[image.filename,image.path]));
// D14: the sort appends production's two sorts. Save view is the concept's own (saved views, #249 D16 retired).
const toolbar={...reference.toolbar};
@@ -318,7 +318,7 @@ async function behaviorsOf(page,scope) {
return {hover,opened,viewerTitle,focusAfterViewer,selected,layouts,search,single};
}
test.beforeEach(async({page,request},testInfo)=>{testInfo.manifest=await seedConceptAt(page,request);});
test.beforeEach(async({request},testInfo)=>{testInfo.manifest=await seedConcept(request);});
for(const [width,height] of SIZES)for(const theme of THEMES)test(`images page geometry, tokens, controls and behaviors match the concept ${width}x${height} ${theme}`,async({page,request},testInfo)=>{
test.setTimeout(240_000);
@@ -348,7 +348,7 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`images page ge
importDiff:diffInventory(mapImportConcept(referenceImport),actualImport),referenceBehaviors,actualBehaviors};
}
// Load more in production: a fixture past one listing page appends one batch and shows the concept's toast.
testInfo.manifest=await seedConceptAt(page,request,{extra_images:LOAD_MORE_EXTRA});
testInfo.manifest=await seedConcept(request,{extra_images:LOAD_MORE_EXTRA});
for(const motion of MOTIONS) {
const total=FIXTURE_IMAGES+LOAD_MORE_EXTRA;
await openProductionLoaded(page,theme,motion,total);
@@ -514,7 +514,7 @@ test('one appended image reads in the singular',async({page,request})=>{
test.setTimeout(120_000);
await page.setViewportSize({width:1600,height:1100});
const total=PRODUCTION_BATCH+1;
await seedConceptAt(page,request,{extra_images:total-FIXTURE_IMAGES});
await seedConcept(request,{extra_images:total-FIXTURE_IMAGES});
await openProductionLoaded(page,'dark','no-preference',total);
await expect(page.locator('.infinite-continuation > p').first()).toHaveText(countCopy(`${PRODUCTION_BATCH} of ${total} images · more load as you scroll`));
// A dispatched press: scrolling the button into view lets the page's own observer load the one image first.
@@ -530,7 +530,7 @@ test('one image appended by scrolling reads in the singular',async({page,request
test.setTimeout(120_000);
await page.setViewportSize({width:1600,height:1100});
const total=PRODUCTION_BATCH+1;
await seedConceptAt(page,request,{extra_images:total-FIXTURE_IMAGES});
await seedConcept(request,{extra_images:total-FIXTURE_IMAGES});
await openProductionLoaded(page,'dark','no-preference',total);
await expect(page.locator('#result-count')).toHaveText(new RegExp(`^${PRODUCTION_BATCH} of ${total} images`));
await page.locator('.infinite-continuation').scrollIntoViewIfNeeded();
@@ -551,7 +551,7 @@ test('one footer height for the collection (D3)',async({page,request})=>{
const reference=await inventoryOf(page,'#main');
expect(reference.grid.columns).toBe(2);
expect(reference.cards.every(card=>card.info.p.height>parseFloat(card.info.p.lineHeight))).toBe(true);
const manifest=await seedConceptAt(page,request,{seedless_images:2,short_seedless:true});
const manifest=await seedConcept(request,{seedless_images:2,short_seedless:true});
await openProductionLoaded(page,'dark','no-preference',FIXTURE_IMAGES+2);
const actual=await inventoryOf(page,'#workspace-main');
const box=card=>({left:card.left,top:card.top,width:card.width,height:card.height});
+5 -10
View File
@@ -117,7 +117,7 @@ function measureViewer() {
['dl.meta-grid', node => node.closest('details') ? null : 'facts'],
['details > summary', node => text(node)],
['button.send', node => node.closest('.prompt-section') ? `send ${node.closest('.prompt-section').matches('.positive') ? 'positive' : 'negative'}` : 'send pair'],
['button', node => /Prompt history/.test(node.textContent) ? 'Prompt history' : text(node) === 'Copy settings summary' ? 'settings summary' : null],
['button', node => /Prompt history/.test(node.textContent) ? 'Prompt history' : null],
];
const sections = [...rail.querySelectorAll('*')].map(node => {
for (const [selector, label] of SECTIONS) if (node.matches(selector)) return label(node);
@@ -158,18 +158,13 @@ async function withProductionCopy(page) {
}, { COPY, ACCEPT });
}
/**
* D66: the viewer of a local file is titled by its filename and has no tag chips and no Prompt history. D117 (LI-4):
* "Copy settings summary" follows the six facts.
*/
/** D66: the viewer of a local file is titled by its filename and has no tag chips and no Prompt history. */
function mapViewerConcept(reference) {
const sections = reference.sections.filter(section => section !== 'tag chips' && section !== 'Prompt history');
const facts = sections.indexOf('facts');
return {
...reference,
title: FILE,
railTitle: FILE,
sections: [...sections.slice(0, facts + 1), 'settings summary', ...sections.slice(facts + 1)],
sections: reference.sections.filter(section => section !== 'tag chips' && section !== 'Prompt history'),
};
}
@@ -274,9 +269,9 @@ for (const [width, height] of SIZES) for (const theme of THEMES) {
// The chooser names the chosen file after a real setInputFiles, as the concept's does.
expect(production.initial.chosen).toEqual({ name: '', files: [] });
expect(production.selected.chosen).toEqual({ name: FILE, files: [FILE] });
// D66 in the rail: no tag chips and no Prompt history; the title is the filename. D117 follows the facts.
// D66 in the rail: no tag chips and no Prompt history; the title is the filename.
expect(production.viewer.sections).toEqual(['heading', 'filename', 'badges', 'positive', 'send positive', 'negative',
'send negative', 'facts', 'settings summary', 'File & provenance', 'Workflow & raw metadata', 'send pair']);
'send negative', 'facts', 'File & provenance', 'Workflow & raw metadata', 'send pair']);
});
}
+7 -19
View File
@@ -45,10 +45,10 @@ function withCategoryScope(inventory) {
}
/**
* Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34, D38 and D117); everything else must
* equal production. `reference.viewerPlain` is the concept's own full details
* with its tag chips made plain (D20), D117's summary button added and, under a coarse pointer, D34's targets, all at
* runtime: the browser lays out what follows them, as the Images spec's removed toolbar items do.
* Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34 and D38); everything else must equal
* production. `reference.viewerPlain` is the concept's own full details
* with its tag chips made plain (D20) and, under a coarse pointer, D34's targets, both at runtime: the browser lays out
* what follows them, as the Images spec's removed toolbar items do.
*/
export function mapInspectorConcept(reference,{viewport}) {
const {panel,viewerPlain,fileControls}=reference;
@@ -105,11 +105,7 @@ async function openProductionPanel(page,theme) {
await settle(page);
}
/**
* The concept's tag chips in the open viewer as plain `span.chip`s (D20), D34's coarse targets and D117's "Copy
* settings summary" after the six facts (LI-4), drawn as the concept's own plain button (its "Download sample JSON",
* without its action); returns an undo.
*/
/** The concept's tag chips in the open viewer as plain `span.chip`s (D20) and D34's coarse targets; returns an undo. */
async function plainViewerChips(page) {
const restoreTargets=await mutate(page,{css:D34_COARSE_TARGETS});
await page.evaluate(()=>{
@@ -117,15 +113,10 @@ async function plainViewerChips(page) {
const span=document.createElement('span');span.className='chip';span.textContent=button.textContent;
button.replaceWith(span);return [span,button];
});
const summary=document.querySelector('#viewer-details [data-action="download-workflow"]').cloneNode(false);
for(const key of Object.keys(summary.dataset))delete summary.dataset[key];
summary.textContent='Copy settings summary';summary.classList.add('settings-summary');
document.querySelector('#viewer-details > div > dl.meta-grid').after(summary);window.__summaryButton=summary;
});
await settle(page);
return async()=>{
await page.evaluate(()=>{for(const [span,button] of window.__plainChips)span.replaceWith(button);delete window.__plainChips;
window.__summaryButton.remove();delete window.__summaryButton;});
await page.evaluate(()=>{for(const [span,button] of window.__plainChips)span.replaceWith(button);delete window.__plainChips;});
await restoreTargets();
};
}
@@ -351,7 +342,7 @@ for(const [width,height] of SIZES)test(`image tags saved on the concept image ke
// The comparison is only worth something if it notices a change (spec §7 mutation check): on the panel one CSS
// mutation (the role label's letter spacing) and one DOM mutation (the footer removed); in the viewer (final review m5)
// its pair actions removed, D38's Show in folder removed and D117's summary removed; each applied after an exact match.
// its pair actions removed and D38's Show in folder removed; each applied after an exact match.
test('inspector comparison detects mutations',async({page})=>{
test.setTimeout(120_000);
const viewport={width:1600,height:1100};
@@ -375,9 +366,6 @@ test('inspector comparison detects mutations',async({page})=>{
expect(()=>assertInspectorMatch(reference,withoutActions,viewport)).toThrow(/viewerDetails\.actions/);
const withoutReveal=await viewerMutated({remove:['#viewer-details button[aria-label$=" in folder"]']});
expect(()=>assertInspectorMatch(reference,withoutReveal,viewport)).toThrow(/fileControls/);
// D117: the summary action after the facts is part of the mapped concept, so losing it is a difference.
const withoutSummary=await viewerMutated({remove:['#viewer-details > div > button']});
expect(()=>assertInspectorMatch(reference,withoutSummary,viewport)).toThrow(/viewerDetails\.structure\.inner/);
assertInspectorMatch(reference,await viewerMutated(),viewport);
await mutate(page,{remove:['#image-inspector > .detail-footer']});
+3 -26
View File
@@ -1,7 +1,7 @@
import {writeFile} from 'node:fs/promises';
import {test,expect} from './fixtures.mjs';
import {captureCanonicalState} from './history-canonical-comparison.mjs';
import {SIZES,THEMES,MOTIONS,seedConceptAt,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact,countCopy,reflow} from './concept-inventory.mjs';
import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact,countCopy,reflow} from './concept-inventory.mjs';
// Task E3 of #249: the production Prompt library shelf against the immutable concept (app.js renderLibrary a572–586,
// style.css s648–708), on the concept-shaped real fixture (the concept's six prompts with their negatives, categories,
@@ -37,8 +37,7 @@ export function mapLibraryConcept(reference) {
// D43: tag chips come in the store's alphabetical order; the row flows again from the concept's own chips.
tags:sortedTags(card.tags),
// D23: the stars are aria-hidden, with a visually hidden "Rated N out of 5" spoken in their place. The fixture's
// run_count (4) and last_used_at (its own "now", where the page's clock stands) give the concept's own "4 runs"
// and "Used today". The two-line clamp is
// run_count (4) and last_used_at (now) give the concept's own "4 runs" and "Used today". The two-line clamp is
// applied below through `clampedCards`, measured on the concept with `CLAMP_CSS` (its samples wrap at 390px).
footer:{...card.footer,facts:{...card.footer.facts,spoken:card.footer.facts.spoken.replace(/^(★+)/,stars=>`Rated ${stars.length} out of 5`)}}}));
return {...reference,library:{...library,toolbar,
@@ -239,8 +238,7 @@ async function openHost(context) {
}
test.beforeEach(async({page,request},testInfo)=>{
// The page's clock stands at the fixture's own "now", so its last use reads "Used today" on either side of midnight.
testInfo.manifest=await seedConceptAt(page,request);
testInfo.manifest=await seedConcept(request);
// Both documents copy through the clipboard; the stub records what they wrote.
await page.addInitScript(()=>{window.copied=[];Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>{window.copied.push(text);}}});});
});
@@ -324,27 +322,6 @@ for(const theme of THEMES)test(`library grid and cards match the concept 1800x11
expect(compare(reference,actual,{sections:['library'],library:['grid','cards','trailing']})).toEqual([]);
});
// PR #284's CI run: the fixture recorded the last use at 23:59:5x UTC and the shelf rendered at 00:00:05, so production
// read "Used yesterday" against the concept's "Used today". Here the browser's wall clock starts at 23:59:58 UTC, the
// fixture records its use at that instant, and midnight passes before either document opens.
const BEFORE_MIDNIGHT=Date.UTC(2026,9,22,23,59,58);
const PAST_MIDNIGHT_MS=5_000;
test('a run that crosses midnight still reads the concept fixture\'s day',async({page,request})=>{
await page.setViewportSize({width:1800,height:1100});
await page.clock.install({time:BEFORE_MIDNIGHT});
const manifest=await seedConceptAt(page,request,{now:BEFORE_MIDNIGHT/1000});
expect(manifest.now*1000,'the fixture\'s now').toBe(BEFORE_MIDNIGHT);
const used=(await (await request.get('/prompt_manager/library')).json()).items.map(item=>Date.parse(item.last_used_at));
expect(used,'every last use at the fixture\'s now').toEqual(Array(CONCEPT_PROMPTS).fill(BEFORE_MIDNIGHT));
await page.clock.fastForward(PAST_MIDNIGHT_MS);
await openConceptLibrary(page,'dark','no-preference');
const reference=await conceptInventory(page);
await openProductionLibrary(page,'dark','no-preference');
const actual=await inventoryOf(page,'#workspace-main');
expect(actual.library.cards.map(card=>card.footer.facts.text.replace(/^★+ · /,''))).toEqual(Array(CONCEPT_PROMPTS).fill('Used today'));
expect(compare(reference,actual,{sections:['library'],library:['grid','cards','trailing']})).toEqual([]);
});
// The comparison is only worth something if it notices a change (spec §7 mutation check): one CSS mutation (the footer
// padding) and one DOM mutation (the negative-variants chip removed), each applied after an exact match.
test('library comparison detects mutations',async({page})=>{
+1 -16
View File
@@ -19,13 +19,10 @@ from PIL import Image
from PIL.PngImagePlugin import PngInfo
from tests.browser.request_hold import register_request_holds
from tests.browser import slow_reset_watch
from tests.browser.workspace_activity_fixture import register_activity_commands
from tests.browser.workspace_system_fixture import register_system_commands
from tests.browser.workspace_concept_fixture import (
MAX_EXTRA_IMAGES as CONCEPT_MAX_EXTRA_IMAGES,
MAX_SEEDLESS_IMAGES as CONCEPT_MAX_SEEDLESS_IMAGES,
MAX_EPOCH_SECONDS as CONCEPT_MAX_EPOCH_SECONDS,
seed_workspace_concept,
)
from tests.browser.workspace_visual_fixture import seed_workspace_visual
@@ -199,9 +196,7 @@ def create_app(root: Path) -> web.Application:
register_tagging_commands(routes, api, root, model)
reveal_stub = register_reveal_stub(routes, api, root)
register_activity_commands(app, routes, api)
register_system_commands(app, routes, api, root)
request_holds = register_request_holds(app, routes)
slow_reset_watch.install(app, api)
async def canonical_asset(request: web.Request) -> web.Response:
filename, content_type = CANONICAL_ASSETS[request.path]
@@ -494,17 +489,8 @@ def create_app(root: Path) -> web.Application:
if (
request.query
or not isinstance(data, dict)
or set(data) - set(limits) - {"short_seedless", "now"}
or set(data) - set(limits) - {"short_seedless"}
or not isinstance(data.get("short_seedless", False), bool)
# A chosen "now" (epoch seconds) pins every time the fixture writes.
or (
"now" in data
and (
isinstance(data["now"], bool)
or not isinstance(data["now"], (int, float))
or not 0 < data["now"] < CONCEPT_MAX_EPOCH_SECONDS
)
)
or any(
isinstance(data.get(key, 0), bool)
or not isinstance(data.get(key, 0), int)
@@ -524,7 +510,6 @@ def create_app(root: Path) -> web.Application:
extra_images=data.get("extra_images", 0),
seedless_images=data.get("seedless_images", 0),
short_seedless=data.get("short_seedless", False),
now=data.get("now"),
)
)
return web.json_response({"success": True, **manifest})
-237
View File
@@ -1,237 +0,0 @@
"""Report a slow test reset with what the server was doing while it waited.
A reset normally answers within about a second. Once one runs past
SLOW_RESET_SECONDS, this writes to stderr, while the reset is still waiting:
- every thread's stack, so a reset waiting on a lock, a full executor or a
slow disk shows where it waits and who holds it;
- the default executor's threads and queued work;
- how many database connections are open, and the WAL size;
- on Linux, each thread's scheduler state and the kernel function it sleeps
in (``D`` with a journal or writeback wait is a slow disk flush, a nanosleep
is SQLite's busy handler, a futex is a Python lock or an idle worker).
When the reset ends it writes how long it took and, for each executor call it
made, how long the call queued and how long it ran.
Every report line starts with REPORT_PREFIX. The worker fixture
(tools/vendor/start-browser-server.mjs) copies those lines to the job log,
so a slow reset is visible even in a test that still passes.
"""
import asyncio
import contextvars
import faulthandler
import os
import sys
import tempfile
import threading
import time
from typing import Callable, TextIO
from aiohttp import web
SLOW_RESET_SECONDS = 2.0
REPORT_PREFIX = "pm-slow-reset| "
_current: contextvars.ContextVar["ResetWatch | None"] = contextvars.ContextVar(
"pm_reset_watch", default=None
)
def is_reset_path(path: str) -> bool:
return path.startswith("/__test__/") and path.endswith("/reset")
class ResetWatch:
"""Time one reset; dump the server's state if it passes the threshold."""
def __init__(
self,
label: str,
describe: Callable[[], list[str]],
*,
threshold: float = SLOW_RESET_SECONDS,
stream: TextIO | None = None,
) -> None:
self.label = label
self.threshold = threshold
self._describe = describe
self._stream = stream
self._start = time.monotonic()
self._calls: list[str] = []
self._calls_lock = threading.Lock()
self._stalled = threading.Event()
self._timer = threading.Timer(threshold, self._report_stall)
self._timer.daemon = True
self._timer.name = "pm-slow-reset-watch"
def start(self) -> "ResetWatch":
self._timer.start()
return self
def elapsed(self) -> float:
return time.monotonic() - self._start
def record_call(self, name: str, queued: float, ran: float) -> None:
with self._calls_lock:
self._calls.append(
f"executor call {name}: queued {queued:.3f}s, ran {ran:.3f}s"
)
def finish(self, outcome: str) -> None:
self._timer.cancel()
elapsed = self.elapsed()
# A coarse clock (Windows) can fire the timer just short of the
# threshold; a reset that was reported always gets its summary.
if elapsed < self.threshold and not self._stalled.is_set():
return
with self._calls_lock:
calls = list(self._calls)
self._write([f"{self.label} {outcome} after {elapsed:.3f}s", *calls])
def _report_stall(self) -> None:
self._stalled.set()
lines = [f"{self.label} still running after {self.elapsed():.3f}s"]
try:
lines.extend(self._describe())
except Exception as error: # a diagnostic never breaks the reset
lines.append(f"state unavailable: {error!r}")
lines.extend(thread_stacks())
self._write(lines)
def _write(self, lines: list[str]) -> None:
stream = self._stream or sys.stderr
text = "".join(
f"{REPORT_PREFIX}{part}\n" for line in lines for part in line.splitlines()
)
try:
stream.write(text)
stream.flush()
except (OSError, ValueError):
pass
def thread_stacks() -> list[str]:
"""Every thread's stack, from faulthandler.
Formatting sys._current_frames() showed a thread blocked in SQLite as idle
(Python 3.14); faulthandler reports where each thread really is.
"""
# Before Python 3.14 faulthandler prints ids only; name each id here.
names = [
f"thread 0x{thread.ident:016x} is {thread.name}"
for thread in threading.enumerate()
if thread.ident is not None
]
with tempfile.TemporaryFile() as dump:
faulthandler.dump_traceback(file=dump, all_threads=True)
dump.seek(0)
return [*names, *dump.read().decode("utf-8", "replace").splitlines()]
def executor_state(loop: asyncio.AbstractEventLoop) -> list[str]:
executor = getattr(loop, "_default_executor", None)
if executor is None:
return ["default executor: not started"]
threads = getattr(executor, "_threads", ())
queue = getattr(executor, "_work_queue", None)
queued = queue.qsize() if queue is not None else "?"
return [
f"default executor: {len(threads)} of "
f"{getattr(executor, '_max_workers', '?')} threads, {queued} queued"
]
def database_state(model) -> list[str]:
"""How many connections are open, and the WAL size.
Never touches another thread's connection: a connection busy in SQLite
holds its mutex, and reading even ``in_transaction`` from here would block
on it while holding the GIL, freezing every thread until that query ends.
"""
with model._conn_lock:
count = len(model._connections)
wal = model.db_path + "-wal"
size = os.path.getsize(wal) if os.path.exists(wal) else 0
return [f"database: {count} connections, WAL {size} bytes"]
def kernel_wait_state() -> list[str]:
"""Each thread's state and kernel wait channel, where /proc has them."""
lines = []
for thread in threading.enumerate():
task = f"/proc/self/task/{thread.native_id}"
try:
with open(f"{task}/stat", encoding="ascii") as stat:
state = stat.read().rsplit(")", 1)[1].split()[0]
with open(f"{task}/wchan", encoding="ascii") as wchan:
channel = wchan.read().strip() or "0"
except (OSError, IndexError):
continue
# The id matches the "Thread 0x..." headers of the stacks below.
lines.append(
f"kernel: {thread.name} (0x{thread.ident:016x}) state {state}, "
f"waiting in {channel}"
)
return lines
def install(app: web.Application, api) -> None:
"""Watch every reset route, and time the executor calls each one makes."""
run_in_executor = api._run_in_executor
async def timed_run_in_executor(func, *args, **kwargs):
watch = _current.get()
if watch is None:
return await run_in_executor(func, *args, **kwargs)
queued_at = time.monotonic()
started: list[float] = []
def timed(*call_args, **call_kwargs):
started.append(time.monotonic())
return func(*call_args, **call_kwargs)
try:
return await run_in_executor(timed, *args, **kwargs)
finally:
end = time.monotonic()
begin = started[0] if started else end
watch.record_call(
getattr(func, "__qualname__", repr(func)),
begin - queued_at,
end - begin,
)
api._run_in_executor = timed_run_in_executor
@web.middleware
async def watch_resets(request: web.Request, handler):
if not is_reset_path(request.path):
return await handler(request)
loop = asyncio.get_running_loop()
def describe() -> list[str]:
return [
*executor_state(loop),
*database_state(api.db.model),
*kernel_wait_state(),
]
watch = ResetWatch(
f"{request.method} {request.path}", describe, threshold=SLOW_RESET_SECONDS
).start()
token = _current.set(watch)
outcome = "failed"
try:
response = await handler(request)
outcome = f"answered {response.status}"
return response
except web.HTTPException as error:
outcome = f"answered {error.status}"
raise
finally:
_current.reset(token)
watch.finish(outcome)
app.middlewares.append(watch_resets)
-535
View File
@@ -1,535 +0,0 @@
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
import { SIZES, THEMES, seedConcept, openConcept, openProduction, diffInventory, mutate } from './concept-inventory.mjs';
// #249 System S2 Task 6: System's shell and its Overview section (app.js renderSystem 658–706), the Statistics dialog
// ("Usage statistics", a1600–1604) and the health report (a1439–1456) against the immutable concept, on the
// concept-shaped real fixture. Each concept measurement is mapped only by the recorded deviations (D92–D96, D121,
// D122, D123), each commented where it applies; after that the difference must be empty, and a mutation of a
// row, a label or a token must be noticed.
test.use({ locale: 'en-US', timezoneId: 'UTC' });
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.conceptErrors = [];
page.on('pageerror', error => page.conceptErrors.push(error.message));
});
test.afterEach(async ({ page, request, browserName }) => {
await saveCoverage(page, browserName);
await request.post('/__test__/system/reset');
expect(page.conceptErrors).toEqual([]);
});
/** Runs in the page; self-contained. System's heading, section nav, Overview body and Save row under `mainSelector`. */
function measureSystem(mainSelector) {
const main = document.querySelector(mainSelector);
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 box = (node, origin = main) => {
const rect = node.getBoundingClientRect(), base = origin.getBoundingClientRect();
return { left: Math.round(rect.left - base.left), width: Math.round(rect.width) };
};
// A surface's top: from `.two-col` beside its neighbour, else from the bottom of the surface above it. This pins the
// concept's `.surface + .surface` 18px offset, which concept.css applies inside `.two-col` too (copied, not ruled).
const top = node => {
const rect = node.getBoundingClientRect(), previous = node.previousElementSibling?.getBoundingClientRect();
if (previous && rect.top >= previous.bottom) return Math.round(rect.top - previous.bottom);
return Math.round(rect.top - node.parentElement.getBoundingClientRect().top);
};
const text = node => node?.textContent.trim() ?? null;
const button = node => ({ text: text(node), className: node.className,
...css(node, 'backgroundColor', 'color', 'fontSize', 'paddingTop', 'paddingLeft', 'borderTopWidth', 'borderTopColor',
'borderTopLeftRadius', 'opacity', 'cursor') });
const heading = main.querySelector(':scope > .heading');
const nav = main.querySelector(':scope > .section-nav');
const layout = main.querySelector(':scope > .settings-layout');
const metrics = layout.querySelector(':scope > .summary-metrics');
const columns = layout.querySelector(':scope > .two-col');
const save = layout.querySelector(':scope > .save-row');
const firstMetric = metrics.querySelector('.metric');
const firstSurface = columns.querySelector('.surface');
const firstOperation = firstSurface.querySelector('.operation');
return {
tree: [...main.children].map(name),
heading: {
title: text(heading.querySelector('h1')), description: text(heading.querySelector('p.muted')),
actions: [...(heading.querySelector('.heading-actions')?.children ?? [])].map(name),
},
nav: {
role: nav.getAttribute('role'), label: nav.getAttribute('aria-label'),
...css(nav, 'display', 'flexWrap', 'columnGap', 'rowGap', 'paddingBottom', 'marginBottom', 'borderBottomWidth', 'borderBottomColor'),
box: box(nav),
buttons: [...nav.children].map(item => ({ pressed: item.getAttribute('aria-pressed'), ...button(item) })),
},
layout: { tree: [...layout.children].map(name), ...css(layout, 'maxWidth'), box: box(layout) },
metrics: {
...css(metrics, 'display', 'gridTemplateColumns', 'columnGap', 'rowGap', 'marginBottom'), box: box(metrics),
items: [...metrics.children].map(item => ({ tree: [...item.children].map(name), value: text(item.querySelector('strong')),
label: text(item.querySelector('span')), box: box(item) })),
metric: css(firstMetric, 'paddingTop', 'paddingLeft', 'borderTopWidth', 'borderTopColor', 'borderTopLeftRadius', 'backgroundColor'),
strong: css(firstMetric.querySelector('strong'), 'display', 'fontSize', 'fontWeight', 'color'),
span: css(firstMetric.querySelector('span'), 'fontSize', 'color', 'marginTop'),
},
columns: {
...css(columns, 'display', 'gridTemplateColumns', 'columnGap', 'rowGap'), box: box(columns),
surfaces: [...columns.children].map(surface => ({ tree: [...surface.children].map(name), box: box(surface), top: top(surface),
heading: text(surface.querySelector(':scope > h2')),
rows: [...surface.querySelectorAll(':scope > .operation')].map(row => ({ tree: [...row.children].map(name),
title: text(row.querySelector('h3')), description: text(row.querySelector('p')),
action: button(row.querySelector('.row-actions > button')) })) })),
surface: css(firstSurface, 'paddingTop', 'paddingLeft', 'backgroundColor', 'borderTopWidth', 'borderTopColor', 'borderTopLeftRadius'),
h2: css(firstSurface.querySelector('h2'), 'fontSize', 'fontWeight', 'color', 'marginBottom'),
operation: css(firstOperation, 'display', 'alignItems', 'justifyContent', 'columnGap', 'paddingTop', 'paddingBottom',
'borderTopWidth', 'borderTopColor'),
h3: css(firstOperation.querySelector('h3'), 'fontSize', 'fontWeight', 'color'),
p: css(firstOperation.querySelector('p'), 'fontSize', 'color', 'marginTop'),
actions: css(firstOperation.querySelector('.row-actions'), 'display', 'columnGap', 'flexShrink'),
},
save: {
tree: [...save.children].map(name), ...css(save, 'display', 'alignItems', 'justifyContent', 'columnGap', 'marginTop', 'paddingTop'),
status: { text: text(save.querySelector('#settings-status')), ...css(save.querySelector('#settings-status'), 'fontSize', 'color') },
button: button(save.querySelector('button')),
},
pageScroll: document.documentElement.scrollWidth > document.documentElement.clientWidth,
};
}
/** Runs in the page: the open dialog's title, body sequence, tables, operation rows and actions. */
function measureDialog() {
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 body = dialog.querySelector('#modal-body');
const firstCell = selector => body.querySelector(selector);
const list = body.querySelector(':scope > .operation-list');
const actions = body.querySelector(':scope > .dialog-actions');
return {
title: dialog.querySelector('#modal-title').textContent,
body: [...body.children].map(name),
tables: [...body.querySelectorAll(':scope > table')].map(table => ({
caption: table.caption?.textContent ?? null,
head: [...table.tHead.rows[0].cells].map(cell => [cell.localName, cell.scope, cell.textContent]),
rows: [...table.tBodies[0].rows].map(row => [...row.cells].map(cell => cell.textContent)),
})),
tableStyle: firstCell(':scope > table') && {
table: css(firstCell(':scope > table'), 'width', 'borderCollapse', 'fontSize', 'marginTop'),
caption: css(firstCell(':scope > table caption'), 'textAlign', 'fontSize', 'fontWeight', 'color', 'paddingBottom'),
th: css(firstCell(':scope > table thead th'), 'textAlign', 'fontSize', 'fontWeight', 'color', 'paddingTop', 'paddingLeft',
'borderBottomWidth', 'borderBottomColor'),
td: css(firstCell(':scope > table tbody td'), 'textAlign', 'fontSize', 'color', 'paddingTop', 'paddingLeft',
'borderBottomWidth', 'borderBottomColor'),
},
operations: list && {
...css(list, 'display', 'rowGap', 'marginTop'),
rows: [...list.children].map(row => ({ tree: [...row.children].map(name), title: row.querySelector('h3').textContent,
chip: row.querySelector('.chip').textContent })),
operation: css(list.querySelector('.operation'), 'display', 'alignItems', 'justifyContent', 'paddingTop', 'borderTopWidth'),
h3: css(list.querySelector('.operation h3'), 'fontSize', 'fontWeight', 'color'),
chip: css(list.querySelector('.operation .chip'), 'fontSize', 'color', 'backgroundColor', 'borderTopLeftRadius', 'paddingLeft'),
},
actions: actions && {
...css(actions, 'display', 'justifyContent', 'columnGap', 'marginTop'),
buttons: [...actions.children].map(item => ({ text: item.textContent, className: item.className,
...css(item, 'backgroundColor', 'color', 'fontSize', 'paddingTop', 'paddingLeft') })),
},
};
}
/** Runs in the page: a `.notice` (Maintenance's "Check first…") with its copy. */
function measureNotice(mainSelector) {
const notice = document.querySelector(`${mainSelector} .settings-layout > .notice`);
const css = (node, ...keys) => Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]]));
const name = node => [node.localName, ...[...node.classList].sort()].join('.');
const run = notice.querySelector(':scope > button');
return {
tree: [...notice.children].map(name), text: [...notice.querySelectorAll(':scope > div > *')].map(node => [node.localName, node.textContent]),
frame: css(notice, 'display', 'alignItems', 'justifyContent', 'columnGap', 'borderTopWidth', 'borderTopColor', 'backgroundColor',
'borderTopLeftRadius', 'paddingTop', 'paddingLeft', 'marginBottom'),
h2: css(notice.querySelector('h2'), 'fontSize', 'fontWeight', 'color'),
p: css(notice.querySelector('p'), 'fontSize', 'color', 'marginTop'),
run: { text: run.textContent, className: run.className, ...css(run, 'backgroundColor', 'color', 'fontSize', 'paddingTop', 'paddingLeft') },
};
}
async function serverJson(request, url) {
const response = await request.get(url);
await expect(response).toBeOK();
return response.json();
}
const number = value => value.toLocaleString('en-US');
const ratingText = value => value === null ? 'Not rated' : `${number(value)} / 5`;
/** The concept's shell and Overview with the recorded deviations applied, given the server's real overview. */
function mapOverviewConcept(reference, overview) {
const relabel = { 'Database integrity': 'Not checked', 'Positive prompts': number(overview.library.positive_prompts),
'Negative variants': number(overview.library.negative_variants), Images: number(overview.images.indexed) };
const rows = {
// D93 (Q6): no review queue yet, so no count and the action is unavailable (dimmed, as the Save row's D92).
'3 ambiguous image links': row => ({ ...row, title: 'Ambiguous image links', description: 'Not available yet.',
action: { ...row.action, opacity: '0.55', cursor: 'not-allowed' } }),
// D93: no backup has been recorded (verified backups arrive with S8).
'Backup status': row => ({ ...row, description: 'No retained backup record.' }),
// D93: there is no paging, so the shared settings are grid and density.
'One display configuration': row => ({ ...row, description: 'Grid and density settings are shared across views.' }),
// D93: the real sources, not samples.
'2 sample output sources': row => ({ ...row, title: `${number(overview.sources.effective)} output sources` }),
// D96: the dialog shows runs and ratings by category and library totals, not prompt, tag or date usage.
Statistics: row => ({ ...row, description: 'Runs and ratings by category, with library totals.' }),
};
return {
...reference,
// D92: no "Sample configuration" chip.
heading: { ...reference.heading, actions: [] },
// D122: the concept's literal label is its prototype action name ("system-tab sections").
nav: { ...reference.nav, label: 'System sections' },
metrics: { ...reference.metrics, items: reference.metrics.items.map(item => ({ ...item, value: relabel[item.label] })) },
columns: { ...reference.columns, surfaces: reference.columns.surfaces.map(surface => ({ ...surface,
rows: surface.rows.map(row => (rows[row.title] ?? (value => value))(row)) })) },
save: { ...reference.save,
// D92: truthful status copy, and "Save preferences" is aria-disabled (dimmed) while nothing is unsaved.
status: { ...reference.save.status, text: 'No unsaved changes.' },
button: { ...reference.save.button, opacity: '0.55', cursor: 'not-allowed' } },
};
}
async function conceptOverview(page, theme) {
await openConcept(page, 'system', theme);
await expect(page.locator('#main .summary-metrics')).toBeVisible();
await page.mouse.move(0, 0);
return page.evaluate(measureSystem, '#main');
}
async function productionOverview(page, theme) {
await openProduction(page, 'system/overview', theme);
await expect(page.locator('#workspace-main .summary-metrics')).toBeVisible();
// The thumbnail row is its own read (I2): compare once it answered.
await expect(page.locator('#workspace-main .operation h3').filter({ hasText: 'thumbnails' })).toHaveText('No missing thumbnails');
await page.mouse.move(0, 0);
return page.evaluate(measureSystem, '#workspace-main');
}
for (const theme of THEMES) for (const [width, height] of SIZES) {
test(`system shell and overview match the concept ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => {
await page.setViewportSize({ width, height });
const overview = await serverJson(request, '/prompt_manager/system/overview');
// The fixture's thumbnails all exist, so the concept's "No missing thumbnails" is also the real title.
expect((await serverJson(request, '/prompt_manager/images/thumbnails/status')).pending).toBe(0);
const concept = await conceptOverview(page, theme);
expect(concept.heading.actions).toEqual(['span.chip']);
// Paired captures are artifacts only; there are no PNG baselines.
await page.screenshot({ path: testInfo.outputPath(`concept-system-${theme}-${width}.png`), fullPage: true });
const production = await productionOverview(page, theme);
await page.screenshot({ path: testInfo.outputPath(`production-system-${theme}-${width}.png`), fullPage: true });
expect(diffInventory(mapOverviewConcept(concept, overview), production)).toEqual([]);
expect(production.pageScroll).toBe(false);
});
}
// The recorded differences, by path: D93 the review row (title, Not available yet, dimmed action), the backup row, the
// preferences row and the sources row, and metric 4; D96 the statistics row; D92 the chip, the status and the dimmed
// Save; D122 the nav label. (The fixture's first three metrics are the concept's own counts.)
const UNMAPPED_PATHS = [
'columns.surfaces.0.rows.0.action.cursor', 'columns.surfaces.0.rows.0.action.opacity',
'columns.surfaces.0.rows.0.description', 'columns.surfaces.0.rows.0.title', 'columns.surfaces.0.rows.1.description',
'columns.surfaces.1.rows.0.description', 'columns.surfaces.1.rows.1.title', 'columns.surfaces.1.rows.2.description',
'heading.actions.0', 'heading.actions.length', 'metrics.items.3.value', 'nav.label',
'save.button.cursor', 'save.button.opacity', 'save.status.text',
];
test('system comparison detects mutations', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const overview = await serverJson(request, '/prompt_manager/system/overview');
const concept = await conceptOverview(page, 'dark');
const expected = mapOverviewConcept(concept, overview);
const production = await productionOverview(page, 'dark');
expect(diffInventory(expected, production)).toEqual([]);
// Unmapped, the concept differs exactly where a deviation is recorded, and nowhere else.
expect(diffInventory(concept, production).map(item => item.path).sort()).toEqual(UNMAPPED_PATHS);
const measure = () => page.evaluate(measureSystem, '#workspace-main');
const paths = async () => diffInventory(expected, await measure()).map(item => item.path);
// A removed row.
const undoRow = await mutate(page, { remove: ['#workspace-main .two-col > .surface:first-child > .operation:nth-of-type(2)'] });
expect(await paths()).toEqual(expect.arrayContaining(['columns.surfaces.0.rows.length', 'columns.surfaces.0.tree.length']));
await undoRow();
// A changed label.
await page.evaluate(() => { document.querySelector('#workspace-main .metric span').textContent = 'Positive prompt'; });
expect(await paths()).toEqual(['metrics.items.0.label']);
await page.evaluate(() => { document.querySelector('#workspace-main .metric span').textContent = 'Positive prompts'; });
// A swapped token.
const undoToken = await mutate(page, { css: '#workspace-main .settings-layout { --line: rgb(255, 0, 0); }' });
expect(await paths()).toEqual(expect.arrayContaining(['metrics.metric.borderTopColor', 'columns.surface.borderTopColor']));
await undoToken();
expect(diffInventory(expected, await measure())).toEqual([]);
});
/** D96: the concept's table, its rows the real ones; then "Library totals" in the same table markup. */
function mapStatisticsConcept(reference, statistics) {
const [runs] = reference.tables;
return {
...reference,
// No sample note (prototype copy); a second table follows the first.
body: ['table', 'table'],
tables: [
{ ...runs, rows: statistics.by_category.map(row => [row.category ?? 'Uncategorized', number(row.runs), ratingText(row.average_rating)]) },
{ caption: 'Library totals', head: [['th', 'col', 'Total'], ['th', 'col', 'Count']],
rows: [['Prompts', number(statistics.totals.total_prompts)], ['Tags', number(statistics.totals.total_tags)],
['Images', number(statistics.totals.total_images)], ['Rated images', number(statistics.rated_images)]] },
],
};
}
// The fixture's image sources and each check's real chip on it: nothing is damaged, Needs review does not exist yet (Q6).
const FIXTURE_SOURCES = ['output', 'input'];
const FIXTURE_CHIPS = Object.freeze({ 'Integrity check': 'Passed', 'Foreign keys & pair references': 'Passed',
'Canonical hashes': 'Passed', 'Pair uniqueness': 'Passed', 'Missing files': 'Passed', 'Ambiguous metadata': 'Not available yet',
Consistency: 'Passed' });
const chipOf = title => {
if (!(title in FIXTURE_CHIPS)) throw new Error(`No expected chip for ${title}`);
return FIXTURE_CHIPS[title];
};
/**
* D95: the concept's health report from its own five rows, labels verbatim, titled "Health report". Only the D95 rows
* are inserted where they are recorded (Pair uniqueness after Canonical hashes, Consistency last); Missing files has a
* row per image source when there are several; each chip is the fixture's expected result.
*/
function mapHealthConcept(reference, sources = FIXTURE_SOURCES) {
const [first] = reference.operations.rows;
const added = title => ({ ...first, title, chip: chipOf(title) });
const rows = reference.operations.rows.flatMap(row => {
const mapped = row.title === 'Missing files' && sources.length > 1
? sources.map(source => ({ ...row, title: `Missing files · ${source}`, chip: chipOf(row.title) }))
: [{ ...row, chip: chipOf(row.title) }];
return row.title === 'Canonical hashes' ? [...mapped, added('Pair uniqueness')] : mapped;
});
return {
...reference,
title: 'Health report',
// No sample note (prototype copy); D95 adds the checked line after the list.
body: ['div.operation-list', 'p.small', 'div.dialog-actions'],
operations: { ...reference.operations, rows: [...rows, added('Consistency')] },
};
}
// Unmapped, the health report differs exactly where D95 is recorded: the title, the body sequence (no sample note, the
// checked line), the rows after Canonical hashes (Pair uniqueness inserted, Missing files per source) and the chips of
// the concept's sample findings.
const UNMAPPED_HEALTH_PATHS = [
'body.0', 'body.1',
...[3, 4].flatMap(row => [`operations.rows.${row}.chip`, `operations.rows.${row}.title`]),
// The concept's five rows end at row 4; rows 5–7 exist only with the D95 rows and the second source.
...[5, 6, 7].flatMap(row => ['chip', 'title', 'tree.0', 'tree.1', 'tree.length'].map(key => `operations.rows.${row}.${key}`)),
'operations.rows.length', 'title',
];
async function conceptDialogs(page, theme, testInfo) {
await openConcept(page, 'system', theme);
await page.locator('#main').getByRole('button', { name: 'Explore', exact: true }).click();
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Usage statistics');
await page.mouse.move(0, 0);
const statistics = await page.evaluate(measureDialog);
await page.screenshot({ path: testInfo.outputPath(`concept-statistics-${theme}.png`) });
await page.keyboard.press('Escape');
await page.locator('#main .section-nav').getByRole('button', { name: 'Maintenance', exact: true }).click();
const notice = await page.evaluate(measureNotice, '#main');
await page.locator('#main .notice').getByRole('button', { name: 'Run health checks', exact: true }).click();
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Sample health report');
await page.mouse.move(0, 0);
await page.screenshot({ path: testInfo.outputPath(`concept-health-${theme}.png`) });
return { statistics, notice, health: await page.evaluate(measureDialog) };
}
async function productionDialogs(page, theme, testInfo) {
await openProduction(page, 'system/overview', theme);
await page.locator('#workspace-main').getByRole('button', { name: 'Explore', exact: true }).click();
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Usage statistics');
await page.mouse.move(0, 0);
const statistics = await page.evaluate(measureDialog);
await page.screenshot({ path: testInfo.outputPath(`production-statistics-${theme}.png`) });
await page.keyboard.press('Escape');
await page.locator('#workspace-main .section-nav').getByRole('button', { name: 'Maintenance', exact: true }).click();
await expect(page.locator('#workspace-main .notice')).toBeVisible();
const notice = await page.evaluate(measureNotice, '#workspace-main');
await page.locator('#workspace-main .notice').getByRole('button', { name: 'Run health checks', exact: true }).click();
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Health report');
await page.mouse.move(0, 0);
await page.screenshot({ path: testInfo.outputPath(`production-health-${theme}.png`) });
return { statistics, notice, health: await page.evaluate(measureDialog) };
}
for (const theme of THEMES) test(`statistics and health dialogs match the concept ${theme}`, async ({ page, request }, testInfo) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const concept = await conceptDialogs(page, theme, testInfo);
expect(concept.statistics.body).toEqual(['p.demo-note', 'table']);
expect(concept.health.body).toEqual(['p.demo-note', 'div.operation-list', 'div.dialog-actions']);
const production = await productionDialogs(page, theme, testInfo);
const statistics = await serverJson(request, '/prompt_manager/system/statistics');
const { report } = await serverJson(request, '/prompt_manager/system/health');
// The fixture the expected chips describe: its two sources, and the concept's checks plus the D95 ones.
const missing = report.checks.find(check => check.id === 'missing_files');
expect(missing.sources.map(source => source.name)).toEqual(FIXTURE_SOURCES);
expect(diffInventory(mapStatisticsConcept(concept.statistics, statistics), production.statistics)).toEqual([]);
expect(diffInventory(mapHealthConcept(concept.health), production.health)).toEqual([]);
// D123: Maintenance's notice is the concept's own, delivered ahead of the rest of the section.
expect(diffInventory(concept.notice, production.notice)).toEqual([]);
// Unmapped, the dialogs differ only where D95 and D96 are recorded.
expect(diffInventory(concept.health, production.health).map(item => item.path).sort()).toEqual(UNMAPPED_HEALTH_PATHS);
expect(diffInventory(concept.statistics, production.statistics).map(item => item.path.split('.')[0])
.filter((part, index, all) => all.indexOf(part) === index).sort()).toEqual(['body', 'tables']);
// Mutation check: a changed chip token in the health report is noticed, then undone to an exact match again.
const undo = await mutate(page, { css: '#modal .operation .chip { background: rgb(255, 0, 0); }' });
expect(diffInventory(mapHealthConcept(concept.health), await page.evaluate(measureDialog)).map(item => item.path))
.toEqual(['operations.chip.backgroundColor']);
await undo();
expect(diffInventory(mapHealthConcept(concept.health), await page.evaluate(measureDialog))).toEqual([]);
// A renamed check (database/health.py's label) is noticed against the concept's own label.
await page.evaluate(() => { document.querySelector('#modal .operation h3').textContent = 'Integrity checks'; });
expect(diffInventory(mapHealthConcept(concept.health), await page.evaluate(measureDialog)).map(item => item.path))
.toEqual(['operations.rows.0.title']);
await page.evaluate(() => { document.querySelector('#modal .operation h3').textContent = 'Integrity check'; });
// And a removed "Library totals" table and a changed caption in the statistics dialog.
await page.keyboard.press('Escape');
await page.locator('#workspace-main .section-nav').getByRole('button', { name: 'Overview', exact: true }).click();
await page.locator('#workspace-main').getByRole('button', { name: 'Explore', exact: true }).click();
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Usage statistics');
const expectedStatistics = mapStatisticsConcept(concept.statistics, statistics);
const undoTable = await mutate(page, { remove: ['#modal-body > table:nth-of-type(2)'] });
expect(diffInventory(expectedStatistics, await page.evaluate(measureDialog)).map(item => item.path))
.toEqual(expect.arrayContaining(['body.length', 'tables.length']));
await undoTable();
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([]);
});
+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);
});
+9 -82
View File
@@ -2,13 +2,6 @@ import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage
import { test, expect } from './fixtures.mjs';
import { seedConcept, openConcept, openProduction } from './concept-inventory.mjs';
// The Activity store's periodic list read (activity-store.mjs POLL_MS) and the detail view's read throttle
// (activity-job-view.mjs DETAIL_INTERVAL_MS).
const POLL_MS = 15_000;
const DETAIL_INTERVAL_MS = 1_000;
// A request is reported to the test a little after the browser sends it; that delay is not a shorter wait.
const REPORT_SLACK_MS = 100;
// Activity (#249 Activity-1): the concept's Activity page (app.js renderActivity 732–738), its top-bar button
// (index.html 51–52) and sidebar row (app.js ~341), with the recorded D52 (counts of running jobs), D53 (the progress
// pill) and D54 (real job items). Fake jobs come from tests/browser/workspace_activity_fixture.py.
@@ -284,22 +277,7 @@ 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);
await page.setViewportSize({ width: 1600, height: 1100 });
const job = await startJob(request);
await step(request, job.id, { processed: 4000 });
@@ -316,12 +294,9 @@ test('lost server clears the pill and marks jobs ended', async ({ page, request
await expect(items(page).first().locator('p')).toHaveText(LOST);
await expect(items(page).first().locator('progress, .row-actions')).toHaveCount(0);
await expect(navRow(page).locator('.nav-count')).toHaveCount(0);
// One re-read found the restart; there is no retry storm and no finish toast for a job that only vanished. The
// read after it is the periodic one, a whole poll interval later: any extra read lands in that gap and fails it.
const found = reads.length;
expect(found, 'reads that found the restart').toBeGreaterThanOrEqual(1);
await expect.poll(() => reads.length, { timeout: POLL_MS + 10_000 }).toBeGreaterThan(found);
expect(reads[found] - reads[found - 1], 'the next read waits for the poll').toBeGreaterThanOrEqual(POLL_MS - REPORT_SLACK_MS);
// One re-read found the restart; there is no retry storm and no finish toast for a job that only vanished.
await page.waitForTimeout(2500);
expect(reads.length).toBeLessThanOrEqual(2);
await expect(toast(page)).toHaveText('');
// A server that cannot be read at all also ends the running job, then is read again with a growing backoff.
@@ -333,12 +308,9 @@ test('lost server clears the pill and marks jobs ended', async ({ page, request
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
await expect(pill(page)).toBeHidden();
await expect(items(page).filter({ hasText: LOST })).toHaveCount(1);
// 1 s, then 2 s, then 4 s: each failed re-read waits twice as long as the one before. The gaps between the
// reads themselves are the evidence, so a slow runner noticing the ended job late cannot add a read to a window.
await expect.poll(() => failed.length, { timeout: 20_000 }).toBeGreaterThanOrEqual(3);
const gaps = failed.slice(1, 3).map((at, index) => at - failed[index]);
expect(gaps[0]).toBeGreaterThanOrEqual(2000 - REPORT_SLACK_MS);
expect(gaps[1]).toBeGreaterThanOrEqual(4000 - REPORT_SLACK_MS);
await page.waitForTimeout(3500);
// 1 s, then 2 s: at most two re-reads in 3.5 s.
expect(failed.length).toBeLessThanOrEqual(2);
await page.unroute('**/prompt_manager/jobs**');
expect(second.id).not.toBe(job.id);
});
@@ -635,8 +607,6 @@ test('filter narrows the selected outcome', async ({ page, request }) => {
test('job view renders 200-row cap note', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
// The page's timers are Playwright's, running at real speed, so the M12 check below can run a read throttle out.
await page.clock.install();
const job = await startJob(request);
await step(request, job.id, { processed: 260, bulk: { group: 'added', count: 250 }, records: [
...Array.from({ length: 10 }, (_, index) => ({ group: 'no_metadata', path: `misc/plain_${index}.png`, detail: 'No prompt metadata in the file' })),
@@ -676,15 +646,11 @@ test('job view renders 200-row cap note', async ({ page, request }) => {
page.on('request', item => { if (new URL(item.url()).pathname === `/prompt_manager/jobs/${job.id}`) reads.push(Date.now()); });
await step(request, job.id, { processed: 300, bulk: { group: 'added', count: 40 } });
await expect(tiles(page).first().locator('dd')).toHaveText('290');
// Every read the counter update could have scheduled is due within one throttle interval: run it out, so any such
// read is sent before the log opens. Requests reach the test in the order they were sent, so once the log's read
// has answered, a read sent before it would already be listed.
await page.clock.runFor(DETAIL_INTERVAL_MS);
await page.waitForTimeout(1500);
expect(reads).toEqual([]);
await scanLog(page).locator('summary').click();
await expect.poll(() => reads.length).toBe(1);
await expect(scanLog(page).locator('pre')).toContainText('bulk/file_00039.png');
expect(reads, 'only the log\'s own read').toHaveLength(1);
});
test('cancel scan ends cancelled with partial counts', async ({ page, request }) => {
@@ -737,55 +703,16 @@ 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.
await page.clock.install();
const running = await startJob(request, { title: 'Database maintenance', kind: 'maintenance' });
await startFromImport(page);
await expect(toast(page)).toHaveText('Another job is running: Database maintenance.');
await expect(toast(page).getByRole('link', { name: 'Database maintenance', exact: true })).toHaveAttribute('href', `#/activity/${running.id}`);
await expect(page).toHaveURL(/#\/images$/);
expect((await listed(request)).map(item => item.id)).toEqual([running.id]);
// Review M14: a toast with a link outlasts the plain 6.5 s, so the link stays reachable. Running the page's timers
// 7 s on fires a 6.5 s clear for certain, however slowly this runner reached this line.
await page.clock.runFor(7000);
// Review M14: a toast with a link outlasts the plain 6.5 s, so the link stays reachable.
await page.waitForTimeout(7000);
await expect(toast(page).getByRole('link', { name: 'Database maintenance', exact: true })).toBeVisible();
await toast(page).getByRole('link', { name: 'Database maintenance', exact: true }).click();
await expect(heading(page).locator('h1')).toHaveText('Database maintenance');
-17
View File
@@ -494,23 +494,6 @@ test('the inline current file names its prompts apart from the frozen run fields
expect(await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'))).toBe(before);
});
// LI-4 (D117): the recorded history's current-file viewer is the shared full inspector, so it copies the legacy summary.
test('the current file viewer copies its settings summary',async({page})=>{
await page.addInitScript(()=>{window.__copied=[];Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>{window.__copied.push(text);}}});});
await page.route(url=>url.pathname.endsWith('/inspection'),async route=>{const response=await route.fetch();const body=await response.json();
body.prompts={positive:{status:'resolved',text:'current file positive',source:'graph-inferred'},negative:{status:'resolved',text:'current file negative',source:'graph-inferred'}};
await route.fulfill({response,json:body});});
await open(page,page.fixture.detail_id);await marker(page,page.fixture.runs.shared).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();
const details=page.locator('dialog#viewer-dialog #viewer-details');await expect(details.locator('.panel-heading')).toBeVisible();
const facts=await details.locator(':scope > div > dl.meta-grid dd').allTextContents();
await details.getByRole('button',{name:'Copy settings summary',exact:true}).click();
await expect(page.locator('#workspace-toast')).toHaveText('Settings summary copied.');
const [seed,steps,cfg,sampler,,model]=facts;
expect(await page.evaluate(()=>window.__copied)).toEqual([[`Checkpoint: ${model}`,'Positive Prompt: current file positive','Negative Prompt: current file negative',
`Steps: ${steps}`,`CFG Scale: ${cfg}`,`Sampler: ${sampler}`,`Seed: ${seed}`].join('\n').replace(/: (Not recorded|Unresolved|Ambiguous|Unavailable within inspection limits)(?=\n|$)/g,': Unknown')]);
});
test('the recorded run dialog with a resolved current file has no duplicate button or region names',async({page})=>{
await page.route(url=>url.pathname.endsWith('/inspection'),async route=>{const response=await route.fetch();const body=await response.json();
body.prompts={positive:{status:'resolved',text:'current file positive',source:'graph-inferred'},negative:{status:'resolved',text:'current file negative',source:'graph-inferred'}};
-48
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();
@@ -951,10 +907,6 @@ test('browser Back closes the open Images dialog and leaves the next page usable
test('count line shows images and positive prompts', async ({ page, request }) => {
await page.addInitScript(() => { window.IntersectionObserver = undefined; });
// D17's "Today" is the newest file's day: the page's clock stands at that file's mtime, so the fixture's day and
// the page's agree on either side of midnight.
const { images } = await (await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();
await page.clock.setFixedTime(new Date(Math.max(...images.map(image => image.modified_time)) * 1000));
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
await expect(caption(page).locator('#result-count')).toHaveText('2 of 2 images · 2 positive prompts');
+19 -189
View File
@@ -207,40 +207,6 @@ test('chooser opens the shared full inspector without a second read', async ({ p
expect(await page.evaluate(() => window.__slices)).toBe(0);
});
// PR L3 Task 10: local roles are the server inspector's (local-resolver.mjs), so for every parity PNG the rail shows the
// prompts, their status lines and the facts Images shows for the same bytes in the output folder.
const PARITY_PNGS = ['f01b', 'f02', 'f03', 'f06a', 'f06b', 'f06c', 'f06d', 'f06e', 'f07a', 'f07b', 'f08', 'f09', 'f10', 'f11', 'f12',
'f14', 'f15', 'f16', 'f17', 'f19', 'f20', 'f21', 'f22', 'f23', 'f24'];
const roleSections = locator => locator.evaluate(node => [...node.querySelectorAll('.prompt-section')].map(item => item.textContent));
test('chooser shows the Images roles and resolution for every parity PNG', async ({ page, request }) => {
test.slow();
for (const name of PARITY_PNGS) {
await expect(await request.post('/__test__/add-local-inspect-file', { data: { name: `${name}.png` } })).toBeOK();
}
const served = {};
await page.goto(`${workspace}#/images`);
for (const name of PARITY_PNGS) {
await page.locator(`article[data-image-key="0:local-inspect/${name}.png"] .image-open`).click();
await expect(viewer(page).locator('#viewer-title')).toHaveText(`${name}.png`);
await expect(rail(page).locator('.panel-heading')).toBeVisible();
await expect(rail(page).locator('.prompt-section')).not.toHaveCount(0);
served[name] = { content: await railContent(rail(page)), roles: await roleSections(rail(page)) };
await page.keyboard.press('Escape');
await expect(viewer(page)).toHaveCount(0);
}
await openInspect(page);
for (const name of PARITY_PNGS) {
await choose(page, parityPng(`${name}.png`));
await openViewer(page);
await expect(viewer(page).locator('#viewer-title')).toHaveText(`${name}.png`);
await expect(rail(page).locator('.prompt-section')).not.toHaveCount(0);
expect({ content: await railContent(rail(page)), roles: await roleSections(rail(page)) }, name).toEqual(served[name]);
await page.keyboard.press('Escape');
await expect(viewer(page)).toHaveCount(0);
}
});
test('Import → Inspect a local image lands here', async ({ page }) => {
await page.goto(`${workspace}#/images`);
await page.getByRole('button', { name: 'Import', exact: true }).click();
@@ -515,13 +481,15 @@ test('replacing a video that just started loading never blocks the page', async
});
test('a video replaced while still starting is reset once it settles', async ({ context, browserName }) => {
test.setTimeout(180000);
// The video is replaced from its own `loadstart` (readyState HAVE_NOTHING), or a few milliseconds after it. On
// WebKit (GStreamer) a video that left the page or the viewport before its pipeline had started deadlocked the
// page (TESTING.md D62), so it stays in place, inert and still playing its URL, with the new preview after it.
// Once it reports metadata or an error it is paused, loses its src, is reset (load() with no src, which frees its
// decoder) and leaves the page, and only then is its URL revoked. Fresh pages share one browser process.
for (let attempt = 0; attempt < 15; attempt++) {
// A WebKit (GStreamer) video released exactly at its `loadstart` sometimes blocks the page, with or without any
// reset and also with the code before the deferred reset (2 of 24 runs); tracked in TESTING.md (D62). WebKit's
// no-hang guard is `replacing a video that just started loading never blocks the page`.
test.skip(browserName === 'webkit', 'WebKit release at loadstart is a tracked follow-up (D62)');
test.setTimeout(120000);
// The video is replaced from its own `loadstart` (readyState HAVE_NOTHING). It leaves the page with no src before
// its URL is revoked, and is reset (load() with no src, which frees its decoder) only once it reports metadata or
// an error, so the page never blocks. Fresh pages share one browser process.
for (let attempt = 0; attempt < 5; attempt++) {
const page = await context.newPage();
try {
if (attempt === 0) await startCoverage(page, browserName);
@@ -542,12 +510,10 @@ test('a video replaced while still starting is reset once it settles', async ({
const video = slot.querySelector('video');
if (!video) return;
observer.disconnect();
const replace = () => {
video.addEventListener('loadstart', () => {
window.__starting = { node: video, url: video.getAttribute('src'), readyState: video.readyState };
input.files = window.__pngFiles; input.dispatchEvent(new Event('change', { bubbles: true }));
window.__starting.replaced = { connected: video.isConnected, src: video.hasAttribute('src'), inert: video.inert, resets: video.__resets ?? 0 };
};
video.addEventListener('loadstart', () => { if (window.__delay) setTimeout(replace, window.__delay); else replace(); }, { once: true });
}, { once: true });
}).observe(slot, { childList: true });
});
const png = parityPng('f02.png');
@@ -556,17 +522,13 @@ test('a video replaced while still starting is reset once it settles', async ({
transfer.items.add(new File([new Uint8Array(bytes)], 'f02.png', { type: 'image/png' }));
window.__pngFiles = transfer.files;
}, [...png.buffer]);
await page.evaluate(delay => { window.__delay = delay; }, attempt % 5);
await choose(page, localFixture('core-tags.webm', 'video/webm'));
await expect(preview(page).locator('img.local-file-preview')).toBeVisible({ timeout: 8000 });
await expect(preview(page).locator('video')).toHaveCount(0);
// Replaced while starting (always so at `loadstart` itself): kept in place and untouched. A video that had
// settled is released at once. Either way it is reset once, and its URL goes last.
await expect.poll(() => page.evaluate(() => window.__starting.revoked ?? null)).toEqual({ src: false, connected: false, resets: 1 });
const { readyState, replaced } = await page.evaluate(() => ({ readyState: window.__starting.readyState, replaced: window.__starting.replaced }));
if (attempt % 5 === 0) expect(readyState).toBe(0);
expect(replaced).toEqual(readyState === 0 ? { connected: true, src: true, inert: true, resets: 0 } : { connected: false, src: false, inert: false, resets: 1 });
expect(await page.evaluate(() => window.__starting.node.__resets)).toBe(1);
// Released while starting, and never reset synchronously: the URL went once the node had no src.
expect(await page.evaluate(() => ({ readyState: window.__starting.readyState, revoked: window.__starting.revoked })))
.toEqual({ readyState: 0, revoked: { src: false, connected: false, resets: 0 } });
await expect.poll(() => page.evaluate(() => window.__starting.node.__resets ?? 0), { timeout: 15000 }).toBe(1);
// The page still answers.
await chooser(page).setInputFiles(parityPng('f02.png', 'after.png'), { timeout: 8000 });
await expect(preview(page).locator('img.local-file-preview')).toBeVisible({ timeout: 8000 });
@@ -577,54 +539,6 @@ test('a video replaced while still starting is reset once it settles', async ({
}
});
test('leaving for another route while a video starts waits for it to settle', async ({ context, browserName }) => {
test.setTimeout(180000);
// A WebKit (GStreamer) video detached before its pipeline had started deadlocked the page (TESTING.md D62); a
// route change detaches the whole page, so the shell renders the next route once the video reports metadata or an
// error. The route changes from the video's own `loadstart`, or a few milliseconds after it.
for (let attempt = 0; attempt < 15; attempt++) {
const page = await context.newPage();
try {
if (attempt === 0) await startCoverage(page, browserName);
await page.addInitScript(() => {
const load = HTMLMediaElement.prototype.load;
HTMLMediaElement.prototype.load = function () {
if (!this.hasAttribute('src') && window.__starting?.node === this) window.__starting.reset = { readyState: this.readyState, failed: this.error !== null, connected: this.isConnected };
return load.call(this);
};
});
await openInspect(page);
await page.evaluate(delay => {
const slot = document.querySelector('#local-preview');
new MutationObserver((records, observer) => {
const video = slot.querySelector('video');
if (!video) return;
observer.disconnect();
const leave = () => {
window.__starting = { node: video, readyState: video.readyState };
location.hash = '#/overview';
};
video.addEventListener('loadstart', () => { if (delay) setTimeout(leave, delay); else leave(); }, { once: true });
}).observe(slot, { childList: true });
}, attempt % 5);
await choose(page, localFixture('core-tags.webm', 'video/webm'));
await expect(main(page).getByRole('heading', { level: 1, name: 'Overview', exact: true })).toBeVisible({ timeout: 8000 });
const { readyState, reset } = await page.evaluate(() => ({ readyState: window.__starting.readyState, reset: window.__starting.reset }));
if (attempt % 5 === 0) expect(readyState).toBe(0);
// The video was reset and left the page only once it had settled.
expect(reset.readyState >= 1 || reset.failed).toBe(true);
expect(reset.connected).toBe(true);
// The page still answers.
await page.locator('aside.sidebar nav').getByRole('link', { name: 'Inspect a file', exact: true }).click();
await chooser(page).setInputFiles(parityPng('f02.png'), { timeout: 8000 });
await expect(preview(page).locator('img.local-file-preview')).toBeVisible({ timeout: 8000 });
if (attempt === 0) await saveCoverage(page, browserName);
} finally {
await page.close();
}
}
});
test('closing the viewer on a video that just started never blocks the page', async ({ context, browserName }) => {
test.setTimeout(180000);
// The viewer's video teardown resets its element; on WebKit a reset of a still-starting pipeline blocked the page.
@@ -738,19 +652,13 @@ test('local roles send exactly', async ({ page, context }) => {
test('local inspection makes no content request or storage write', async ({ page }) => {
await instrument(page);
const requests = [];
page.on('request', request => requests.push({ url: request.url(), method: request.method(), body: request.postData() }));
page.on('websocket', socket => requests.push({ url: socket.url(), method: 'WS', body: null }));
const table = /\/prompt_manager\/js\/inspection-semantics\.json$/;
const tableRead = page.waitForResponse(response => table.test(response.url()));
await openInspect(page);
// The one static file Inspect reads, its node table, is fetched as the page mounts: before any selection.
expect((await tableRead).ok()).toBe(true);
// The shell's own reads (counts, Activity) settle before the baseline.
await expect(page.locator('aside.sidebar nav [data-route="images"] .nav-count')).toBeAttached();
const baseline = (await urls(page)).writes.length;
const before = requests.length;
expect(requests.filter(entry => table.test(entry.url))).toHaveLength(1);
const requests = [];
page.on('request', request => requests.push({ url: request.url(), method: request.method(), body: request.postData() }));
page.on('websocket', socket => requests.push({ url: socket.url(), method: 'WS', body: null }));
for (const file of [sendablePng('private-name.png'), localFixture('core-tags.webm', 'video/webm', 'private-clip.webm')]) {
await choose(page, file);
await openViewer(page);
@@ -758,8 +666,7 @@ test('local inspection makes no content request or storage write', async ({ page
await page.keyboard.press('Escape');
await expect(viewer(page)).toHaveCount(0);
}
const network = requests.slice(before).filter(entry => !entry.url.startsWith('blob:') && !entry.url.startsWith('data:'));
expect(requests.filter(entry => table.test(entry.url)), 'the table is read once per page').toHaveLength(1);
const network = requests.filter(entry => !entry.url.startsWith('blob:') && !entry.url.startsWith('data:'));
expect(network.filter(entry => /private|lighthouse|fox/.test(`${entry.url} ${entry.body ?? ''}`))).toEqual([]);
expect(network.filter(entry => entry.method !== 'GET')).toEqual([]);
expect(network.filter(entry => !/\/prompt_manager\/(activity|images\/output|library|vocabulary)/.test(entry.url))).toEqual([]);
@@ -778,80 +685,3 @@ test('touch layout at 390 has no horizontal overflow at 200% zoom', async ({ pag
expect(area).toBe(true);
}
});
// LI-4 (D117): "Copy settings summary" after the six facts is the legacy metadata page's COPY ALL, in the shared full
// inspector, so a file served in Images and the same bytes chosen here copy the same summary of what the rail shows.
/** Records clipboard writes in the page, or (`clipboard: false`) leaves the page without a clipboard. */
async function stubClipboard(page, { clipboard = true } = {}) {
await page.addInitScript(available => {
window.__copied = [];
Object.defineProperty(navigator, 'clipboard', { configurable: true,
value: available ? { writeText: async text => { window.__copied.push(text); } } : undefined });
}, clipboard);
}
/** The summary the rail's own prompts and facts make, in the legacy COPY ALL lines and placeholders. */
const expectedSummary = locator => locator.evaluate(node => {
const facts = Object.fromEntries([...node.querySelectorAll(':scope .meta-grid:not(details .meta-grid) > div')]
.map(item => [item.querySelector('dt').textContent, item.querySelector('dd').textContent]));
// A confirmed prompt has its Copy action; an explicitly empty one takes the legacy placeholder too.
const prompt = role => {
const text = node.querySelector(`.prompt-section.${role} [data-copy-role]`) ? node.querySelector(`.prompt-section.${role} p.prompt-text`).textContent : null;
return text === `No ${role} prompt (explicitly empty)` ? null : text;
};
return [`Checkpoint: ${facts.Model}`, `Positive Prompt: ${prompt('positive') ?? 'No prompt found'}`,
`Negative Prompt: ${prompt('negative') ?? 'No negative prompt found'}`, `Steps: ${facts.Steps}`, `CFG Scale: ${facts.CFG}`,
`Sampler: ${facts.Sampler}`, `Seed: ${facts.Seed}`].join('\n');
});
async function copySummary(page) {
const button = rail(page).getByRole('button', { name: 'Copy settings summary', exact: true });
// It follows the six facts, before "File & provenance".
expect(await button.evaluate(node => [node.previousElementSibling?.localName, node.nextElementSibling?.localName])).toEqual(['dl', 'details']);
await button.click();
}
test('Copy settings summary copies the same legacy summary in Images and Inspect', async ({ page, request }) => {
await stubClipboard(page);
await expect(await request.post('/__test__/add-local-inspect-file', { data: { name: 'f02.png' } })).toBeOK();
await page.goto(`${workspace}#/images`);
await page.locator('article[data-image-key="0:local-inspect/f02.png"] .image-open').click();
await expect(rail(page).locator('.prompt-section.positive p.prompt-text')).toBeVisible();
const served = await expectedSummary(rail(page));
expect(served).toMatch(/^Checkpoint: .+\nPositive Prompt: [\s\S]+\nNegative Prompt: [\s\S]+\nSteps: .+\nCFG Scale: .+\nSampler: .+\nSeed: .+$/);
expect(served).not.toContain('Unknown');
await copySummary(page);
await expect(page.locator('#workspace-toast')).toHaveText('Settings summary copied.');
expect(await page.evaluate(() => window.__copied)).toEqual([served]);
await page.keyboard.press('Escape');
await expect(viewer(page)).toHaveCount(0);
await openInspect(page);
await choose(page, parityPng('f02.png'));
await openViewer(page);
expect(await expectedSummary(rail(page))).toBe(served);
await copySummary(page);
await expect.poll(() => page.evaluate(() => window.__copied)).toEqual([served, served]);
// The panel variant (the Images side panel) keeps the concept's footer: no summary action there.
await page.keyboard.press('Escape');
await page.goto(`${workspace}#/images`);
await expect(page.locator('#image-inspector .panel-heading')).toBeVisible();
await expect(page.locator('#image-inspector').getByRole('button', { name: 'Copy settings summary', exact: true })).toHaveCount(0);
});
test('Copy settings summary without a clipboard opens the select-to-copy dialog', async ({ page }) => {
await stubClipboard(page, { clipboard: false });
await openInspect(page);
await choose(page, sendablePng());
await openViewer(page);
const summary = await expectedSummary(rail(page));
expect(summary).toContain(`Positive Prompt: ${POSITIVE_LINES}`);
await copySummary(page);
const fallback = page.getByRole('dialog', { name: 'Copy settings summary', exact: true });
await expect(fallback).toBeVisible();
await expect(fallback.getByRole('textbox', { name: 'Select and copy this text', exact: true })).toHaveValue(summary);
await expect(fallback.getByRole('textbox', { name: 'Select and copy this text', exact: true })).toBeFocused();
await page.keyboard.press('Escape');
await expect(fallback).toHaveCount(0);
// Escape closes only the copy dialog; focus returns to the action in the still-open viewer.
await expect(viewer(page)).toBeVisible();
await expect(rail(page).getByRole('button', { name: 'Copy settings summary', exact: true })).toBeFocused();
});
+1 -3
View File
@@ -610,9 +610,7 @@ test('card matches the concept and never wraps the category mid-word',async({pag
await expect(await request.post('/prompt_manager/bulk/category',{data:{prompt_ids:[dual.id],category:''}})).toBeOK();
// A long saved title fills the title row, so only the chip's own min-content keeps "Uncategorized" and "asian" whole.
const wide='A remarkably long saved positive prompt title that fills the whole card title row';
// One "now" for the patched last use and the page's clock, so it reads "Used today" on either side of midnight.
const usedAt=new Date();await page.clock.setFixedTime(usedAt);
await patchShelf(page,item=>item.id===cat.id?{run_count:4,last_used_at:usedAt.toISOString(),rating:5}:item.id===dual.id||item.id===dog.id?{title:wide,run_count:1,last_used_at:null,rating:null}:{});
await patchShelf(page,item=>item.id===cat.id?{run_count:4,last_used_at:new Date().toISOString(),rating:5}:item.id===dual.id||item.id===dog.id?{title:wide,run_count:1,last_used_at:null,rating:null}:{});
for(const [width,height] of [[1600,1100],[390,844]]) {
await page.setViewportSize({width,height});await page.goto(route);await expect(cards(page)).toHaveCount(3);
const card=page.locator(`article[data-prompt-id="${cat.id}"]`);
-617
View File
@@ -1,617 +0,0 @@
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
import { seedConcept } from './concept-inventory.mjs';
// System › Overview (#249 System S2 Task 6) on the concept-shaped real fixture: the metrics, rows, Statistics dialog and
// Health report read the server, never sample values (D93, D95, D96). The hooks are workspace_system_fixture.py's.
const workspace = '/prompt_manager/workspace';
const OVERVIEW_URL = '/prompt_manager/system/overview';
const THUMBNAILS_URL = '/prompt_manager/images/thumbnails/status';
const number = value => value.toLocaleString('en-US');
test.beforeEach(async ({ page, request, browserName }) => {
await startCoverage(page, browserName);
page.manifest = 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__/system/reset');
expect(page.systemErrors).toEqual([]);
});
async function serverJson(request, url) {
const response = await request.get(url);
await expect(response).toBeOK();
return response.json();
}
const layoutOf = page => page.locator('#workspace-main .settings-layout');
async function openOverview(page) {
await page.goto(`${workspace}#/system/overview`);
await expect(layoutOf(page).locator(':scope > .summary-metrics')).toBeVisible();
return layoutOf(page);
}
/** The rows of one Overview surface: `[h3, p, button]`. */
function rowsOf(layout, heading) {
return layout.locator('.two-col > section.surface').filter({ has: layout.page().getByRole('heading', { name: heading, exact: true }) })
.locator('.operation').evaluateAll(rows => rows.map(row => [row.querySelector('h3').textContent,
row.querySelector('p').textContent, row.querySelector('.row-actions button').textContent]));
}
test('overview shows real counts and Not checked', async ({ page, request }) => {
const writes = [];
page.on('request', item => { if (!['GET', 'HEAD', 'OPTIONS'].includes(item.method())) writes.push(item.url()); });
const overview = await serverJson(request, OVERVIEW_URL);
const thumbnails = await serverJson(request, THUMBNAILS_URL);
// The concept fixture: six positives, their saved negatives and 24 linked images, all with thumbnails.
expect([overview.library.positive_prompts, overview.images.indexed, thumbnails.pending]).toEqual([6, 24, 0]);
// I2: the Overview never walks the sources for thumbnails; the row reads the status route itself.
expect(overview).not.toHaveProperty('thumbnails');
const layout = await openOverview(page);
expect(await layout.locator('.summary-metrics > .metric').evaluateAll(items => items.map(item =>
[item.querySelector('strong').textContent, item.querySelector('span').textContent]))).toEqual([
[number(overview.library.positive_prompts), 'Positive prompts'],
[number(overview.library.negative_variants), 'Negative variants'],
[number(overview.images.indexed), 'Images'],
['Not checked', 'Database integrity'],
]);
expect(await rowsOf(layout, 'Library health')).toEqual([
['Ambiguous image links', 'Not available yet.', 'Review metadata'],
['Backup status', 'No retained backup record.', 'View backups'],
['No missing thumbnails', 'Generate only missing previews when necessary.', 'Review thumbnails'],
]);
expect(await rowsOf(layout, 'Workspace preferences')).toEqual([
['One display configuration', 'Grid and density settings are shared across views.', 'Appearance'],
['2 output sources', 'Monitoring and enrichment share one source list.', 'Manage sources'],
['Statistics', 'Runs and ratings by category, with library totals.', 'Explore'],
]);
expect(writes).toEqual([]);
});
test('Run health checks opens Health report and updates metric 4', async ({ page, request }) => {
await page.goto(`${workspace}#/system/maintenance`);
const layout = layoutOf(page);
await expect(layout.locator(':scope > .notice h2')).toHaveText('Check first. Review changes. Then apply.');
// The rest of Maintenance is still the existing tools' (D94).
await expect(layout.locator(':scope > section.handoff h2')).toHaveText('Your current tools are ready');
const run = layout.getByRole('button', { name: 'Run health checks', exact: true });
await run.click();
const dialog = page.locator('dialog#modal[open]');
await expect(dialog.locator('#modal-title')).toHaveText('Health report');
const { report } = await serverJson(request, '/prompt_manager/system/health');
expect(report.status).toBe('incomplete');
expect(await dialog.locator('.operation-list > .operation').evaluateAll(rows => rows.map(row =>
[row.querySelector('h3').textContent, row.querySelector('span.chip').textContent]))).toEqual([
['Integrity check', 'Passed'], ['Foreign keys & pair references', 'Passed'], ['Canonical hashes', 'Passed'],
['Pair uniqueness', 'Passed'], ['Missing files · output', 'Passed'], ['Missing files · input', 'Passed'],
['Ambiguous metadata', 'Not available yet'], ['Consistency', 'Passed'],
]);
await expect(dialog.locator('#modal-body > p.small')).toHaveText(/^Checked .+\. Some checks could not run, so this report is incomplete\.$/);
await expect(dialog.locator('.dialog-actions button')).toHaveText(['Close', 'Review missing links']);
await dialog.getByRole('button', { name: 'Close', exact: true }).click();
await expect(page.locator('dialog#modal[open]')).toHaveCount(0);
await expect(run).toBeFocused();
await page.locator('#workspace-main .section-nav').getByRole('button', { name: 'Overview', exact: true }).click();
await expect(layout.locator('.metric').nth(3).locator('strong')).toHaveText('Incomplete');
// A missing file and an unmounted source: the report counts the readable sources and names the other one.
await expect(await request.post('/__test__/system/missing-file')).toBeOK();
await expect(await request.post('/__test__/system/missing-source')).toBeOK();
await page.locator('#workspace-main .section-nav').getByRole('button', { name: 'Maintenance', exact: true }).click();
await layout.getByRole('button', { name: 'Run health checks', exact: true }).click();
await expect(dialog.locator('.operation').filter({ hasText: 'Missing files' }).locator('.chip'))
.toHaveText(['1 link needs review', 'Passed', 'Not available']);
await expect(dialog.locator('.operation h3').filter({ hasText: 'Missing files' }))
.toHaveText(['Missing files · output', 'Missing files · input', 'Missing files · unmounted-drive']);
// Review missing links goes to Maintenance, where the cleanup's existing tool is linked.
await dialog.getByRole('button', { name: 'Review missing links', exact: true }).click();
await expect(page.locator('dialog#modal[open]')).toHaveCount(0);
await expect(page).toHaveURL(`${workspace}#/system/maintenance`);
await page.locator('#workspace-main .section-nav').getByRole('button', { name: 'Overview', exact: true }).click();
await expect(layout.locator('.metric').nth(3).locator('strong')).toHaveText('1 issue');
});
test('a health check that fails says so and keeps the last result', async ({ page, request }) => {
await expect(await request.post('/__test__/system/fail', { data: { path: '/prompt_manager/system/health' } })).toBeOK();
await page.goto(`${workspace}#/system/maintenance`);
const run = layoutOf(page).getByRole('button', { name: 'Run health checks', exact: true });
await run.click();
await expect(page.locator('#workspace-toast')).toHaveText('The health check could not finish. Nothing was changed.');
await expect(page.locator('dialog#modal[open]')).toHaveCount(0);
await expect(run).not.toHaveAttribute('aria-disabled', 'true');
await openOverview(page);
await expect(layoutOf(page).locator('.metric').nth(3).locator('strong')).toHaveText('Not checked');
});
test('thumbnail row reflects the status endpoint', async ({ page, request }) => {
await expect(await request.post('/__test__/system/unthumbnailed', { data: { count: 3 } })).toBeOK();
const status = await serverJson(request, '/prompt_manager/images/thumbnails/status');
expect(status.pending).toBe(3);
const layout = await openOverview(page);
const row = layout.locator('.operation').filter({ has: page.getByRole('button', { name: 'Review thumbnails', exact: true }) });
await expect(row.locator('h3')).toHaveText('3 missing thumbnails');
// Until the thumbnail review dialog (S5), Review thumbnails opens Sources.
await row.getByRole('button', { name: 'Review thumbnails', exact: true }).click();
await expect(page).toHaveURL(`${workspace}#/system/sources`);
});
test('the thumbnail row paints when its own status read answers', async ({ page, request }) => {
const held = async () => (await serverJson(request, '/__test__/hold')).held[THUMBNAILS_URL] ?? 0;
await expect(await request.post('/__test__/hold', { data: { path: THUMBNAILS_URL } })).toBeOK();
const layout = await openOverview(page);
const row = layout.locator('.operation').filter({ has: page.getByRole('button', { name: 'Review thumbnails', exact: true }) });
// The other values do not wait for the walk of every source.
await expect(row.locator('h3')).toHaveText('Checking thumbnails…');
await expect(layout.locator('.summary-metrics > .metric')).toHaveCount(4);
await expect.poll(held).toBe(1);
await expect(await request.post('/__test__/release')).toBeOK();
await expect(row.locator('h3')).toHaveText('No missing thumbnails');
});
test('a failed thumbnail read is unavailable, never zero', async ({ page }) => {
await page.route(`**${THUMBNAILS_URL}`, route => route.fulfill({ status: 500, contentType: 'application/json',
body: JSON.stringify({ success: false, error: 'Injected failure' }) }));
const layout = await openOverview(page);
const row = layout.locator('.operation').filter({ has: page.getByRole('button', { name: 'Review thumbnails', exact: true }) });
await expect(row.locator('h3')).toHaveText('Thumbnail status unavailable');
await expect(layout.getByRole('alert')).toHaveCount(0);
await expect(layout.locator('.metric').nth(2).locator('strong')).toHaveText('24');
});
test('ambiguous row reads Not available yet (Q6)', async ({ page }) => {
const layout = await openOverview(page);
const row = layout.locator('.operation').filter({ hasText: 'Ambiguous image links' });
await expect(row.locator('p')).toHaveText('Not available yet.');
const review = row.getByRole('button', { name: 'Review metadata', exact: true });
await expect(review).toHaveAttribute('aria-disabled', 'true');
await review.click({ force: true });
await expect(page).toHaveURL(`${workspace}#/system/overview`);
});
test('View backups / Appearance / Manage sources switch sections', async ({ page }) => {
for (const [label, section, pressed] of [['View backups', 'backups', 'Backup & export'], ['Appearance', 'appearance', 'Appearance'],
['Manage sources', 'sources', 'Sources & scans']]) {
const layout = await openOverview(page);
await layout.getByRole('button', { name: label, exact: true }).click();
await expect(page).toHaveURL(`${workspace}#/system/${section}`);
await expect(page.locator('#workspace-main .section-nav [aria-pressed="true"]')).toHaveText(pressed);
await expect(layout.locator(':scope > .summary-metrics')).toHaveCount(0);
}
});
test('statistics dialog lists runs by category and library totals', async ({ page, request }) => {
const statistics = await serverJson(request, '/prompt_manager/system/statistics');
expect(statistics.by_category.length).toBeGreaterThan(1);
const layout = await openOverview(page);
const explore = layout.getByRole('button', { name: 'Explore', exact: true });
await explore.click();
const dialog = page.locator('dialog#modal[open]');
await expect(dialog.locator('#modal-title')).toHaveText('Usage statistics');
const tables = dialog.locator('#modal-body > table');
await expect(tables.locator('caption')).toHaveText(['Runs by category', 'Library totals']);
await expect(tables.first().locator('thead th')).toHaveText(['Category', 'Runs', 'Avg. rating']);
const cells = table => table.locator('tbody tr').evaluateAll(rows => rows.map(row => [...row.cells].map(cell => cell.textContent)));
expect(await cells(tables.first())).toEqual(statistics.by_category.map(row => [row.category ?? 'Uncategorized',
number(row.runs), row.average_rating === null ? 'Not rated' : `${number(row.average_rating)} / 5`]));
expect(await cells(tables.nth(1))).toEqual([['Prompts', number(statistics.totals.total_prompts)],
['Tags', number(statistics.totals.total_tags)], ['Images', number(statistics.totals.total_images)],
['Rated images', number(statistics.rated_images)]]);
await page.keyboard.press('Escape');
await expect(explore).toBeFocused();
// A statistics read that fails says so; the Overview stays.
await expect(await request.post('/__test__/system/fail', { data: { path: '/prompt_manager/system/statistics' } })).toBeOK();
await explore.click();
await expect(page.locator('#workspace-toast')).toHaveText('Statistics could not be loaded. Your saved data has not changed.');
await expect(page.locator('dialog#modal[open]')).toHaveCount(0);
});
test('load failure shows Retry and no zeros', async ({ page, request }) => {
await expect(await request.post('/__test__/system/fail', { data: { path: OVERVIEW_URL } })).toBeOK();
await page.goto(`${workspace}#/system/overview`);
const layout = layoutOf(page);
await expect(layout.getByRole('alert')).toHaveText('The overview could not be loaded. Your saved data has not changed.');
await expect(layout.locator('.metric')).toHaveCount(0);
await expect(layout).not.toContainText('0');
await layout.getByRole('button', { name: 'Retry', exact: true }).click();
await expect(layout.locator('.summary-metrics > .metric')).toHaveCount(4);
await expect(layout.getByRole('alert')).toHaveCount(0);
});
test('out-of-order overview responses never repaint a newer section', async ({ page, request }) => {
const held = async () => (await serverJson(request, '/__test__/hold')).held[OVERVIEW_URL] ?? 0;
await expect(await request.post('/__test__/hold', { data: { path: OVERVIEW_URL } })).toBeOK();
await page.goto(`${workspace}#/system/overview`);
const layout = layoutOf(page);
await expect(layout.getByRole('status')).toHaveText('Loading the overview…');
await expect.poll(held).toBe(1);
// Leave for Appearance, come back (a second read), then leave again before either answers.
const nav = page.locator('#workspace-main .section-nav');
await nav.getByRole('button', { name: 'Appearance', exact: true }).click();
await nav.getByRole('button', { name: 'Overview', exact: true }).click();
await expect.poll(held).toBe(2);
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: 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);
expect(await layout.evaluate(node => [...node.children].map(child => child.className))).toEqual(['surface handoff', 'save-row']);
// Back on Overview, one fresh read paints one set of metrics.
await nav.getByRole('button', { name: 'Overview', exact: true }).click();
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 });
});
-3
View File
@@ -340,9 +340,6 @@ test('Tag opens the dialog without selecting the card', async ({ page }) => {
const panel = page.getByRole('complementary', { name: 'Selected image details', exact: true });
await page.goto(images);
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
// Capture the panel once it shows the first card's details, not while it is still reading them.
await expect(panel.getByRole('heading', { level: 2 })).toBeVisible();
await expect(panel).not.toContainText('Loading file details');
const before = await panel.innerText();
await openTag(page, SECOND, { goto: false });
await page.keyboard.press('Escape');
+1 -96
View File
@@ -18,8 +18,7 @@ const routes = [
['tags', 'Tags & categories', null],
// #249 local Inspect: the native Inspect a file page; Overview's tool card still links the legacy metadata page.
['inspect', 'Inspect a file', null],
// #249 System S2: native System; its undelivered sections hand off per section (D94, asserted below).
['system', 'System', null],
['system', 'System', '/prompt_manager/admin'],
// #249 D51: SAVED VIEWS' Favorites, Images filtered to favorites.
['favorites', 'Favorites', null],
// #249 Activity-1: the concept's Activity page in the tools-and-system group.
@@ -410,8 +409,6 @@ for (const width of [1600, 390]) test(`overview and handoff render with concept
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
await sidebarLink(page, 'System').click();
// System's Overview is native (S2 Task 6); a section not delivered yet still hands off (D94).
await main.locator('.section-nav').getByRole('button', { name: 'Integrations', exact: true }).click();
const handoff = main.locator('section.handoff');
await expect(handoff).toHaveClass(/\bsurface\b/);
expect(await handoff.evaluate(surfaceStyle)).toMatchObject(SURFACE);
@@ -420,98 +417,6 @@ for (const width of [1600, 390]) test(`overview and handoff render with concept
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
});
// #249 System S2 Task 5: the concept's renderSystem shell (app.js 698–706) at `#/system/<section>`, every section a D94
// hand-off until its PR, and the Save row's truthful status (D92).
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 = { 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 = [];
page.on('request', item => { if (!['GET', 'HEAD', 'OPTIONS'].includes(item.method())) mutations.push(item.url()); });
await page.goto(`${workspace}#/system`);
const main = page.locator('#workspace-main');
await expect(main.locator('h1')).toHaveText('System');
await expect(main.locator('.heading p.muted')).toHaveText('Configuration, maintenance and recovery—with browsing kept in the workspace.');
// D92: no "Sample configuration" chip.
await expect(main.locator('.heading .chip')).toHaveCount(0);
expect(await main.evaluate(node => [...node.children].map(child => `${child.localName}.${child.className}`)))
.toEqual(['div.heading', 'div.section-nav', 'div.settings-layout']);
const nav = main.getByRole('group', { name: 'System sections', exact: true });
await expect(nav.getByRole('button')).toHaveText(SYSTEM_SECTION_LABELS);
await expect(nav.getByRole('button', { name: 'Overview', exact: true })).toHaveAttribute('aria-pressed', 'true');
await expect(nav.locator('[aria-pressed="true"]')).toHaveCount(1);
await expect(page.locator('aside.sidebar a[href="#/system"]')).toHaveAttribute('aria-current', 'page');
const layout = main.locator('.settings-layout');
await expect(layout.locator(':scope > .save-row > span.small#settings-status')).toHaveText('No unsaved changes.');
const save = layout.locator(':scope > .save-row > button.ca-primary');
await expect(save).toHaveText('Save preferences');
await expect(save).toHaveAttribute('aria-disabled', 'true');
// Playwright waits on an aria-disabled control, so the ignored press is forced.
await save.click({ force: true });
await expect(layout.locator('#settings-status')).toHaveText('No unsaved changes.');
for (const [index, label] of SYSTEM_SECTION_LABELS.entries()) {
const hash = SYSTEM_HASHES[index];
const button = nav.getByRole('button', { name: label, exact: true });
await button.click();
await expect(page).toHaveURL(`${workspace}#/system/${hash}`);
await expect(button).toHaveAttribute('aria-pressed', 'true');
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 is Appearance (S3 Task 8); 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();
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'],
maintenance: ['div.notice', 'section.surface handoff', 'div.save-row'] }[hash]
?? ['section.surface handoff', 'div.save-row']);
if (hash === 'overview' || hash === 'appearance') 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'))))
.toEqual(SYSTEM_HANDOFFS[hash] ?? ['/prompt_manager/admin']);
await expect(layout.locator('#settings-status')).toHaveText('No unsaved changes.');
await expect(page.locator('aside.sidebar a[href="#/system"]')).toHaveAttribute('aria-current', 'page');
}
// Deep links, reload and history keep the section; an unknown section is Overview under its own hash.
await page.reload();
await expect(nav.getByRole('button', { name: 'Diagnostics & logs', exact: true })).toHaveAttribute('aria-pressed', 'true');
await page.goBack();
await expect(page).toHaveURL(`${workspace}#/system/backups`);
await expect(nav.getByRole('button', { name: 'Backup & export', exact: true })).toHaveAttribute('aria-pressed', 'true');
await page.goto(`${workspace}#/system/general`);
await expect(page).toHaveURL(`${workspace}#/system/overview`);
await expect(nav.getByRole('button', { name: 'Overview', exact: true })).toHaveAttribute('aria-pressed', 'true');
// The sidebar's System returns to the Overview section.
await nav.getByRole('button', { name: 'Integrations', exact: true }).click();
await sidebarLink(page, 'System').click();
await expect(page).toHaveURL(`${workspace}#/system`);
await expect(nav.getByRole('button', { name: 'Overview', exact: true })).toHaveAttribute('aria-pressed', 'true');
// Leaving System and coming back renders it once, with a fresh section.
await sidebarLink(page, 'Images').click();
await expect(main.locator('h1')).toHaveText('Images');
await expect(main.locator('.section-nav')).toHaveCount(0);
await page.goBack();
await expect(main.locator('.section-nav')).toHaveCount(1);
await expect(main.locator('.settings-layout')).toHaveCount(1);
expect(mutations).toEqual([]);
});
test('Overview opens native System from its System card (Q12)', async ({ page }) => {
await page.goto(`${workspace}#/overview`);
const card = page.locator('#workspace-main .tool-card').filter({ has: page.getByRole('heading', { name: 'System', exact: true }) });
await expect(card.locator('p.small')).toHaveText('Configuration, maintenance and recovery—with browsing kept in the workspace.');
const link = card.getByRole('link', { name: 'Open System', exact: true });
await expect(link).toHaveAttribute('href', '#/system/overview');
await link.click();
await expect(page.locator('#workspace-main h1')).toHaveText('System');
await expect(page.locator('#workspace-main .section-nav [aria-pressed="true"]')).toHaveText('Overview');
});
// Task A3: the concept sidebar (index.html h16-41, app.js nav()) rendered from real data (D27, D28).
const NAV_TOTALS = [
['images', 'Images', '/prompt_manager/images/output?mode=workspace&limit=1'],
+2 -35
View File
@@ -11,7 +11,6 @@ the same sidebar counts (24 images, 6 prompts, 15 tags). Nothing is mocked.
from __future__ import annotations
from dataclasses import dataclass
import datetime
import json
import math
import os
@@ -158,8 +157,6 @@ CONCEPT_MODEL = "illustrative-model.safetensors"
MAX_EXTRA_IMAGES = 120
# Unlinked, seedless images (D11 "W × H · PNG") newer than the concept's, for D3 checks.
MAX_SEEDLESS_IMAGES = 2
# A test's chosen "now" (epoch seconds) stays before the year 3000.
MAX_EPOCH_SECONDS = 32_503_680_000
SEEDLESS_SIZE = (1536, 1152)
# The same 4:3 shape with a short "96 × 72 · PNG" subtitle that stays on one line at
# 390px beside the card's Info and Tag (the D3 footer check).
@@ -345,17 +342,6 @@ def _bounded(name: str, value: int, maximum: int) -> None:
raise ValueError(f"{name} is out of range")
def _instant(now: float | None) -> float:
"""The fixture's one "now": ``now`` itself, or the wall clock when omitted."""
if now is None:
return time.time()
if isinstance(now, bool) or not isinstance(now, (int, float)):
raise TypeError("now must be a number of epoch seconds")
if not 0 < now < MAX_EPOCH_SECONDS:
raise ValueError("now is out of range")
return float(now)
def seed_workspace_concept(
api: PromptManagerAPI,
root: Path,
@@ -363,16 +349,9 @@ def seed_workspace_concept(
extra_images: int = 0,
seedless_images: int = 0,
short_seedless: bool = False,
now: float | None = None,
) -> dict:
"""Replace this server's disposable rows and files with the concept's data.
Every relative time the fixture writes (the prompts' last use, the files'
mtimes, the runs' observation times) is taken from one instant, ``now``
(epoch seconds, the wall clock when omitted), returned as ``manifest["now"]``.
A browser whose clock is fixed at that instant reads the concept's "Used
today" and caption day however close to midnight the run starts.
``extra_images`` continues the concept's rounds (same prompts, dimensions and
seed rule) after its 24 images, for bounded-rendering and Load more checks.
``seedless_images`` adds unlinked PNGs without metadata, newest of all;
@@ -382,10 +361,6 @@ def seed_workspace_concept(
raise TypeError("short_seedless must be a boolean")
_bounded("extra_images", extra_images, MAX_EXTRA_IMAGES)
_bounded("seedless_images", seedless_images, MAX_SEEDLESS_IMAGES)
now = _instant(now)
used_at = datetime.datetime.fromtimestamp(now, datetime.timezone.utc).isoformat(
timespec="microseconds"
)
_clear(api, root)
db = api.db
epoch = db.get_execution_epoch()
@@ -410,15 +385,8 @@ def seed_workspace_concept(
db.update_prompt_pair_metadata(pair_ids[0], title=first_name)
for name, text in prompt.negatives[1:]:
pair_ids.append(db.save_prompt_pair(prompt.positive, text, title=name))
# Real queue-hook counting (D23): four runs, the last one now, at the
# fixture's own instant rather than the database's later wall clock.
# Real queue-hook counting (D23): four runs, the last one now.
db.record_prompt_use(positive_id, times=CONCEPT_RUN_COUNT)
with db.model.get_connection() as conn:
conn.execute(
"UPDATE prompts SET last_used_at = ? WHERE id = ?",
(used_at, positive_id),
)
conn.commit()
prompts.insert(
0, {"id": prompt.id, "positive_id": positive_id, "pair_ids": pair_ids}
)
@@ -432,6 +400,7 @@ def seed_workspace_concept(
favorite_root = _favorite_root_key(list(api._get_all_output_dirs())[0])
total = len(CONCEPT_PROMPTS) * CONCEPT_IMAGE_ROUNDS + extra_images
rounds = math.ceil(total / len(CONCEPT_PROMPTS))
now = time.time()
images, runs = [], []
slots = [
(round_index, prompt_index, prompt)
@@ -515,8 +484,6 @@ def seed_workspace_concept(
api.invalidate_gallery_cache()
return {
"epoch": epoch,
# The fixture's "now" (epoch seconds), for the browser's fixed clock.
"now": now,
# The newest file's mtime (epoch seconds), for the D17 caption date.
"newest_modified": now - 10 if seedless_images else now - 60,
"seedless": seedless,
-144
View File
@@ -1,144 +0,0 @@
"""System test hooks for the browser tests (#249 System S2 Task 6).
The data is the concept-shaped real fixture (``POST /__test__/concept/reset``,
``workspace_concept_fixture.py``): its parents, saved pairs, images, ratings
and runs are what the Overview, the Statistics dialog and the health check
read. These per-server hooks only change what a System test needs on top:
- ``POST /__test__/system/reset`` restores the configured sources, forgets the
session's health report and ends every injected failure;
- ``POST /__test__/system/fail`` ``{path, times}`` answers the next ``times``
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);
- ``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.
Delays use the shared request holds (``request_hold.py``).
"""
import os
from pathlib import Path
from aiohttp import web
from PIL import Image
FAILABLE_PREFIX = "/prompt_manager/system/"
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
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,
)
failures: dict[str, int] = {}
@web.middleware
async def inject_failures(request, handler):
left = failures.get(request.path, 0)
if left <= 0:
return await handler(request)
failures[request.path] = left - 1
return web.json_response(
{"success": False, "code": None, "error": "Injected failure"}, status=500
)
app.middlewares.append(inject_failures)
def reset():
failures.clear()
GalleryConfig.MONITORING_DIRECTORIES = list(configured)
api._health_report = None
PromptManagerConfig.RESULT_TIMEOUT, PromptManagerConfig.WEBUI_DISPLAY_MODE = (
host_values
)
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):
reset()
return web.json_response({"success": True})
@routes.post("/__test__/system/fail")
async def system_fail(request):
data = await request.json()
path, times = data.get("path"), data.get("times", 1)
if (
not isinstance(path, str)
or not path.startswith(FAILABLE_PREFIX)
or isinstance(times, bool)
or not isinstance(times, int)
or not 1 <= times <= 10
):
raise web.HTTPBadRequest()
failures[path] = times
return web.json_response({"success": True})
@routes.post("/__test__/system/missing-source")
async def missing_source(request):
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/missing-file")
async def missing_file(request):
with api.db.model.get_connection() as conn:
row = conn.execute(
"SELECT file_path FROM generated_images ORDER BY id DESC LIMIT 1"
).fetchone()
if row is None:
raise web.HTTPNotFound()
Path(row[0]).unlink()
api.invalidate_gallery_cache()
return web.json_response({"success": True, "filename": Path(row[0]).name})
@routes.post("/__test__/system/unthumbnailed")
async def unthumbnailed(request):
data = await request.json()
count = data.get("count")
if (
isinstance(count, bool)
or not isinstance(count, int)
or not 1 <= count <= MAX_UNTHUMBNAILED
):
raise web.HTTPBadRequest()
folder = root / "output" / "unthumbnailed"
folder.mkdir(exist_ok=True)
for number in range(count):
with Image.new("RGB", (64, 64), "#2b3442") as image:
image.save(folder / f"plain_{number + 1:03}.png")
api.invalidate_gallery_cache()
return web.json_response({"success": True})
return reset
+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
-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',()=>{
-34
View File
@@ -14,11 +14,6 @@
// the carrier rule alone, as the server selects from ffprobe's tag map.
// A {records} entry (records null = an unreadable file) answers
// {classified: null, result: null, metadata} for the projection alone.
// A {mode: 'resolve'} entry (PR L3) answers {evidence}: resolveLocalEvidence of its {records} texts
// with the repository semantics table. With a {path} it reads the file locally and also answers
// {facts} (and {unreadable: true} with the server's unreadable evidence when nothing could be read):
// extractGenerationFacts on the local payload through projectInspectionPayload, exactly as
// tests/js/support/inspection-parity-facts.mjs reads the server's response for vectors.json.
import fs from 'node:fs';
import path from 'node:path';
import { createRequire } from 'node:module';
@@ -33,22 +28,6 @@ const web = path.join(here, '..', '..', '..', 'web', 'js', 'workspace');
const { selectVideoCarrier } = await import(moduleUrl(path.join(web, 'local-video.mjs')));
const { projectMetadata } = await import(moduleUrl(path.join(web, 'local-projection.mjs')));
const { readLocalFile, toInspectionRead } = await import(moduleUrl(path.join(web, 'local-inspection.mjs')));
const { resolveLocalEvidence, compileSemantics, unreadableEvidence } = await import(moduleUrl(path.join(web, 'local-resolver.mjs')));
const { buildLocalPayload } = await import(moduleUrl(path.join(web, 'local-inspection.mjs')));
const { projectInspectionPayload } = await import(moduleUrl(path.join(web, 'inspection-state.mjs')));
const semantics = compileSemantics(JSON.parse(fs.readFileSync(path.join(here, '..', '..', '..', 'web', 'js', 'inspection-semantics.json'), 'utf8')));
const { extractGenerationFacts } = require(path.join(here, '..', '..', '..', 'web', 'js', 'comfy-metadata.js'));
/** The local evidence for one set of record texts. */
const resolved = records => ({ evidence: resolveLocalEvidence(records, semantics) });
/** The local wire's evidence and six facts for one read (no legacy fill, as in the vectors' own harness). */
function localPayload(read, file, classification) {
const payload = buildLocalPayload({ read, file, classification, semantics });
const record = projectInspectionPayload(payload, { kind: 'local-file', key: 'local:1', path: file.name }, null, extractGenerationFacts);
const facts = Object.fromEntries(Object.entries(record.generation).map(([field, fact]) => [field, { status: fact.status, value: fact.value }]));
return { evidence: { prompts: payload.prompts, resolution: payload.resolution }, facts };
}
const selectionRead = (selection) => selection.status === 'complete' ?
{ records: selection.records, record_formats: selection.record_formats, carrierText: selection.carrierText, limited: selection.limited } : null;
@@ -58,19 +37,6 @@ for await (const chunk of process.stdin) chunks.push(chunk);
const output = {};
for (const entry of JSON.parse(Buffer.concat(chunks).toString('utf8'))) {
const { name } = entry;
if (entry.mode === 'resolve') {
if (entry.records !== undefined) { output[name] = resolved(entry.records); continue; }
const blob = await fs.openAsBlob(entry.path);
const { classification, result } = await readLocalFile(blob, { pngMetadata });
const read = classification === null ? null : toInspectionRead(classification, result).read;
if (classification === null) { output[name] = { unreadable: true }; continue; }
// The evidence is the local wire's own (buildLocalPayload), and it equals resolveLocalEvidence of the records.
const local = localPayload(result, { name: path.basename(entry.path), size: blob.size }, classification);
const direct = read === null ? unreadableEvidence() : resolved(read.records).evidence;
if (JSON.stringify(direct) !== JSON.stringify(local.evidence)) throw new Error(`${name}: the wire's roles are not the resolver's`);
output[name] = read === null ? { unreadable: true, ...local } : local;
continue;
}
if (entry.records !== undefined) {
const read = entry.records === null ? null : {
records: entry.records,
-18
View File
@@ -182,24 +182,6 @@ test('start returns busy with the running job', async () => {
world.dispose();
});
test('track follows a job another route started, with no read of its own', async () => {
const { createActivityStore } = await source;
const { world, ...deps } = harness();
const store = createActivityStore(deps);
const seen = [];
store.subscribe(() => seen.push(store.find(ID_A)?.state));
const job = store.track(summary({ kind: 'health', title: 'Health check' }));
assert.equal(job.id, ID_A);
assert.equal(world.gets, 0, 'the started job is known without a list read');
assert.deepEqual(world.open().map(stream => stream.url), [`/prompt_manager/jobs/${ID_A}/events`]);
assert.deepEqual(world.toasts, [], 'the route that started it says so itself');
send(world.open()[0], summary({ kind: 'health', title: 'Health check', state: 'finished', progress: 100 }));
assert.deepEqual(seen, ['running', 'finished']);
assert.equal(world.open().length, 0, 'an ended job is no longer followed');
assert.throws(() => store.track({ id: 'nope' }), /Invalid job/);
world.dispose();
});
test('a list that is not a job list counts as a failed read', async () => {
const { createActivityStore } = await source;
const { world, api, ...deps } = harness();
-148
View File
@@ -215,151 +215,3 @@ test('none and unavailable render no image chips', async () => {
assert.deepEqual(inspectorTagChips(tagged({state, tags:[]}, {status:'none', tags:[], tags_scope:null}), {filterable:true}), []);
}
});
// LI-4 (D117): "Copy settings summary" after the six facts of the full inspector consolidates the legacy metadata
// page's COPY ALL (web/metadata.html copyAllData). The legacy template is read from that page itself and run on the
// legacy parse, so the summary's text is the legacy page's, line for line, wherever the two agree on the values.
const METADATA_PAGE = fs.readFileSync(new URL('../../web/metadata.html', import.meta.url), 'utf8');
function legacyCopyAll(params) {
const template = METADATA_PAGE.match(/const allData = `([\s\S]*?)`;/)[1];
return new Function('currentWorkflowData', `return \`${template}\`;`)(params);
}
const legacyParams = record => ComfyMetadata.extractGenerationParams({prompt:record.metadata.prompt_graph.status === 'parsed' ? record.metadata.prompt_graph.value : null,
workflow:record.metadata.workflow_graph.status === 'parsed' ? record.metadata.workflow_graph.value : null});
/**
* Each summary line against the legacy page's, allowing only the recorded differences (D117): a number keeps the
* inspector's (the server's) spelling ("8.0" where the legacy page wrote 8); a fact the legacy parse left "Unknown" may
* be known; and a negative the #249 ZeroOut guard zeroed reads "No negative prompt found" where the legacy parse read
* the positive's text through the zero-out.
*/
function assertLegacySummary(summary, legacy, record) {
const lines = summary.split('\n'), expected = legacy.split('\n');
assert.deepEqual(lines.map(line => line.split(':')[0]), expected.map(line => line.split(':')[0]), 'the legacy labels, in order');
lines.forEach((line, index) => {
if (line === expected[index]) return;
const [label, value] = [line.slice(0, line.indexOf(': ')), line.slice(line.indexOf(': ') + 2)];
const old = expected[index].slice(label.length + 2);
if (label === 'Negative Prompt' && record.prompts.negative?.source === 'zeroed-conditioning' && value === 'No negative prompt found') return;
if (old === 'Unknown' && !/Prompt$/.test(label)) return;
assert.ok(value.trim() !== '' && Number(value) === Number(old), `${label}: "${value}" differs from the legacy "${old}"`);
});
}
test('settings summary is the legacy COPY ALL text for a local file', async () => {
const {settingsSummary} = await load();
assert.equal(typeof settingsSummary, 'function', 'settingsSummary is exported');
const record = await localRecord(localPng('fox.png'));
assertLegacySummary(settingsSummary(record), legacyCopyAll(legacyParams(record)), record);
assert.equal(settingsSummary(record), ['Checkpoint: synthetic-v1.safetensors', 'Positive Prompt: a red fox in snow, detailed fur',
'Negative Prompt: blurry, lowres', 'Steps: 20', 'CFG Scale: 8.0', 'Sampler: euler', 'Seed: 7'].join('\n'));
// Every parity PNG read in this browser: the legacy page's text but for the recorded differences, and exactly the
// legacy text for most of them (f09 among them: the server is ambiguous, so the record shows the legacy fill).
const exact = [];
for (const name of fs.readdirSync(PARITY).filter(item => item.endsWith('.png')).sort()) {
const parity = await localRecord(new File([fs.readFileSync(new URL(name, PARITY))], name));
const summary = settingsSummary(parity), legacy = legacyCopyAll(legacyParams(parity));
assertLegacySummary(summary, legacy, parity);
if (summary === legacy) exact.push(name);
}
assert.ok(exact.includes('f09.png'));
assert.deepEqual(fs.readdirSync(PARITY).filter(item => item.endsWith('.png') && !exact.includes(item)).sort(), ['f01b.png', 'f17.png', 'f23.png']);
});
test('settings summary uses the legacy placeholders for what the record does not know', async () => {
const {settingsSummary} = await load();
const unknown = {...record(), prompts:{positive:{status:'unresolved'}, negative:{status:'ambiguous'}}, generation:{}};
assert.equal(settingsSummary(unknown), legacyCopyAll({}).replace('Positive Prompt: undefined', 'Positive Prompt: No prompt found')
.replace('Negative Prompt: undefined', 'Negative Prompt: No negative prompt found'));
assert.equal(settingsSummary(unknown), ['Checkpoint: Unknown', 'Positive Prompt: No prompt found', 'Negative Prompt: No negative prompt found',
'Steps: Unknown', 'CFG Scale: Unknown', 'Sampler: Unknown', 'Seed: Unknown'].join('\n'));
// A server answer is shown as the inspector shows it: known facts verbatim, every other state the legacy "Unknown".
const server = {...record(), prompts:{positive:{status:'resolved', text:'a\n猫'}, negative:{status:'resolved', text:''}}, generation:{
model:{status:'known', value:'m.safetensors'}, steps:{status:'not-recorded', value:null}, cfg:{status:'known', value:'6.50'},
sampler:{status:'ambiguous', value:null}, seed:{status:'known', value:'9007199254740993'}, scheduler:{status:'known', value:'karras'}}};
assert.equal(settingsSummary(server), ['Checkpoint: m.safetensors', 'Positive Prompt: a\n猫', 'Negative Prompt: No negative prompt found',
'Steps: Unknown', 'CFG Scale: 6.50', 'Sampler: Unknown', 'Seed: 9007199254740993'].join('\n'));
});
/** The full inspector with a toast node and `clipboard` as `navigator.clipboard`; returns the summary button. */
function renderSummary(record, {variant = 'full', clipboard} = {}) {
const f = mountedDom(), scope = createScope(), fallbacks = [], toast = new f.Node('div');
f.doc.getElementById = id => id === 'workspace-toast' ? toast : null;
const previous = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
Object.defineProperty(globalThis, 'navigator', {configurable:true, value:clipboard === undefined ? {} : {clipboard}});
const view = mountFileInspector(f.container, {sender:{open() {}}, parentScope:scope, variant, copyFallback:(text, options) => fallbacks.push({text, ...options})});
view.setState({loading:false, error:false, record});
const button = f.walk(f.container, n => n.tagName === 'button' && n.textContent === 'Copy settings summary')[0] ?? null;
return {f, button, toast, fallbacks, scope, done() {
view.dispose(); scope.dispose(); f.restore();
if (previous) Object.defineProperty(globalThis, 'navigator', previous); else delete globalThis.navigator;
}};
}
test('Copy settings summary follows the facts in the full inspector only', async () => {
const full = renderSummary(record());
try {
assert.ok(full.button, 'the full inspector offers Copy settings summary');
const inner = full.button.parent, index = inner.children.indexOf(full.button);
assert.equal(inner.children[index - 1].tagName, 'dl', 'it follows the six facts');
assert.equal(inner.children[index + 1].tagName, 'details', 'File & provenance follows it');
// M7: the D117 wrap rule targets this button by its class, never any button of a details block.
assert.equal(full.button.className, 'settings-summary');
} finally { full.done(); }
const panel = renderSummary(record(), {variant:'panel'});
try { assert.equal(panel.button, null, 'the Images side panel keeps the concept footer'); } finally { panel.done(); }
});
test('Copy settings summary copies the summary and shows the shared toast', async () => {
const {settingsSummary} = await load(), copied = [];
const view = renderSummary(record(), {clipboard:{writeText:async text => {copied.push(text);}}});
try {
await view.button.listeners.get('click')[0].callback({target:view.button});
assert.deepEqual(copied, [settingsSummary(record())]);
assert.equal(view.toast.textContent, 'Settings summary copied.');
assert.equal(view.fallbacks.length, 0);
} finally { view.done(); }
});
test('Copy settings summary without a clipboard opens the select-to-copy fallback', async () => {
const {settingsSummary} = await load();
for (const clipboard of [undefined, {writeText:async () => {throw new Error('Denied');}}]) {
const view = renderSummary(record(), {clipboard});
try {
await view.button.listeners.get('click')[0].callback({target:view.button});
assert.equal(view.fallbacks.length, 1);
assert.equal(view.fallbacks[0].text, settingsSummary(record()));
assert.equal(view.fallbacks[0].title, 'Copy settings summary');
assert.equal(view.fallbacks[0].trigger, view.button);
assert.notEqual(view.toast.textContent, 'Settings summary copied.');
} finally { view.done(); }
}
});
test('the inspector is busy while a read is loading, then not', async () => {
const f = mountedDom(), scope = createScope();
const view = mountFileInspector(f.container, {sender:{open() {}}, parentScope:scope, variant:'full'});
try {
view.setState({loading:true, error:false, record:null});
assert.equal(f.container.getAttribute('aria-busy'), 'true');
assert.ok(f.walk(f.container, n => n.textContent === 'Loading file details…').length);
view.setState({loading:false, error:false, record:record()});
assert.equal(f.container.getAttribute('aria-busy'), null);
view.setState({loading:true, error:false, record:null});
view.setState({loading:false, error:true, record:null});
assert.equal(f.container.getAttribute('aria-busy'), null);
view.setState({loading:true, error:false, record:null});
} finally {
view.dispose();
assert.equal(f.container.getAttribute('aria-busy'), null, 'dispose clears the busy state');
scope.dispose(); f.restore();
}
});
test('the D117 wrap rule targets only the settings summary button', async () => {
const fs = await import('node:fs');
const css = fs.readFileSync(new URL('../../web/js/workspace/workspace.css', import.meta.url), 'utf8');
const rules = css.split('\n').filter(line => line.includes('D117'));
assert.deepEqual(rules.map(line => line.trim()), ['.ca-tools #viewer-details .settings-summary { white-space: normal; /* D117 */ }']);
assert.doesNotMatch(css, /#viewer-details > div > button/);
});
-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();
});
+2 -160
View File
@@ -20,16 +20,6 @@ const { extractGenerationFacts } = ComfyMetadata;
// The page loads both as scripts; the legacy fill and the PNG reader find them on the global.
globalThis.PngMetadata = PngMetadata;
globalThis.ComfyMetadata = ComfyMetadata;
// The page's API client and the node table it serves (local-semantics.mjs fetches it once).
const SEMANTICS = fs.readFileSync(path.join(here, "..", "..", "web", "js", "inspection-semantics.json"), "utf8");
globalThis.window = { ApiClient: { resolveUrl: (value) => value } };
// A test can hold the table request (`tableGate`) to stand for a slow or stalled proxy.
let tableGate = null;
globalThis.fetch = async (url) => {
if (url !== "/prompt_manager/js/inspection-semantics.json") throw new Error(`unexpected request ${url}`);
if (tableGate) await tableGate;
return new Response(SEMANTICS, { status: 200 });
};
const m = await import("../../web/js/workspace/local-inspection.mjs").catch((error) => {
if (error.code !== "ERR_MODULE_NOT_FOUND") throw error;
return {};
@@ -69,37 +59,6 @@ test("contract", () => {
assert.ok(Object.isFrozen(m.localFileAdapter));
});
// M3: the first read waits for the page's one table request; an aborted, replaced or cleared selection must not.
test("a read waiting for the node table ends with its selection", async () => {
let release;
tableGate = new Promise((resolve) => { release = resolve; });
try {
const file = pngFile("slow.png", [tEXt("prompt", PROMPT)]);
const aborted = new AbortController();
const reading = m.localFileAdapter.read(await handleFor(file), { signal: aborted.signal });
const settled = (promise) => Promise.race([promise.then(() => "resolved", (error) => error.name), new Promise((resolve) => setTimeout(() => resolve("pending"), 200))]);
await new Promise((resolve) => setTimeout(resolve, 20));
aborted.abort();
assert.equal(await settled(reading), "AbortError");
// Through the controller: clearing the selection ends its read; nothing is published for it.
const controller = createInspectionController({ api: null, adapter: m.localFileAdapter, extractFacts: extractGenerationFacts });
const states = [];
controller.subscribe((state) => states.push(state));
const selected = controller.select(await handleFor(file));
await new Promise((resolve) => setTimeout(resolve, 20));
controller.clear();
assert.equal(await settled(selected), "resolved");
assert.ok(states.every((state) => state.record === null && state.error === false), "no record or error for a cleared selection");
controller.dispose();
} finally {
tableGate = null;
release();
}
// The shared request still lands for the next selection.
const { wire } = await recordFor(pngFile("next.png", [tEXt("prompt", PROMPT)]));
assert.equal(wire.resolution.state, "resolved");
});
test("a PNG File becomes the server-shaped wire with local provenance", async () => {
const file = pngFile("Fox Render.PNG", [tEXt("prompt", PROMPT), tEXt("workflow", WORKFLOW)]);
const { wire, record, handle } = await recordFor(file);
@@ -107,12 +66,8 @@ test("a PNG File becomes the server-shaped wire with local provenance", async ()
size: file.size, width: 640, height: 480, modified_time: 1_700_000_000.123 });
assert.equal(wire.success, true);
assert.equal(wire.inspection_version, 1);
// The roles the server's inspector resolves for the same records (local-resolver.mjs).
assert.deepEqual(wire.prompts, {
positive: { status: "resolved", text: "a red fox in snow, detailed fur", source: "graph-inferred" },
negative: { status: "resolved", text: "blurry, lowres", source: "graph-inferred" },
});
assert.deepEqual(wire.resolution, { state: "resolved", sampler_id: "3", reason: "", reason_code: null, node: null });
assert.deepEqual(wire.prompts, { positive: { status: "unresolved" }, negative: { status: "unresolved" } });
assert.deepEqual(wire.resolution, { state: "unresolved", sampler_id: null, reason: "Resolved in this browser from the workflow.", reason_code: null, node: null });
assert.deepEqual(wire.library, { status: "unavailable", scope: "saved-library", tags: [], category_scope: null, tags_scope: null });
assert.equal(wire.metadata.status, "available");
assert.deepEqual(wire.metadata.raw, { prompt: PROMPT, workflow: WORKFLOW });
@@ -141,33 +96,6 @@ test("a PNG File becomes the server-shaped wire with local provenance", async ()
assert.equal(exported.file.source_label, "Selected local file");
});
test("roles answer as the server's inspector: unreadable, unavailable table, ambiguous", async () => {
const png = { media: "image", kind: "png" };
const unreadable = m.buildLocalPayload({ read: null, file: { name: "a.png" }, classification: png });
assert.deepEqual(unreadable.resolution, { state: "unresolved", sampler_id: null, reason: "Media metadata could not be read", reason_code: null, node: null });
assert.equal(unreadable.prompts.positive.message, "Metadata in this file could not be read.");
const records = { prompt: PROMPT };
const read = { status: "complete", records, limited: [], width: 2, height: 3 };
// Without a table (it could not be fetched) the roles stay open, with the server's own line.
const missing = m.buildLocalPayload({ read, file: { name: "a.png" }, classification: png });
assert.equal(missing.resolution.reason, "Inspection node semantics are unavailable");
assert.equal(missing.prompts.positive.message, "The inspector's node table is unavailable.");
const semantics = await (await import("../../web/js/workspace/local-semantics.mjs")).loadInspectionSemantics();
const graph = JSON.parse(PROMPT);
graph["9"] = { ...graph["3"], inputs: { ...graph["3"].inputs, positive: ["8", 0] } };
graph["8"] = { class_type: "CLIPTextEncode", inputs: { text: "a cat", clip: ["4", 1] } };
const several = m.buildLocalPayload({ read: { ...read, records: { prompt: JSON.stringify(graph) } }, file: { name: "a.png" }, classification: png, semantics });
assert.equal(several.resolution.reason, "Multiple sampler branches");
assert.deepEqual(several.prompts.positive, { status: "ambiguous", text: null, source: null });
// The legacy fill then applies exactly as it does to the server's answer for the same bytes in Images.
const handle = await handleFor(pngFile("grid.png", [tEXt("prompt", JSON.stringify(graph))]));
const local = m.localFileAdapter.toRecord(several, handle, extractGenerationFacts);
const asServed = { ...several, file: { ...several.file, observation: "current-file", source_label: "output" } };
const served = projectInspectionPayload(asServed, { kind: "rooted-file", key: "0:grid.png", root_index: 0, path: "grid.png", root_scope: null }, null, extractGenerationFacts);
assert.deepEqual(local.prompts, served.prompts);
assert.deepEqual(local.generation, served.generation);
});
test("file facts follow the server: extension like Path.suffix, null when unknown", async () => {
const cases = [["a.b.PnG", ".png"], [".hidden", ""], ["noext", ""], ["dot.", ""], ["x.tar.gz", ".gz"]];
for (const [name, extension] of cases) {
@@ -379,89 +307,3 @@ test("the parity corpus PNGs read into renderable records", async () => {
assert.ok(["parsed", "limited", "malformed"].includes(wire.metadata.prompt_graph.status));
}
});
// M4: resolution runs on the page's main thread, so a read yields between its bounded steps (the busy state paints
// and input is handled) and no step may exceed a budget on the largest record the readers keep (2 MiB of roles).
const ROLE_BYTES = 2 * 1024 * 1024;
// Generous for a shared CI runner: a desktop resolves these in 130-455 ms.
const STEP_BUDGET_MS = 2500;
const ENCODE = (text) => ({ class_type: "CLIPTextEncode", inputs: { text, clip: ["4", 1] } });
const KSAMPLER = (positive, negative, latent) => ({ class_type: "KSampler", inputs: { seed: 1, steps: 20, cfg: 8.0, sampler_name: "euler", scheduler: "normal",
denoise: 1.0, model: ["4", 0], positive, negative, latent_image: latent } });
const LOADERS = { "4": { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "m.safetensors" } }, "5": { class_type: "EmptyLatentImage", inputs: { width: 64, height: 64, batch_size: 1 } } };
/** The largest graph of this shape whose JSON fits the role budget. */
function maximal(build, high) {
let low = 1;
while (low < high) {
const middle = (low + high + 1) >> 1;
if (Buffer.byteLength(JSON.stringify(build(middle)), "utf8") <= ROLE_BYTES) low = middle;
else high = middle - 1;
}
return JSON.stringify(build(low));
}
const MAXIMAL_GRAPHS = {
// Many candidates: every sampler is a final stage with its own pair.
independent: maximal((count) => {
const graph = { ...LOADERS };
for (let index = 0; index < count; index++) {
graph[`s${index}`] = KSAMPLER([`p${index}`, 0], [`n${index}`, 0], ["5", 0]);
graph[`p${index}`] = ENCODE(`fox ${index}`);
graph[`n${index}`] = ENCODE(`blur ${index}`);
}
return graph;
}, 6666),
// One long stage chain through the latent: the deepest stage walk.
chain: maximal((count) => {
const graph = { ...LOADERS, "6": ENCODE("fox"), "7": ENCODE("blur") };
for (let index = 0; index < count; index++) graph[`s${index}`] = KSAMPLER(["6", 0], ["7", 0], index ? [`s${index - 1}`, 0] : ["5", 0]);
return graph;
}, 19996),
};
test("resolution of a maximal 2 MiB graph stays within the step budget", async () => {
const { resolveLocalEvidence } = await import("../../web/js/workspace/local-resolver.mjs");
const semantics = await (await import("../../web/js/workspace/local-semantics.mjs")).loadInspectionSemantics();
for (const [name, prompt] of Object.entries(MAXIMAL_GRAPHS)) {
assert.ok(Buffer.byteLength(prompt, "utf8") > ROLE_BYTES - 1024, `${name} fills the role budget`);
const started = performance.now();
const evidence = resolveLocalEvidence({ prompt }, semantics);
const elapsed = performance.now() - started;
assert.ok(elapsed < STEP_BUDGET_MS, `${name}: ${Math.round(elapsed)} ms`);
assert.notEqual(evidence.resolution.reason_code, "malformed", name);
}
});
test("a read yields between bounded steps and answers byte for byte as one synchronous build", async () => {
const semantics = await (await import("../../web/js/workspace/local-semantics.mjs")).loadInspectionSemantics();
const file = pngFile("maximal.png", [tEXt("prompt", MAXIMAL_GRAPHS.independent)]);
const handle = await handleFor(file);
const marks = [];
const yieldControl = async (signal) => {
signal?.throwIfAborted();
marks.push(performance.now());
await new Promise((resolve) => setTimeout(resolve, 0));
};
const started = performance.now();
const wire = await m.localFileAdapter.read(handle, { signal: new AbortController().signal, yieldControl });
marks.push(performance.now());
// Resolution and the projection each run in a turn of their own after the table is in.
assert.ok(marks.length >= 3, `yields: ${marks.length - 1}`);
const gaps = marks.map((mark, index) => mark - (index ? marks[index - 1] : started));
assert.ok(Math.max(...gaps) < STEP_BUDGET_MS, `longest step ${Math.round(Math.max(...gaps))} ms`);
const { result } = await m.readLocalFile(file, { classification: handle.classification });
const direct = await m.fitLocalPayload(m.buildLocalPayload({ read: result, file: { name: handle.filename, size: file.size, lastModified: file.lastModified },
classification: handle.classification, semantics }));
assert.equal(JSON.stringify(wire), JSON.stringify(direct));
assert.equal(wire.resolution.state, "ambiguous");
// An abort at any yield ends the read there.
for (let stop = 0; stop < marks.length - 1; stop++) {
const controller = new AbortController();
let seen = 0;
const stopping = async (signal) => {
if (seen++ === stop) controller.abort();
signal.throwIfAborted();
await new Promise((resolve) => setTimeout(resolve, 0));
};
await assert.rejects(m.localFileAdapter.read(handle, { signal: controller.signal, yieldControl: stopping }), { name: "AbortError" }, `yield ${stop}`);
}
});
-172
View File
@@ -1,172 +0,0 @@
// PR L3 Tasks 8-9: the server's inspection resolver in this browser. The values below are what CPython answers
// (json.loads, len, str.strip, repr, utils/prompt_inspector.inspect_prompt_evidence); the full comparison with the
// server runs in tests/test_local_inspect_parity.py through tests/js/support/local-inspect-oracle.mjs.
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
const py = await import('../../web/js/workspace/py-json.mjs');
const resolver = await import('../../web/js/workspace/local-resolver.mjs');
const semantics = resolver.compileSemantics(JSON.parse(fs.readFileSync(new URL('../../web/js/inspection-semantics.json', import.meta.url), 'utf8')));
test('json.loads keeps Python value kinds and dict order', () => {
const value = py.pyJsonLoads('{"9": 1, "10": 1.0, "a": NaN, "9": [true, -0, 9007199254740993, -Infinity]}');
assert.ok(value instanceof Map);
// A repeated key keeps its first position and takes its last value; integer-like keys keep text order.
assert.deepEqual([...value.keys()], ['9', '10', 'a']);
const [flag, zero, big, negative] = value.get('9');
assert.equal(flag, true); assert.equal(zero, 0); assert.equal(big, 9007199254740993n);
assert.ok(negative instanceof py.PyFloat); assert.equal(negative.value, -Infinity);
assert.ok(value.get('10') instanceof py.PyFloat, '1.0 is a float, not an int');
assert.ok(Number.isNaN(value.get('a').value));
assert.equal(py.isInt(value.get('10')), false);
assert.equal(py.pyJsonLoads('"\\ud83d\\ude00\\ud800"'), '\u{1f600}\ud800');
for (const bad of ['{}', '{"a":1,}', '[01]', '{"a" 1}', '"\u0001"', '"\\x"', '1 2', '-', `${'9'.repeat(4301)}`, '"\\ud800\\uzzzz"']) {
assert.throws(() => py.pyJsonLoads(bad), py.PyValueError, bad);
}
assert.equal(py.pyJsonLoads(` ${'9'.repeat(4300)}\n`), BigInt('9'.repeat(4300)));
});
// One depth cap for both browser CPython-JSON parsers (py-json.mjs here, local-video.mjs for video envelopes): every
// array or object counts, an empty one too, as CPython's scanner enters recursion for each; past the cap it refuses.
test('json.loads refuses nesting past the shared depth cap, counting every container', () => {
const video = import('../../web/js/workspace/local-video.mjs');
assert.equal(py.JSON_MAX_DEPTH, 1000);
const nest = (depth, inner = '') => '['.repeat(depth) + inner + ']'.repeat(depth);
const objects = (depth, inner) => '{"a":'.repeat(depth) + inner + '}'.repeat(depth);
for (const text of [nest(1000), nest(1000, '1'), nest(999, '{}'), objects(1000, '1'), objects(999, '[]')]) assert.doesNotThrow(() => py.pyJsonLoads(text));
for (const text of [nest(1001), nest(1001, '1'), nest(1000, '{}'), objects(1001, '1'), objects(1000, '[]')]) {
assert.throws(() => py.pyJsonLoads(text), py.PyValueError);
}
return video.then(({ pythonJsonLoads, PythonJsonError }) => {
for (const text of [nest(1000), nest(1000, '1'), objects(999, '[]')]) assert.doesNotThrow(() => pythonJsonLoads(text));
for (const text of [nest(1001), nest(1001, '1'), objects(1000, '[]')]) {
assert.throws(() => pythonJsonLoads(text), error => error instanceof PythonJsonError && error.recursion === true);
}
});
});
test('str helpers measure, slice and strip as CPython', () => {
assert.equal(py.pyLen('a\u{1f600}\ud800'), 3);
assert.equal(py.pySlice('\u{1f600}\u{1f600}x', 2), '\u{1f600}\u{1f600}');
// str.isspace() includes \x1c-\x1f and \x85 and excludes , unlike String.prototype.trim.
assert.equal(py.pyStrip('\x1c\x85 a b \x1f'), 'a b');
assert.equal(py.pyStrip('a'), 'a');
assert.deepEqual([1.0, 0.0001, 1e-5, 1e16, 123456789012345.6, -2.5, 1e22].map(py.pyFloatRepr),
['1.0', '0.0001', '1e-05', '1e+16', '123456789012345.6', '-2.5', '1e+22']);
assert.deepEqual([null, true, 0, 1n, new py.PyFloat(2), '', [], new Map()].map(py.pyTruthy), [false, true, false, true, true, false, false, false]);
assert.deepEqual([1, 1n, true, new py.PyFloat(1), '1', 2].map(py.pyEqualsOne), [true, true, true, true, false, false]);
});
const encode = text => ({class_type: 'CLIPTextEncode', inputs: {text, clip: ['4', 1]}});
const sampler = (extra = {}) => ({class_type: 'KSampler', inputs: {seed: 1, positive: ['6', 0], negative: ['7', 0], latent_image: ['5', 0], model: ['4', 0], ...extra}});
const base = (nodes = {}) => ({'3': sampler(), '4': {class_type: 'CheckpointLoaderSimple', inputs: {ckpt_name: 'm.safetensors'}},
'5': {class_type: 'EmptyLatentImage', inputs: {width: 64, height: 64, batch_size: 1}}, '6': encode('a fox'), '7': encode('blurry'), ...nodes});
test('a single chain resolves both roles with their sources', () => {
const evidence = resolver.resolveLocalEvidence({prompt: JSON.stringify(base())}, semantics);
assert.deepEqual(evidence, {
prompts: {positive: {status: 'resolved', text: 'a fox', source: 'graph-inferred'}, negative: {status: 'resolved', text: 'blurry', source: 'graph-inferred'}},
resolution: {state: 'resolved', sampler_id: '3', reason: '', reason_code: null, node: null},
});
});
test('records without roles, malformed text and a missing sampler answer as the server', () => {
assert.deepEqual(resolver.resolveLocalEvidence({workflow: '{}'}, semantics).resolution,
{state: 'absent', sampler_id: null, reason: 'No parsed prompt metadata', reason_code: null, node: null});
const malformed = resolver.resolveLocalEvidence({prompt: '{broken'}, semantics);
assert.equal(malformed.resolution.reason, 'Prompt evidence is malformed');
assert.equal(malformed.prompts.positive.message, 'Prompt metadata in this file is malformed.');
const none = resolver.resolveLocalEvidence({prompt: JSON.stringify({'1': encode('x'), '2': {class_type: 'MySampler', inputs: {positive: ['1', 0]}}})}, semantics);
assert.deepEqual(none.resolution, {state: 'unresolved', sampler_id: null, reason: 'No supported sampler pair found (saw MySampler)', reason_code: 'no-sampler', node: null});
assert.equal(none.prompts.negative.message, 'No supported sampler was found.');
assert.equal(resolver.resolveLocalEvidence({prompt: JSON.stringify(base()), parameters: 'x'}, null).resolution.reason, 'Inspection node semantics are unavailable');
});
test('a float output slot is not a link, and a runtime node is named', () => {
const floatSlot = resolver.resolveLocalEvidence({prompt: JSON.stringify(base()).replace('"positive":["6",0]', '"positive":["6",0.0]')}, semantics);
assert.deepEqual(floatSlot.resolution, {state: 'unresolved', sampler_id: '3', reason: 'Expected a typed graph link or text input', reason_code: 'malformed', node: null});
const runtime = resolver.resolveLocalEvidence({prompt: JSON.stringify(base({'6': {class_type: 'PromptSearchList', inputs: {}}}))}, semantics);
assert.deepEqual(runtime.resolution.node, {id: '6', class_type: 'PromptSearchList'});
assert.equal(runtime.prompts.positive.message, 'Prompt is chosen at run time (PromptSearchList).');
});
test('execution evidence replaces a PromptManagerPair text when it matches the node inputs', () => {
const pair = {class_type: 'PromptManagerPair', inputs: {text: 'fox', negative_text: 'blur', prepend_text: '', append_text: ''}};
const record = {text: 'fox', negative_text: 'blur', positive_text: 'fox, executed', negative_effective_text: 'blur', prepend_text: '', append_text: ''};
const graph = base({'3': sampler({positive: ['8', 0], negative: ['8', 1]}), '8': pair});
const evidence = resolver.resolveLocalEvidence({prompt: JSON.stringify(graph), comfyassets_prompt_pairs: JSON.stringify({version: 1, nodes: {'8': {records: [record]}}})}, semantics);
assert.deepEqual(evidence.prompts.positive, {status: 'resolved', text: 'fox, executed', source: 'execution'});
const stale = resolver.resolveLocalEvidence({prompt: JSON.stringify(graph), comfyassets_prompt_pairs: JSON.stringify({version: 1, nodes: {'8': {records: [{...record, text: 'other'}]}}})}, semantics);
assert.equal(stale.resolution.reason, 'Execution evidence does not match recorded node inputs');
});
test('identical sampler branches resolve to their shared pair, differing ones stay ambiguous (#270)', () => {
const shared = resolver.resolveLocalEvidence({prompt: JSON.stringify(base({'9': sampler()}))}, semantics);
assert.deepEqual(shared.resolution, {state: 'resolved', sampler_id: null, reason: 'Every sampler branch uses the same prompt pair', reason_code: null, node: null});
assert.equal(shared.prompts.positive.text, 'a fox');
const differ = resolver.resolveLocalEvidence({prompt: JSON.stringify(base({'9': sampler({positive: ['8', 0]}), '8': encode('a cat')}))}, semantics);
assert.deepEqual(differ.resolution, {state: 'ambiguous', sampler_id: null, reason: 'Multiple sampler branches', reason_code: null, node: null});
assert.equal(differ.prompts.positive.status, 'ambiguous');
});
test('a detailer stage is compared with the sampler in its image ancestry', () => {
const graph = base({'10': {class_type: 'VAEDecode', inputs: {samples: ['3', 0]}}, '8': encode('a cat'), '12': {class_type: 'SaveImage', inputs: {images: ['11', 0]}},
'11': {class_type: 'FaceDetailer', inputs: {image: ['10', 0], model: ['4', 0], positive: ['8', 0], negative: ['7', 0]}}});
const evidence = resolver.resolveLocalEvidence({prompt: JSON.stringify(graph)}, semantics);
assert.deepEqual(evidence.resolution, {state: 'ambiguous', sampler_id: '11', reason: 'Sampler stages have different prompts', reason_code: 'multistage-differs', node: null});
assert.equal(evidence.prompts.positive.message, undefined);
});
test('A1111 parameters split on the unique settings footer as Python does', () => {
const read = text => resolver.resolveLocalEvidence({parameters: text}, semantics);
assert.deepEqual(read('a fox\nNegative prompt: blur\nSteps: 20, Sampler: euler').prompts, {
positive: {status: 'resolved', text: 'a fox', source: 'embedded-parameters'}, negative: {status: 'resolved', text: 'blur', source: 'embedded-parameters'},
});
// Python's multiline ^ and $ break only at \n: a U+2028 or a lone \r never starts the footer line.
for (const text of ['a fox\u2028Steps: 20, x', 'a fox\rSteps: 20, x']) assert.equal(read(text).resolution.reason, 'A1111 settings footer is not unique');
// \d is any Unicode decimal digit and \s spans line breaks, as in Python's re.
assert.equal(read('a fox\nSteps: \u0662\u0660, x').resolution.reason, 'Negative prompt delimiter is absent');
assert.equal(read('a fox\nSteps:\n 20 \n, x').resolution.state, 'absent');
assert.equal(read('a fox\nSteps: 1, x\nNegative Template: n\nTemplate: t').resolution.reason_code, 'malformed');
assert.equal(read('\u{1f600}'.repeat(60000) + '\nSteps: 1, x').resolution.state, 'absent', 'the length limit counts code points');
});
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', () => {
const {vectors} = JSON.parse(fs.readFileSync(new URL('../fixtures/local-inspect/resolver-vectors.json', import.meta.url), 'utf8'));
const canonical = value => Array.isArray(value) ? value.map(canonical) :
value !== null && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value;
let compared = 0;
for (const {name, records, evidence} of vectors) {
assert.deepEqual(canonical(resolver.resolveLocalEvidence(records, semantics)), canonical(evidence), name);
compared++;
}
assert.ok(compared > 280, `compared ${compared}`);
});
test('a list or object workflow node type is skipped, as on the server (#286)', () => {
const graph = base({'6': {class_type: 'PromptManager', inputs: {text: 'a fox', clip: ['4', 1]}}});
for (const type of [['PromptManager'], {name: 'PromptManager'}]) {
const evidence = resolver.resolveLocalEvidence({prompt: JSON.stringify(graph), workflow: JSON.stringify({nodes: [{id: 6, type, widgets_values: ['x']}]})}, semantics);
assert.equal(evidence.resolution.state, 'resolved');
assert.equal(evidence.prompts.positive.status, 'resolved');
assert.equal(evidence.prompts.positive.text, 'a fox');
}
});
@@ -1,43 +0,0 @@
// web/js/workspace/local-semantics.mjs (#249 local Inspect, PR L3 Task 10): the node table local Inspect resolves
// with is the server's own web/js/inspection-semantics.json, fetched once through the API client before a file is
// chosen. A table that cannot be fetched or used answers as the server does when its table is unusable.
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
const TABLE = fs.readFileSync(new URL("../../web/js/inspection-semantics.json", import.meta.url), "utf8");
let instance = 0;
/** A fresh module instance (its cache is per page) with `fetch` and the API client stubbed. */
async function load(respond) {
const calls = [];
globalThis.window = { ApiClient: { resolveUrl: (path) => `/proxy${path}` } };
globalThis.fetch = async (url, init) => { calls.push([url, init]); return respond(); };
const module = await import(`../../web/js/workspace/local-semantics.mjs?instance=${++instance}`);
return { module, calls };
}
test.after(() => { delete globalThis.window; delete globalThis.fetch; });
test("the table is fetched once through the API client and compiled", async () => {
const { module, calls } = await load(() => new Response(TABLE, { status: 200 }));
const [first, second] = await Promise.all([module.loadInspectionSemantics(), module.loadInspectionSemantics()]);
assert.equal(first, second);
assert.ok(first.samplers.has("KSampler"));
assert.equal(await module.loadInspectionSemantics(), first);
assert.deepEqual(calls, [["/proxy/prompt_manager/js/inspection-semantics.json", { credentials: "same-origin" }]]);
});
test("a failed or unusable table is null, and a later selection asks again", async () => {
for (const respond of [() => new Response("nope", { status: 404 }), () => new Response("{\"schema\": 2}", { status: 200 }), () => { throw new TypeError("offline"); }]) {
const { module, calls } = await load(respond);
assert.equal(await module.loadInspectionSemantics(), null);
assert.equal(await module.loadInspectionSemantics(), null);
assert.equal(calls.length, 2);
}
});
test("without the API client nothing is fetched", async () => {
const { module, calls } = await load(() => new Response(TABLE));
delete globalThis.window;
assert.equal(await module.loadInspectionSemantics(), null);
assert.deepEqual(calls, []);
});
+2 -2
View File
@@ -193,8 +193,8 @@ test("unclassified input creates no object URL", async () => {
// declares a supported type it opens the inspector's unreadable state, with no preview and no URL (ledger ruling).
const gone = pngFile("gone.png", "fox", { gate: () => Promise.reject(new DOMException("The file was removed", "NotReadableError")) });
assert.equal(value.select([gone]), "accepted");
// The read yields between its steps (local-inspection.mjs): wait for the answer, not a fixed number of turns.
const unreadable = await recorded(value.controller);
await settle();
const unreadable = value.controller.state;
assert.ok(unreadable.record, "the unreadable record is published");
assert.deepEqual([value.state.phase, value.state.message, value.state.preview], ["ready", m.LOCAL_SESSION_MESSAGES.unreadable, null]);
assert.equal(m.LOCAL_SESSION_MESSAGES.unreadable, "Embedded metadata could not be read.");
+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();
});
+2 -44
View File
@@ -23,50 +23,8 @@ test('tags is a native route titled Tags; only unbuilt routes hand off to legacy
assert.equal(routeTitle(route), route.label);
}
assert.equal(routeTitle(resolveRoute('#/library/7')), 'Prompt library');
// System is native from S2 (#249 System): its undelivered sections hand off per section (D94), not the route.
for (const id of ['images', 'library', 'tags', 'inspect', 'overview', 'activity', 'system']) assert.equal(isHandoff(resolveRoute(`#/${id}`)), false, id);
});
test('system resolves its sections; #/overview stays the production hub', async () => {
const { resolveRoute, routeTitle, isHandoff, routes } = await source;
// `#/system` is the Overview section and keeps the sidebar's hash.
const system = resolveRoute('#/system');
assert.deepEqual([system.id, system.section, system.hash, routeTitle(system), isHandoff(system)],
['system', 'overview', '#/system', 'System', false]);
for (const section of ['overview', 'appearance', 'sources', 'integrations', 'tagging', 'maintenance', 'backups', 'diagnostics']) {
const route = resolveRoute(`#/system/${section}`);
assert.deepEqual([route.id, route.section, route.hash], ['system', section, `#/system/${section}`]);
}
// An unknown child is the Overview section with a replaced hash.
for (const hash of ['#/system/general', '#/system/', '#/system/__proto__', '#/system/backups/1', '#/system/<b>']) {
const route = resolveRoute(hash);
assert.deepEqual([route.id, route.section, route.hash], ['system', 'overview', '#/system/overview'], hash);
}
// D28: the production hub is untouched.
const hub = resolveRoute('#/overview');
assert.deepEqual([hub.id, hub.section], ['overview', undefined]);
// Q12: Overview's System card opens the native System Overview, with no legacy href or action.
const card = routes.find(route => route.id === 'system');
assert.equal(card.href, '#/system/overview');
assert.equal(card.native, true);
assert.equal(card.action, 'Open System');
});
test('the router renders again when only the System section changes', async () => {
const { mountRouter } = await source;
const win = new EventTarget();
win.location = { hash: '#/system' };
const replacements = [];
win.history = { state: null, replaceState(state, unused, hash) { replacements.push(hash); win.location.hash = hash; } };
const seen = [];
const dispose = mountRouter(win, route => seen.push([route.id, route.section]));
for (const hash of ['#/system/appearance', '#/system/appearance', '#/system/bogus', '#/system/overview', '#/images']) {
win.location.hash = hash;
win.dispatchEvent(new Event('hashchange'));
}
assert.deepEqual(seen, [['system', 'overview'], ['system', 'appearance'], ['system', 'overview'], ['images', undefined]]);
assert.deepEqual(replacements, ['#/system/overview']);
dispose();
for (const id of ['images', 'library', 'tags', 'inspect', 'overview', 'activity']) assert.equal(isHandoff(resolveRoute(`#/${id}`)), false, id);
for (const id of ['system']) assert.equal(isHandoff(resolveRoute(`#/${id}`)), true, id);
});
test('inspect is the native Inspect a file page with the concept description', async () => {
@@ -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(); }
});
-214
View File
@@ -1,214 +0,0 @@
// System controls (#249 System S2 Task 5): the concept's `setting()` (app.js 624–634, its toggles hydrated into Toolkit
// switches by `hydrateSwitches` 708–717), `operation()` (645–654) and `sectionNav()` (306–308) as DOM builders whose
// listeners live on the caller's scope.
const test = require('node:test');
const assert = require('node:assert/strict');
const controlsModule = import('../../web/js/workspace/system-controls.mjs');
const coreModule = import('../../web/js/shared/toolkit/core.js');
/** 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);
}
}
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; },
};
}
test('settingRow is the concept row: div > label[for] + p, then the control', async () => {
const { settingRow } = await controlsModule;
const { createScope } = await coreModule;
const dom = fakeDOM();
const scope = createScope();
try {
const changes = [];
const row = settingRow({ id: 'timeout', label: 'Node search auto-hide (seconds)',
description: 'Node search behavior only. Use 0 to keep results visible.',
control: { type: 'number', value: 30, min: 0, max: 300 }, scope, onChange: value => changes.push(value) });
assert.equal(row.className, 'setting-row');
assert.deepEqual(dom.tags(row), ['div', 'input']);
const [text, input] = row.children;
assert.deepEqual(dom.tags(text), ['label', 'p']);
assert.equal(text.children[0].textContent, 'Node search auto-hide (seconds)');
assert.equal(text.children[0].htmlFor, 'timeout');
assert.equal(text.children[1].textContent, 'Node search behavior only. Use 0 to keep results visible.');
assert.deepEqual([input.id, input.type, input.value, input.min, input.max], ['timeout', 'number', '30', '0', '300']);
assert.equal(input.dataset.setting, 'timeout');
input.value = '45';
input.dispatch('change');
assert.deepEqual(changes, [45]);
const select = settingRow({ id: 'open-mode', label: 'Open from ComfyUI', description: 'Keep the node pack’s popup / new-tab preference.',
control: { type: 'select', options: [['newtab', 'New tab'], ['popup', 'Popup window']], value: 'popup' }, scope,
onChange: value => changes.push(value) }).children[1];
assert.equal(select.tagName, 'SELECT');
assert.deepEqual(select.children.map(option => [option.value, option.textContent]), [['newtab', 'New tab'], ['popup', 'Popup window']]);
assert.equal(select.value, 'popup');
select.value = 'newtab';
select.dispatch('change');
assert.deepEqual(changes, [45, 'newtab']);
// The concept's range is 1–16 unless the row says otherwise (D113: the CPU count).
const range = settingRow({ id: 'workers', label: 'Worker threads', description: 'Balance background scans.',
control: { type: 'range', value: 4 } }).children[1];
assert.deepEqual([range.type, range.min, range.max, range.value], ['range', '1', '16', '4']);
const password = settingRow({ id: 'civitai-key', label: 'CivitAI API key', description: 'Credentials stay on the server.',
control: { type: 'password', value: '' } }).children[1];
assert.equal(password.type, 'password');
// A ready-made control is placed as given and takes the row id.
const custom = document.createElement('textarea');
const customRow = settingRow({ id: 'custom', label: 'Custom', description: 'Any control.', control: custom });
assert.equal(customRow.children[1], custom);
assert.equal(custom.id, 'custom');
assert.throws(() => settingRow({ id: '', label: 'x', description: 'y', control: { type: 'number' } }), TypeError);
assert.throws(() => settingRow({ id: 'x', label: 'x', description: 'y', control: { type: 'colour' } }), TypeError);
} finally {
scope.dispose();
dom.restore();
}
});
test('a toggle row holds the Toolkit switch, labelled by the row label', async () => {
const { settingRow } = await controlsModule;
const { createScope } = await coreModule;
const dom = fakeDOM();
const scope = createScope();
try {
const changes = [];
const row = settingRow({ id: 'video-auto', label: 'Autoplay videos', description: 'Videos stay within the same gallery.',
control: { type: 'toggle', checked: false }, scope, onChange: value => changes.push(value) });
const field = row.children[1];
// createSwitch('', '', id): `label.ca-switch` > input[type=checkbox][role=switch] + an empty label text.
assert.equal(field.tagName, 'LABEL');
assert.equal(field.className, 'ca-switch');
const input = field.children[0];
assert.deepEqual([input.type, input.role, input.id, input.checked], ['checkbox', 'switch', 'video-auto', false]);
assert.equal(row.children[0].children[0].htmlFor, input.id);
assert.equal(input.dataset.setting, 'video-auto');
input.checked = true;
input.dispatch('change');
assert.deepEqual(changes, [true]);
// The concept's toggles are on unless configured off (`val !== false`).
const on = settingRow({ id: 'video-loop', label: 'Loop videos', description: 'Repeat a clip.', control: { type: 'toggle' } });
assert.equal(on.children[1].children[0].checked, true);
} finally {
scope.dispose();
dom.restore();
}
});
test('operationRow is the concept operation with a danger class only when flagged', async () => {
const { operationRow } = await controlsModule;
const { createScope } = await coreModule;
const dom = fakeDOM();
const scope = createScope();
try {
const clicks = [];
const row = operationRow({ title: 'Missing-file links',
description: 'Remove stale database links only. Files on disk are never deleted by this operation.',
label: 'Review plan', danger: true, scope, onClick: button => clicks.push(button) });
assert.equal(row.className, 'operation');
assert.deepEqual(dom.tags(row), ['div', 'div']);
assert.deepEqual(dom.tags(row.children[0]), ['h3', 'p']);
assert.equal(row.children[0].children[0].textContent, 'Missing-file links');
assert.equal(row.children[1].className, 'row-actions');
const button = row.children[1].children[0];
assert.deepEqual([button.tagName, button.type, button.textContent, button.className], ['BUTTON', 'button', 'Review plan', 'danger']);
button.click();
assert.deepEqual(clicks, [button]);
const plain = operationRow({ title: 'Statistics', description: 'Usage by prompt, category, tag and date.', label: 'Explore',
scope, onClick() {} });
assert.equal(plain.children[1].children[0].className, '');
assert.throws(() => operationRow({ title: 'x', description: 'y', label: 'z', onClick() {} }), TypeError);
} finally {
scope.dispose();
dom.restore();
}
});
test('sectionNav marks the active section and reports the chosen one', async () => {
const { sectionNav } = await controlsModule;
const { createScope } = await coreModule;
const dom = fakeDOM();
const scope = createScope();
try {
const chosen = [];
const nav = sectionNav({ label: 'System sections', sections: [['overview', 'Overview'], ['appearance', 'Appearance']],
active: 'appearance', scope, onSelect: id => chosen.push(id) });
assert.equal(nav.className, 'section-nav');
assert.equal(nav.getAttribute('role'), 'group');
assert.equal(nav.getAttribute('aria-label'), 'System sections');
assert.deepEqual(nav.children.map(button => [button.textContent, button.getAttribute('aria-pressed'), button.dataset.value]),
[['Overview', 'false', 'overview'], ['Appearance', 'true', 'appearance']]);
nav.children[0].click();
assert.deepEqual(chosen, ['overview']);
} finally {
scope.dispose();
dom.restore();
}
});
test('disposing the scope leaves no listener behind', async () => {
const { settingRow, operationRow, sectionNav } = await controlsModule;
const { createScope } = await coreModule;
const dom = fakeDOM();
const scope = createScope();
try {
settingRow({ id: 'a', label: 'A', description: 'a', control: { type: 'toggle' }, scope, onChange() {} });
settingRow({ id: 'b', label: 'B', description: 'b', control: { type: 'select', options: ['Grid', 'List'] }, scope, onChange() {} });
operationRow({ title: 'C', description: 'c', label: 'Go', scope, onClick() {} });
sectionNav({ label: 'System sections', sections: [['overview', 'Overview']], active: 'overview', scope, onSelect() {} });
assert.equal(dom.listenerCount() > 0, true);
scope.dispose();
assert.equal(dom.listenerCount(), 0);
} finally {
scope.dispose();
dom.restore();
}
});
-255
View File
@@ -1,255 +0,0 @@
// System › Overview (#249 System S2 Task 6): the concept's Overview copy on real values (D93), the Health report rows
// (D95), the Statistics tables (D96) and the health check run that waits for its read job.
const test = require('node:test');
const assert = require('node:assert/strict');
const overviewModule = import('../../web/js/workspace/system-overview.mjs');
const OVERVIEW = Object.freeze({
library: { positive_prompts: 1234, negative_variants: 7 },
images: { indexed: 24, rated: 20 },
health: null,
backup: null,
needs_review: { pending: null },
sources: { configured: 2, effective: 2, auto_detected: false },
});
test('metrics are the server counts and Not checked before any health check', async () => {
const { metricValues } = await overviewModule;
assert.deepEqual(metricValues(OVERVIEW), [
[(1234).toLocaleString(), 'Positive prompts'], ['7', 'Negative variants'], ['24', 'Images'],
['Not checked', 'Database integrity'],
]);
});
test('metric 4 is the last health result', async () => {
const { integrityText } = await overviewModule;
assert.equal(integrityText(null), 'Not checked');
assert.equal(integrityText({ status: 'passed', issues: 0 }), 'Passed');
assert.equal(integrityText({ status: 'issues', issues: 1 }), '1 issue');
assert.equal(integrityText({ status: 'issues', issues: 3 }), '3 issues');
assert.equal(integrityText({ status: 'incomplete', issues: 0 }), 'Incomplete');
// Issues found beside a check that could not run (Needs review, Q6) are still named.
assert.equal(integrityText({ status: 'incomplete', issues: 2 }), '2 issues');
});
test('library health rows never invent a review count, a backup or thumbnails', async () => {
const { ambiguousTitle, backupText } = await overviewModule;
assert.equal(ambiguousTitle({ pending: null }), 'Ambiguous image links');
assert.equal(ambiguousTitle(undefined), 'Ambiguous image links');
assert.equal(ambiguousTitle({ pending: 1 }), '1 ambiguous image link');
assert.equal(ambiguousTitle({ pending: 3 }), '3 ambiguous image links');
assert.equal(backupText(null), 'No retained backup record.');
assert.equal(backupText({ verified_at: 'not a time' }), 'No retained backup record.');
assert.match(backupText({ verified_at: '2026-10-22T09:30:00Z' }), /^Last verified backup .+\.$/);
});
test('the thumbnail row is its own read of the thumbnail status', async () => {
const { thumbnailTitle, THUMBNAILS_LOADING } = await overviewModule;
// Until `/images/thumbnails/status` answers the row says it is checking; a failed read is unavailable, never 0.
assert.equal(THUMBNAILS_LOADING, 'Checking thumbnails…');
assert.equal(thumbnailTitle(undefined), THUMBNAILS_LOADING);
assert.equal(thumbnailTitle({ pending: 0 }), 'No missing thumbnails');
assert.equal(thumbnailTitle({ pending: 1 }), '1 missing thumbnail');
assert.equal(thumbnailTitle({ pending: 12 }), '12 missing thumbnails');
assert.equal(thumbnailTitle(null), 'Thumbnail status unavailable');
assert.equal(thumbnailTitle({ pending: 'x' }), 'Thumbnail status unavailable');
// With no image source there is nothing to count.
assert.equal(thumbnailTitle({ pending: 0 }, { effective: 0 }), 'Thumbnail status unavailable');
assert.equal(thumbnailTitle({ pending: 2 }, { effective: 1 }), '2 missing thumbnails');
});
test('an integrity count SQLite stopped at reads as a lower bound', async () => {
const { checkChip } = await overviewModule;
assert.equal(checkChip({ id: 'integrity', status: 'issues', count: 100, capped: true }), '100+ problems found');
assert.equal(checkChip({ id: 'integrity', status: 'issues', count: 37, capped: false }), '37 problems found');
assert.equal(checkChip({ id: 'integrity', status: 'issues', count: 1 }), '1 problem found');
});
test('the sources row tells configured, available and detected sources apart', async () => {
const { sourcesCopy } = await overviewModule;
const shared = 'Monitoring and enrichment share one source list.';
assert.deepEqual(sourcesCopy({ configured: 2, effective: 2, auto_detected: false }), ['2 output sources', shared]);
assert.deepEqual(sourcesCopy({ configured: 2, effective: 1, auto_detected: false }),
['1 output source', `1 of 2 configured sources available. ${shared}`]);
assert.deepEqual(sourcesCopy({ configured: 0, effective: 1, auto_detected: true }),
['1 output source', `None configured is available; ComfyUI's output folder is used. ${shared}`]);
});
const REPORT = Object.freeze({
checked_at: '2026-10-23T08:00:00Z',
status: 'issues',
checks: [
{ id: 'integrity', label: 'Integrity check', status: 'passed', count: 0 },
{ id: 'foreign_keys', label: 'Foreign keys & pair references', status: 'issues', count: 2 },
{ id: 'canonical_hashes', label: 'Canonical hashes', status: 'issues', count: 1 },
{ id: 'pair_uniqueness', label: 'Pair uniqueness', status: 'failed', count: null },
{ id: 'missing_files', label: 'Missing files', status: 'issues', count: 2,
sources: [{ name: 'output', status: 'checked', count: 2 }] },
{ id: 'ambiguous_metadata', label: 'Ambiguous metadata', status: 'unavailable', count: null },
{ id: 'consistency', label: 'Consistency', status: 'issues', count: null },
],
});
test('health rows give each check a chip from its real status, in the server order', async () => {
const { healthRows } = await overviewModule;
assert.deepEqual(healthRows(REPORT), [
['Integrity check', 'Passed'],
['Foreign keys & pair references', '2 broken references'],
['Canonical hashes', '1 hash does not match its text'],
['Pair uniqueness', 'Could not check'],
['Missing files', '2 links need review'],
['Ambiguous metadata', 'Not available yet'],
['Consistency', 'Issues found'],
]);
});
test('missing files is reported per image source when there are several', async () => {
const { healthRows } = await overviewModule;
const report = { ...REPORT, checks: [{ id: 'missing_files', label: 'Missing files', status: 'issues', count: 1, partial: true,
sources: [{ name: 'output', status: 'checked', count: 1 }, { name: 'input', status: 'checked', count: 0 },
{ name: 'archive', status: 'unavailable', count: null }] }] };
assert.deepEqual(healthRows(report), [
['Missing files · output', '1 link needs review'],
['Missing files · input', 'Passed'],
['Missing files · archive', 'Not available'],
]);
const unavailable = { ...REPORT, checks: [{ id: 'missing_files', label: 'Missing files', status: 'unavailable', count: null, sources: [] }] };
assert.deepEqual(healthRows(unavailable), [['Missing files', 'Not available']]);
});
test('the report summary says when it was checked and whether it is complete', async () => {
const { healthSummary } = await overviewModule;
const when = new Date(REPORT.checked_at).toLocaleString();
assert.equal(healthSummary({ ...REPORT, status: 'passed' }), `Checked ${when}. Every check passed.`);
assert.equal(healthSummary({ ...REPORT, status: 'incomplete' }), `Checked ${when}. Some checks could not run, so this report is incomplete.`);
assert.equal(healthSummary({ ...REPORT, checked_at: 'x' }), 'Nothing was changed; review the checks with issues.');
});
test('statistics rows are the real runs and ratings, then the library totals', async () => {
const { categoryRows, totalsRows } = await overviewModule;
const statistics = {
by_category: [{ category: 'Landscapes', runs: 1200, average_rating: 4.5 },
{ category: null, runs: 3, average_rating: null }],
totals: { total_prompts: 12, total_tags: 15, total_images: 24 },
rated_images: 20,
};
assert.deepEqual(categoryRows(statistics), [['Landscapes', (1200).toLocaleString(), '4.5 / 5'], ['Uncategorized', '3', 'Not rated']]);
assert.deepEqual(totalsRows(statistics), [['Prompts', '12'], ['Tags', '15'], ['Images', '24'], ['Rated images', '20']]);
});
/** A fake api answering each `get`/`post` from a queue per URL, recording the calls. */
function fakeApi(answers) {
const calls = [];
const answer = (method, url) => {
calls.push(`${method} ${url}`);
const queue = answers[`${method} ${url}`];
if (!queue?.length) throw new Error(`unexpected ${method} ${url}`);
const next = queue.shift();
return next instanceof Error ? Promise.reject(next) : Promise.resolve(next);
};
return { calls, get: url => answer('GET', url), post: url => answer('POST', url) };
}
const JOB = '0123456789abcdef0123456789abcdef';
const job = (state, id = JOB, kind = 'health') => ({ id, kind, state });
/**
* A fake Activity store holding `jobs`: `track(job)` lists a started job, `load()` applies the next of `reads` (the
* server's job lists) and `push(job)` is a stream update; each notifies as the real store does. It never polls.
*/
function fakeActivity({ jobs = [], reads = [] } = {}) {
const listeners = new Set();
const emit = () => { for (const listener of [...listeners]) listener(); };
const store = {
loads: 0,
find: id => jobs.find(item => item.id === id),
running: () => jobs.filter(item => item.state === 'running'),
subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
listeners: () => listeners.size,
async load() {
store.loads += 1;
if (reads.length) jobs = reads.shift();
emit();
},
track(item) {
if (!item?.id) throw new TypeError('Invalid job');
store.push(item);
return item;
},
push(item) {
jobs = [...jobs.filter(other => other.id !== item.id), item];
emit();
},
};
return store;
}
test('a health check tracks its job in the Activity store, then reads the report', async () => {
const { runHealthCheck } = await overviewModule;
const api = fakeApi({
'POST /prompt_manager/system/health': [{ job: job('running') }],
'GET /prompt_manager/system/health': [{ report: REPORT }],
});
const activity = fakeActivity();
const checking = runHealthCheck(api, { activity });
await new Promise(resolve => setImmediate(resolve));
// The store follows the started job's event stream; nothing reads or polls the job here.
assert.equal(activity.loads, 0);
assert.equal(activity.find(JOB).state, 'running');
assert.deepEqual(api.calls, ['POST /prompt_manager/system/health']);
activity.push(job('finished'));
assert.equal(await checking, REPORT);
assert.deepEqual(api.calls, ['POST /prompt_manager/system/health', 'GET /prompt_manager/system/health']);
assert.equal(activity.listeners(), 0);
});
test('a check already running elsewhere is waited for, not refused', async () => {
const { runHealthCheck } = await overviewModule;
const busy = Object.assign(new Error('busy'), { status: 409, code: 'busy' });
const api = fakeApi({
'POST /prompt_manager/system/health': [busy],
'GET /prompt_manager/system/health': [{ report: REPORT }],
});
const activity = fakeActivity({ reads: [[job('running', 'f'.repeat(32), 'scan'), job('running')]] });
const checking = runHealthCheck(api, { activity });
await new Promise(resolve => setImmediate(resolve));
assert.equal(activity.loads, 1);
activity.push(job('finished'));
assert.equal(await checking, REPORT);
// The other check ended between the refusal and the read: its report is the latest.
const ended = fakeApi({ 'POST /prompt_manager/system/health': [busy], 'GET /prompt_manager/system/health': [{ report: REPORT }] });
assert.equal(await runHealthCheck(ended, { activity: fakeActivity({ reads: [[]] }) }), REPORT);
});
test('a failed, lost or reportless check rejects; other errors pass through', async () => {
const { runHealthCheck, waitForJob, HEALTH_FAILED_TEXT } = await overviewModule;
const failed = fakeApi({ 'POST /prompt_manager/system/health': [{ job: job('failed') }] });
await assert.rejects(runHealthCheck(failed, { activity: fakeActivity() }), { message: HEALTH_FAILED_TEXT });
// The store marks a job lost when the server no longer lists it (ComfyUI restarted).
const restarted = fakeActivity({ jobs: [job('running')] });
const waiting = waitForJob(restarted, JOB);
restarted.push(job('lost'));
assert.equal(await waiting, 'lost');
const empty = fakeApi({ 'POST /prompt_manager/system/health': [{ job: job('finished') }],
'GET /prompt_manager/system/health': [{ report: null }] });
await assert.rejects(runHealthCheck(empty, { activity: fakeActivity() }), { message: HEALTH_FAILED_TEXT });
const broken = fakeApi({ 'POST /prompt_manager/system/health': [Object.assign(new Error('server'), { status: 500 })] });
await assert.rejects(runHealthCheck(broken, { activity: fakeActivity() }), { message: 'server' });
const invalid = fakeApi({ 'POST /prompt_manager/system/health': [{ job: null }] });
await assert.rejects(runHealthCheck(invalid, { activity: fakeActivity() }), { message: 'Invalid job' });
});
test('waiting stops when its signal aborts', async () => {
const { waitForJob } = await overviewModule;
const controller = new AbortController();
const activity = fakeActivity({ jobs: [job('running')] });
const waiting = waitForJob(activity, JOB, { signal: controller.signal });
await new Promise(resolve => setImmediate(resolve));
controller.abort(new Error('left'));
await assert.rejects(waiting, { message: 'left' });
assert.equal(activity.listeners(), 0);
const gone = new AbortController();
gone.abort(new Error('already'));
await assert.rejects(waitForJob(fakeActivity(), JOB, { signal: gone.signal }), { message: 'already' });
});
-318
View File
@@ -1,318 +0,0 @@
// System draft store (#249 System S2 Task 5): one Save row for browser, host and runtime fields (concept app.js
// renderSystem `.save-row` 705, `save-settings` 1320). It saves browser, then host with the GET revision, then runtime;
// a domain that fails keeps its fields dirty, and the status copy (D92) says what really happened.
const test = require('node:test');
const assert = require('node:assert/strict');
const source = import('../../web/js/workspace/system-state.mjs');
/** Adapters that record every call; `host` answers like POST /prompt_manager/settings. */
function adapters({ browser = {}, host = {}, runtime = {}, revision = 'sha256:one' } = {}) {
const calls = [];
const outcome = { browser: null, host: null, runtime: null };
return {
calls,
outcome,
readBrowser: () => { calls.push(['readBrowser']); return { ...browser }; },
readHost: async () => { calls.push(['readHost']); return { values: { ...host }, revision }; },
readRuntime: async () => { calls.push(['readRuntime']); return { ...runtime }; },
writeBrowser: async changes => {
calls.push(['writeBrowser', changes]);
if (outcome.browser) return outcome.browser(changes);
return { persisted: true };
},
writeHost: async (changes, options) => {
calls.push(['writeHost', changes, options]);
if (outcome.host) return outcome.host(changes, options);
return { success: true, persisted: true, revision: 'sha256:two', runtime_applied: true };
},
writeRuntime: async changes => {
calls.push(['writeRuntime', changes]);
if (outcome.runtime) return outcome.runtime(changes);
return { success: true };
},
};
}
async function loaded(domains) {
const { createSystemDrafts } = await source;
const drafts = createSystemDrafts(domains);
await Promise.all(['browser', 'host', 'runtime'].map(domain => drafts.load(domain)));
domains.calls.length = 0;
return drafts;
}
test('sections follow the concept order and the production hashes', async () => {
const { SYSTEM_SECTIONS, isSystemSection } = await source;
assert.deepEqual(SYSTEM_SECTIONS.map(([id, label]) => [id, label]), [
['overview', 'Overview'], ['appearance', 'Appearance'], ['sources', 'Sources & scans'],
['integrations', 'Integrations'], ['tagging', 'Auto-tagging'], ['maintenance', 'Maintenance'],
['backups', 'Backup & export'], ['diagnostics', 'Diagnostics & logs'],
]);
assert.equal(isSystemSection('backups'), true);
for (const value of ['general', '', 'constructor', '__proto__', undefined]) assert.equal(isSystemSection(value), false, value);
});
test('drafts read baselines and report dirty only for changed values', async () => {
const domains = adapters({ browser: { layout: 'grid' }, host: { result_timeout: 30 } });
const drafts = await loaded(domains);
assert.equal(drafts.get('browser', 'layout'), 'grid');
assert.equal(drafts.get('host', 'result_timeout'), 30);
assert.equal(drafts.dirty(), false);
drafts.set('host', 'result_timeout', 45);
assert.equal(drafts.get('host', 'result_timeout'), 45);
assert.equal(drafts.dirty(), true);
assert.equal(drafts.dirty('host'), true);
assert.equal(drafts.dirty('browser'), false);
// Setting the saved value back is clean again.
drafts.set('host', 'result_timeout', 30);
assert.equal(drafts.dirty(), false);
assert.throws(() => drafts.set('elsewhere', 'x', 1), TypeError);
assert.throws(() => drafts.get('elsewhere', 'x'), TypeError);
});
test('save commits browser then host then runtime', async () => {
const domains = adapters({ browser: { layout: 'grid' }, host: { result_timeout: 30 }, runtime: { level: 'INFO' } });
const drafts = await loaded(domains);
drafts.set('runtime', 'level', 'DEBUG');
drafts.set('host', 'result_timeout', 45);
drafts.set('browser', 'layout', 'list');
const outcome = await drafts.save();
assert.deepEqual(outcome, { browser: 'saved', host: 'saved', runtime: 'applied' });
assert.deepEqual(domains.calls, [
['writeBrowser', { layout: 'list' }],
['writeHost', { result_timeout: 45 }, { expected_revision: 'sha256:one' }],
['writeRuntime', { level: 'DEBUG' }],
]);
assert.equal(drafts.dirty(), false);
// The next host save sends the revision the last one returned.
drafts.set('host', 'result_timeout', 60);
await drafts.save();
assert.deepEqual(domains.calls.at(-1), ['writeHost', { result_timeout: 60 }, { expected_revision: 'sha256:two' }]);
});
test('host failure keeps host fields dirty and browser saved', async () => {
const domains = adapters({ browser: { layout: 'grid' }, host: { result_timeout: 30 } });
const drafts = await loaded(domains);
domains.outcome.host = () => { throw Object.assign(new Error('Settings could not be written to config.json.'), { code: 'config-write-failed' }); };
drafts.set('browser', 'layout', 'list');
drafts.set('host', 'result_timeout', 45);
const outcome = await drafts.save();
assert.deepEqual(outcome, { browser: 'saved', host: 'failed', runtime: 'clean' });
assert.equal(drafts.dirty('browser'), false);
assert.equal(drafts.dirty('host'), true);
assert.equal(drafts.get('host', 'result_timeout'), 45);
assert.deepEqual([...drafts.failed('host')], ['result_timeout']);
assert.deepEqual([...drafts.failed('browser')], []);
assert.equal(drafts.statusText(), 'Some changes were not saved. Review the highlighted settings.');
// A save answered `persisted: false` without an error is not saved either.
domains.outcome.host = () => ({ success: false, persisted: false });
assert.deepEqual(await drafts.save(), { browser: 'clean', host: 'failed', runtime: 'clean' });
// Editing the failed field clears its highlight and reads as unsaved again.
drafts.set('host', 'result_timeout', 50);
assert.deepEqual([...drafts.failed('host')], []);
assert.equal(drafts.statusText(), 'Unsaved changes.');
});
test('stale revision reports conflict and keeps the draft', async () => {
const domains = adapters({ host: { result_timeout: 30 } });
const drafts = await loaded(domains);
domains.outcome.host = () => { throw Object.assign(new Error('Settings changed since they were loaded.'), { code: 'settings-changed' }); };
drafts.set('host', 'result_timeout', 45);
assert.deepEqual(await drafts.save(), { browser: 'clean', host: 'conflict', runtime: 'clean' });
assert.equal(drafts.dirty('host'), true);
assert.equal(drafts.get('host', 'result_timeout'), 45);
assert.equal(drafts.statusText(), 'Settings changed elsewhere. Reload this section to see them.');
// The conflict read the host values again (their new revision), keeping the draft.
assert.deepEqual(domains.calls.slice(-2).map(([name]) => name), ['writeHost', 'readHost']);
// Reloading the section reads the new revision and keeps the draft (Q10).
domains.outcome.host = null;
await drafts.load('host');
assert.equal(drafts.get('host', 'result_timeout'), 45);
await drafts.save();
assert.deepEqual(domains.calls.at(-1)[2], { expected_revision: 'sha256:one' });
});
test('host save without a read revision never posts', async () => {
const { createSystemDrafts } = await source;
// Never read: an edit made before the host values (and their revision) arrived must not merge blindly.
const unread = adapters({ host: { result_timeout: 30 } });
const drafts = createSystemDrafts(unread);
drafts.set('host', 'result_timeout', 45);
assert.deepEqual(await drafts.save(), { browser: 'clean', host: 'failed', runtime: 'clean' });
assert.deepEqual(unread.calls, []);
assert.equal(drafts.dirty('host'), true);
assert.deepEqual([...drafts.failed('host')], ['result_timeout']);
// A read of an unreadable configuration has no revision either.
const unreadable = adapters({ host: { result_timeout: 30 }, revision: null });
const blind = createSystemDrafts(unreadable);
await blind.load('host');
blind.set('host', 'result_timeout', 45);
assert.equal((await blind.save()).host, 'failed');
assert.equal(unreadable.calls.filter(([name]) => name === 'writeHost').length, 0);
// Once read, the same draft saves with the revision.
await drafts.load('host');
assert.equal((await drafts.save()).host, 'saved');
assert.deepEqual(unread.calls.at(-1), ['writeHost', { result_timeout: 45 }, { expected_revision: 'sha256:one' }]);
});
test('reloadHost reads the host values and says whether it could', async () => {
const { createSystemDrafts } = await source;
const domains = adapters({ host: { result_timeout: 30 } });
const drafts = createSystemDrafts(domains);
assert.equal(await drafts.reloadHost(), true);
assert.equal(drafts.get('host', 'result_timeout'), 30);
const broken = createSystemDrafts({ ...domains, readHost: async () => { throw new Error('offline'); } });
assert.equal(await broken.reloadHost(), false);
broken.set('host', 'result_timeout', 45);
assert.equal((await broken.save()).host, 'failed');
});
test('no domain called when clean', async () => {
const domains = adapters({ browser: { layout: 'grid' } });
const drafts = await loaded(domains);
assert.deepEqual(await drafts.save(), { browser: 'clean', host: 'clean', runtime: 'clean' });
assert.deepEqual(domains.calls, []);
drafts.set('browser', 'layout', 'list');
drafts.discard();
assert.equal(drafts.dirty(), false);
assert.equal(drafts.get('browser', 'layout'), 'grid');
assert.deepEqual(await drafts.save(), { browser: 'clean', host: 'clean', runtime: 'clean' });
assert.deepEqual(domains.calls, []);
});
test('status text per outcome', async () => {
const { statusText, SETTINGS_STATUS } = await source;
assert.deepEqual(SETTINGS_STATUS, {
clean: 'No unsaved changes.',
dirty: 'Unsaved changes.',
browser: 'Saved in this browser.',
host: 'Saved to the server configuration.',
runtime: 'Applied until the server restarts.',
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.',
});
const clean = { browser: 'clean', host: 'clean', runtime: 'clean' };
assert.equal(statusText(clean), 'No unsaved changes.');
assert.equal(statusText({ ...clean, browser: 'saved' }), 'Saved in this browser.');
assert.equal(statusText({ ...clean, host: 'saved' }), 'Saved to the server configuration.');
assert.equal(statusText({ ...clean, runtime: 'applied' }), 'Applied until the server restarts.');
assert.equal(statusText({ ...clean, browser: 'tab-only' }), 'Saved for this tab only; browser storage is unavailable.');
assert.equal(statusText({ browser: 'saved', host: 'saved', runtime: 'applied' }),
'Saved in this browser. Saved to the server configuration. Applied until the server restarts.');
assert.equal(statusText({ ...clean, browser: 'saved', runtime: 'failed' }),
'Some changes were not saved. Review the highlighted settings.');
assert.equal(statusText({ browser: 'failed', host: 'conflict', runtime: 'clean' }),
'Settings changed elsewhere. Reload this section to see them.');
});
test('the store says unsaved, then the save outcome, and notifies subscribers', async () => {
const domains = adapters({ browser: { layout: 'grid' } });
const drafts = await loaded(domains);
const seen = [];
const unsubscribe = drafts.subscribe(() => seen.push(drafts.statusText()));
assert.equal(drafts.statusText(), 'No unsaved changes.');
drafts.set('browser', 'layout', 'list');
domains.outcome.browser = () => ({ persisted: false });
await drafts.save();
assert.deepEqual(seen, ['Unsaved changes.', 'Saved for this tab only; browser storage is unavailable.']);
unsubscribe();
drafts.set('browser', 'layout', 'masonry');
assert.equal(seen.length, 2);
// The tab-only value is this tab's baseline now.
drafts.set('browser', 'layout', 'list');
assert.equal(drafts.dirty(), false);
});
test('an edit made while a save runs stays dirty', async () => {
const domains = adapters({ browser: { layout: 'grid' } });
const drafts = await loaded(domains);
let release;
domains.outcome.browser = () => new Promise(resolve => { release = () => resolve({ persisted: true }); });
drafts.set('browser', 'layout', 'list');
const saving = drafts.save();
// A second Save while one runs joins it instead of writing twice.
assert.equal(drafts.save(), saving);
assert.equal(drafts.saving(), true);
await new Promise(resolve => setImmediate(resolve));
drafts.set('browser', 'layout', 'masonry');
release();
assert.deepEqual(await saving, { browser: 'saved', host: 'clean', runtime: 'clean' });
assert.equal(drafts.saving(), false);
assert.equal(drafts.dirty('browser'), true);
assert.equal(drafts.get('browser', 'layout'), 'masonry');
assert.equal(domains.calls.filter(([name]) => name === 'writeBrowser').length, 1);
});
test('a failed read keeps the store usable and drafts survive a reload (Q10)', async () => {
const { createSystemDrafts } = await source;
const domains = adapters({ browser: { layout: 'grid' } });
const drafts = createSystemDrafts(domains);
await drafts.load('browser');
drafts.set('browser', 'layout', 'list');
await drafts.load('browser');
assert.equal(drafts.get('browser', 'layout'), 'list');
assert.equal(drafts.dirty(), true);
const failing = createSystemDrafts({ ...domains, readHost: async () => { throw new Error('offline'); } });
await assert.rejects(failing.load('host'), /offline/);
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');
});
-147
View File
@@ -76,7 +76,6 @@ function fakeDOM() {
get activeElement() { return active ?? body; },
createElement: tag => new Element(tag),
createElementNS: (namespace, tag) => new Element(tag),
createTextNode: text => Object.assign(new Element('#text'), { _text: String(text) }),
};
const all = (root, predicate, found = []) => {
if (predicate(root)) found.push(root);
@@ -522,10 +521,6 @@ test('copy fallback selects the text', async () => {
assert.equal(note.className, 'small');
assert.equal(note.textContent, 'Automatic clipboard access requires a secure browser context.');
assert.throws(() => openCopyFallback(42, { scope }), TypeError);
// LI-4 (D117): the settings summary names its own dialog; the select-to-copy body is the same.
openCopyFallback('Seed: 7', { scope, title: 'Copy settings summary' });
assert.equal(byId(dom, 'modal-title').textContent, 'Copy settings summary');
assert.equal(byId(dom, 'copy-text').value, 'Seed: 7');
} finally {
closeWorkspaceDialog();
scope.dispose();
@@ -791,145 +786,3 @@ test('the Filters button shows the active refinement count', async () => {
dom.restore();
}
});
// #249 System S2 Task 5: the scan review's dialog and backup box (D55) generalised for every record-changing review.
const scanModule = import('../../web/js/workspace/activity-scan.mjs');
const bodyShape = dom => byId(dom, 'modal-body').children.map(node => [node.tagName.toLowerCase(), node.className].filter(Boolean).join('.'));
test('backupNotice is the concept .notice with its copy and one Back up now action', async () => {
const { backupNotice, SCAN_BACKUP_COPY } = await dialogsModule;
const dom = fakeDOM();
try {
let calls = 0;
const notice = backupNotice({ copy: SCAN_BACKUP_COPY, onBackup: () => { calls++; } });
assert.equal(notice.className, 'notice');
assert.deepEqual(tags(notice), ['div', 'div']);
assert.deepEqual(tags(notice.children[0]), ['h3', 'p']);
assert.equal(notice.children[0].children[0].textContent, 'Back up the database first (recommended)');
assert.equal(notice.children[0].children[1].textContent,
'Downloads a copy of the prompts database before the scan changes any records.');
assert.equal(notice.children[1].className, 'row-actions');
const action = button(notice, 'Back up now');
action.click();
assert.equal(calls, 1);
assert.throws(() => backupNotice({ copy: { heading: '', text: 'x' }, onBackup() {} }), TypeError);
assert.throws(() => backupNotice({ copy: SCAN_BACKUP_COPY }), TypeError);
} finally {
dom.restore();
}
});
test('openReviewDialog lays out heading, grid, options, sentence, backup box and actions', async () => {
const { openReviewDialog, closeWorkspaceDialog } = await dialogsModule;
const { createScope } = await coreModule;
const dom = fakeDOM();
const scope = createScope();
try {
const trigger = new dom.Element('button');
dom.body.append(trigger);
const option = new dom.Element('div');
option.className = 'setting-row';
const started = [];
const review = openReviewDialog({ scope, title: 'Review maintenance plan', heading: 'Missing-file links',
grid: [['Scope', '2 stale image links'], ['Media on disk', 'No source files removed']], options: [option],
sentence: 'Cancelling leaves all data untouched.', backup: { copy: { heading: 'Back up first', text: 'A copy.' }, onBackup() {} },
cancelLabel: 'Cancel', primary: { label: 'Apply reviewed operation' }, onStart: () => started.push('start'), trigger });
assert.equal(byId(dom, 'modal-title').textContent, 'Review maintenance plan');
assert.deepEqual(bodyShape(dom), ['h3', 'dl.meta-grid', 'div.setting-row', 'p.small', 'div.notice', 'div.dialog-actions']);
const grid = byId(dom, 'modal-body').children[1];
assert.deepEqual(grid.children.map(entry => [tags(entry), entry.children[0].textContent, entry.children[1].textContent]), [
[['dt', 'dd'], 'Scope', '2 stale image links'], [['dt', 'dd'], 'Media on disk', 'No source files removed']]);
review.setValue('Scope', '3 stale image links');
assert.equal(grid.children[0].children[1].textContent, '3 stale image links');
const actions = byId(dom, 'modal-body').children.at(-1);
assert.deepEqual(actions.children.map(node => [node.textContent, node.className]),
[['Cancel', ''], ['Apply reviewed operation', 'ca-primary']]);
actions.children[1].click();
assert.deepEqual(started, ['start']);
assert.equal(modal(dom).open, false);
assert.equal(document.activeElement, trigger);
// A danger primary (the concept's Restore) has no sentence, heading, options or backup box when none is given.
openReviewDialog({ scope, title: 'Restore preview', grid: [['Integrity', 'Verified']], backup: null,
cancelLabel: 'Cancel', primary: { label: 'Restore', danger: true }, onStart() {} });
assert.deepEqual(bodyShape(dom), ['dl.meta-grid', 'div.dialog-actions']);
assert.equal(button(byId(dom, 'modal-body'), 'Restore').className, 'danger');
button(byId(dom, 'modal-body'), 'Cancel').click();
assert.equal(modal(dom).open, false);
assert.throws(() => openReviewDialog({ scope, title: 'x', grid: [['a']], cancelLabel: 'Cancel', primary: { label: 'Go' },
onStart() {} }), TypeError);
assert.throws(() => openReviewDialog({ scope, title: 'x', grid: [], cancelLabel: 'Cancel', primary: {}, onStart() {} }), TypeError);
} finally {
scope.dispose();
closeWorkspaceDialog();
dom.restore();
}
});
test('review dialog backup box keeps the dialog open and toasts the outcome', async () => {
const { openReviewDialog, closeWorkspaceDialog, SCAN_BACKUP_COPY } = await dialogsModule;
const { downloadDatabaseBackup, BACKUP_DONE, BACKUP_FAILED } = await scanModule;
const { createScope } = await coreModule;
const dom = fakeDOM();
const scope = createScope();
const created = URL.createObjectURL;
const revoked = URL.revokeObjectURL;
URL.createObjectURL = () => 'blob:backup';
URL.revokeObjectURL = () => {};
try {
const toasts = [];
let answer;
const fetchUrl = () => new Promise(resolve => { answer = resolve; });
openReviewDialog({ scope, title: 'Review metadata scan', grid: [['Sources', '2 output folders']],
backup: { copy: SCAN_BACKUP_COPY, onBackup: () => downloadDatabaseBackup({ fetchUrl, notify: text => toasts.push(text) }) },
cancelLabel: 'Cancel', primary: { label: 'Start scan' }, onStart() { throw new Error('not started'); } });
const action = button(byId(dom, 'modal-body'), 'Back up now');
action.click();
assert.equal(action.textContent, 'Backing up…');
assert.equal(action.getAttribute('aria-disabled'), 'true');
// A second press while the copy downloads is ignored.
action.click();
answer({ ok: true, blob: async () => new Blob(['db']), headers: { get: () => 'attachment; filename="prompts_backup_1.db"' } });
await tick();
assert.deepEqual(toasts, [BACKUP_DONE]);
assert.equal(modal(dom).open, true);
assert.equal(action.textContent, 'Back up now');
assert.equal(action.getAttribute('aria-disabled'), null);
action.click();
answer({ ok: false, status: 500 });
await tick();
assert.deepEqual(toasts, [BACKUP_DONE, BACKUP_FAILED]);
assert.equal(modal(dom).open, true);
} finally {
URL.createObjectURL = created;
URL.revokeObjectURL = revoked;
scope.dispose();
closeWorkspaceDialog();
dom.restore();
}
});
test('the scan review is a thin openReviewDialog caller with its scope switch', async () => {
const { openScanReviewDialog, closeWorkspaceDialog } = await dialogsModule;
const { createScope } = await coreModule;
const dom = fakeDOM();
const scope = createScope();
try {
const starts = [];
openScanReviewDialog({ scope, sources: 2, onBackup() {}, onStart: value => starts.push(value) });
assert.equal(byId(dom, 'modal-title').textContent, 'Review metadata scan');
assert.deepEqual(bodyShape(dom), ['dl.meta-grid', 'div.setting-row', 'p.small', 'div.notice', 'div.dialog-actions']);
const grid = byId(dom, 'modal-body').children[0];
assert.deepEqual(grid.children.map(entry => entry.textContent), ['Sources2 output folders', 'ScopeNew and unresolved files']);
const allFiles = byId(dom, 'scan-all-files');
allFiles.checked = true;
allFiles.dispatchEvent(new Event('change'));
assert.equal(grid.children[1].children[1].textContent, 'All files');
button(byId(dom, 'modal-body'), 'Start scan').click();
assert.deepEqual(starts, [{ scope: 'all' }]);
} finally {
scope.dispose();
closeWorkspaceDialog();
dom.restore();
}
});
File diff suppressed because it is too large Load Diff
-877
View File
@@ -1,877 +0,0 @@
"""Candidate-selection record sets for the local resolver parity test (PR L3 Task 9).
``local_resolver_cases`` covers one sampler's tracing. These cases reach the
rest of ``_resolve``: several sampler candidates and the identical-branch rule
(#270), stages that only reach previews, detailer stages and their upstream
samplers (multistage), and A1111 ``parameters`` with every footer shape. The
server's ``inspect_prompt_evidence`` is the oracle; nothing here states an
expected answer. All content is synthetic.
"""
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
def _encode(text):
return {"class_type": "CLIPTextEncode", "inputs": {"text": text, "clip": ["4", 1]}}
def _sampler(positive, negative, latent=None, name="KSampler"):
inputs = {"seed": 1, "model": ["4", 0], "positive": positive}
if negative is not None:
inputs["negative"] = negative
inputs["latent_image"] = latent or ["5", 0]
return {"class_type": name, "inputs": inputs}
def _decode(sampler_id):
return {"class_type": "VAEDecode", "inputs": {"samples": [sampler_id, 0]}}
def _save(image, name="SaveImage"):
return {"class_type": name, "inputs": {"images": image}}
def _detailer(image, positive, negative, name="FaceDetailer", **extra):
inputs = {"image": image, "model": ["4", 0], "positive": positive}
inputs["negative"] = negative
inputs.update(extra)
return {"class_type": name, "inputs": inputs}
def _base(**nodes):
graph = {
"4": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "m"}},
"5": {"class_type": "EmptyLatentImage", "inputs": {"width": 64}},
"6": _encode("a fox"),
"7": _encode("blurry"),
"8": _encode("a cat"),
}
graph.update(nodes)
return graph
def _samplers():
"""Several unanchored samplers: identical, differing and mixed branches."""
same = _sampler(["6", 0], ["7", 0])
cases = {
"two-identical": _base(**{"3": same, "9": same}),
"two-differ": _base(**{"3": same, "9": _sampler(["8", 0], ["7", 0])}),
"two-negative-differs": _base(**{"3": same, "9": _sampler(["6", 0], ["8", 0])}),
"identical-without-negative": _base(
**{"3": _sampler(["6", 0], None), "9": _sampler(["6", 0], None)}
),
"one-unresolved": _base(**{"3": same, "9": _sampler(["missing", 0], ["7", 0])}),
"both-unresolved": _base(
**{"3": _sampler(["x", 0], ["7", 0]), "9": _sampler(["y", 0], ["7", 0])}
),
"three-identical-one-advanced": _base(
**{
"3": same,
"9": same,
"10": _sampler(["6", 0], ["7", 0], name="KSamplerAdvanced"),
}
),
# Equal texts, different recipes: a PromptManager pair against an encoder.
"same-text-other-recipe": _base(
**{
"3": same,
"9": _sampler(["11", 0], ["7", 0]),
"11": {
"class_type": "PromptManager",
"inputs": {
"text": "fox",
"prepend_text": "a",
"append_text": "",
"clip": ["4", 1],
},
},
}
),
"multistage-latent": _base(
**{"3": same, "9": _sampler(["6", 0], ["7", 0], latent=["3", 0])}
),
}
selector = {
"class_type": "easy ifElse",
"inputs": {"boolean": ["4", 0], "on_true": ["6", 0], "on_false": ["8", 0]},
}
cases["selector-and-plain"] = _base(
**{"3": same, "9": _sampler(["12", 0], ["7", 0]), "12": selector}
)
return cases
def _previews():
"""Stages whose outputs reach only previews are not the saved file's."""
same, other = _sampler(["6", 0], ["7", 0]), _sampler(["8", 0], ["7", 0])
return {
"preview-dropped": _base(
**{
"3": same,
"9": other,
"13": _decode("3"),
"14": _decode("9"),
"15": _save(["13", 0]),
"16": _save(["14", 0], "PreviewImage"),
}
),
"all-previews-kept": _base(
**{
"3": same,
"9": other,
"13": _decode("3"),
"14": _decode("9"),
"15": _save(["13", 0], "PreviewImage"),
"16": _save(["14", 0], "Image Comparer (rgthree)"),
}
),
"preview-and-file": _base(
**{
"3": same,
"9": other,
"13": _decode("3"),
"14": _decode("9"),
"15": _save(["13", 0]),
"16": _save(["14", 0], "PreviewImage"),
"17": _save(["14", 0]),
}
),
"unconsumed-stage-kept": _base(
**{
"3": same,
"9": other,
"14": _decode("9"),
"16": _save(["14", 0], "PreviewImage"),
}
),
# A list or object class_type is never a preview class (#286).
"preview-unhashable-class": _base(
**{
"3": same,
"9": other,
"14": _decode("9"),
"16": {"class_type": ["PreviewImage"], "inputs": {"images": ["14", 0]}},
"17": {"class_type": {"a": 1}, "inputs": {"images": ["3", 0]}},
}
),
"preview-through-bad-node": _base(
**{
"3": same,
"9": other,
"13": _decode("3"),
"15": _save(["13", 0]),
"16": _save(["9", 0], "PreviewImage"),
"18": "not a node",
"19": {"class_type": "Note", "inputs": {"x": ["18", 0]}},
}
),
}
def _detailers():
"""Detailer stages judged with the samplers in their image ancestry."""
same = _sampler(["6", 0], ["7", 0])
cases = {
"detailer-same-prompt": _base(
**{
"3": same,
"10": _decode("3"),
"11": _detailer(["10", 0], ["6", 0], ["7", 0]),
"12": _save(["11", 0]),
}
),
"detailer-differs": _base(
**{
"3": same,
"10": _decode("3"),
"11": _detailer(["10", 0], ["8", 0], ["7", 0]),
"12": _save(["11", 0]),
}
),
"detailer-and-own-output": _base(
**{
"3": same,
"10": _decode("3"),
"11": _detailer(["10", 0], ["8", 0], ["7", 0]),
"12": _save(["11", 0]),
"13": _save(["10", 0]),
}
),
"detailer-image-literal": _base(
**{
"11": _detailer("not a link", ["6", 0], ["7", 0]),
"12": _save(["11", 0]),
}
),
"detailer-cycle": _base(
**{
"11": _detailer(["10", 0], ["6", 0], ["7", 0]),
"10": {"class_type": "ImageScale", "inputs": {"image": ["11", 0]}},
"12": _save(["11", 0]),
}
),
"detailer-chain": _base(
**{
"3": same,
"10": _decode("3"),
"11": _detailer(["10", 0], ["6", 0], ["7", 0]),
"21": _detailer(
["11", 0], ["8", 0], ["7", 0], name="UltimateSDUpscale"
),
"12": _save(["21", 0]),
}
),
"detailer-chain-same": _base(
**{
"3": same,
"10": _decode("3"),
"11": _detailer(["10", 0], ["6", 0], ["7", 0]),
"21": _detailer(["11", 0], ["6", 0], ["7", 0], name="DetailerForEach"),
"12": _save(["21", 0]),
}
),
"detailer-two-upstream": _base(
**{
"3": same,
"9": _sampler(["8", 0], ["7", 0]),
"10": {
"class_type": "ImageBlend",
"inputs": {"image1": ["13", 0], "image2": ["14", 0]},
},
"13": _decode("3"),
"14": _decode("9"),
"11": _detailer(["10", 0], ["6", 0], ["7", 0]),
"12": _save(["11", 0]),
}
),
"detailer-unsupported-stage": _base(
**{
"10": {
"class_type": "MysterySampler",
"inputs": {"positive": ["6", 0], "model": ["4", 0]},
},
"11": _detailer(["10", 0], ["6", 0], ["7", 0]),
"12": _save(["11", 0]),
}
),
"detailer-upstream-unresolved": _base(
**{
"3": _sampler(["gone", 0], ["7", 0]),
"10": _decode("3"),
"11": _detailer(["10", 0], ["6", 0], ["7", 0]),
"12": _save(["11", 0]),
}
),
"detailer-upstream-selector": _base(
**{
"3": _sampler(["15", 0], ["7", 0]),
"15": {
"class_type": "easy ifElse",
"inputs": {
"boolean": ["4", 0],
"on_true": ["6", 0],
"on_false": ["8", 0],
},
},
"10": _decode("3"),
"11": _detailer(["10", 0], ["6", 0], ["7", 0]),
"12": _save(["11", 0]),
}
),
"detailer-wildcard": _base(
**{
"3": same,
"10": _decode("3"),
"11": _detailer(["10", 0], ["6", 0], ["7", 0], wildcard="[x|y]"),
"12": _save(["11", 0]),
}
),
"detailer-no-upscaled-image": _base(
**{
"3": same,
"10": _decode("3"),
"11": _detailer(
["10", 0], ["6", 0], ["7", 0], name="UltimateSDUpscaleNoUpscale"
),
"12": _save(["11", 0]),
}
),
"detailer-no-sampler-upstream": _base(
**{
"10": {"class_type": "LoadImage", "inputs": {"image": "a.png"}},
"11": _detailer(["10", 0], ["6", 0], ["7", 0]),
"12": _save(["11", 0]),
}
),
}
deep = {"3": _sampler(["6", 0], ["7", 0]), "d0": _decode("3")}
for index in range(1, 70):
deep[f"d{index}"] = {
"class_type": "ImageScale",
"inputs": {"image": [f"d{index - 1}", 0]},
}
deep["11"] = _detailer(["d69", 0], ["6", 0], ["7", 0])
deep["12"] = _save(["11", 0])
cases["detailer-deep-ancestry"] = _base(**deep)
# More nested detailer stages than the depth bound.
stacked = {"3": _sampler(["6", 0], ["7", 0]), "s0": _decode("3")}
previous = "s0"
for index in range(1, 67):
stacked[f"s{index}"] = _detailer([previous, 0], ["6", 0], ["7", 0])
previous = f"s{index}"
stacked["12"] = _save([previous, 0])
cases["detailer-stacked-past-depth"] = _base(**stacked)
# Each detailer walks a wide shared image ancestry: the total visit budget
# runs out across stages before any candidate is read.
wide = {
f"l{index}": {"class_type": "LoadImage", "inputs": {}} for index in range(1500)
}
wide["hub"] = {
"class_type": "ImageBatchMany",
"inputs": {f"i{index}": [f"l{index}", 0] for index in range(1500)},
}
for index in range(15):
wide[f"w{index}"] = _detailer(["hub", 0], ["6", 0], ["7", 0])
wide[f"o{index}"] = _save([f"w{index}", 0])
cases["detailers-exhaust-total-visits"] = _base(**wide)
cases["detailer-mutual-cycle"] = _base(
**{
"11": _detailer(["21", 0], ["6", 0], ["7", 0]),
"21": _detailer(["11", 0], ["6", 0], ["7", 0]),
"12": _save(["21", 0]),
}
)
# The sampler's output reaches a preview through more nodes than one
# detailer's allowance: the walk runs out, so the sampler is not absorbed.
long = {"3": _sampler(["6", 0], ["7", 0]), "10": _decode("3")}
previous = "10"
for index in range(2100):
long[f"c{index}"] = {
"class_type": "ImageScale",
"inputs": {"image": [previous, 0]},
}
previous = f"c{index}"
long["p"] = _save([previous, 0], "PreviewImage")
long["11"] = _detailer(["10", 0], ["6", 0], ["7", 0])
long["12"] = _save(["11", 0])
cases["detailer-output-walk-exhausted"] = _base(**long)
# Every detailer's ancestry walk has its own allowance: the first one is
# wide, and the second still absorbs the first.
chained = {"3": _sampler(["6", 0], ["7", 0]), "10": _decode("3")}
chained["h1"] = {
"class_type": "ImageBatchMany",
"inputs": {
"i": ["10", 0],
**{f"i{index}": [f"a{index}", 0] for index in range(1500)},
},
}
chained["h2"] = {
"class_type": "ImageBatchMany",
"inputs": {
"i": ["11", 0],
**{f"i{index}": [f"b{index}", 0] for index in range(600)},
},
}
for index in range(1500):
chained[f"a{index}"] = {"class_type": "LoadImage", "inputs": {}}
for index in range(600):
chained[f"b{index}"] = {"class_type": "LoadImage", "inputs": {}}
chained["11"] = _detailer(["h1", 0], ["6", 0], ["7", 0])
chained["21"] = _detailer(["h2", 0], ["6", 0], ["7", 0])
chained["12"] = _save(["21", 0])
cases["detailers-each-reset"] = _base(**chained)
return cases
_FOOTER = "Steps: 20, Sampler: euler, CFG scale: 7"
def _parameters():
"""A1111 ``parameters`` texts: every footer shape and delimiter rule."""
texts = {
"basic": f"a fox\nNegative prompt: blur\n{_FOOTER}",
"no-negative": f"a fox\n{_FOOTER}",
"two-delimiters": f"a\nNegative prompt: b\nNegative prompt: c\n{_FOOTER}",
"leading-delimiter": f"Negative prompt: blur\n{_FOOTER}",
"blank-positive": f" \x85\nNegative prompt: blur\n{_FOOTER}",
"footer-not-last": f"a fox\n{_FOOTER}\nmore text",
"two-footers": f"a\n{_FOOTER}\n{_FOOTER}",
"bare-footer": "a fox\nNegative prompt: blur\nSteps: 20",
"bare-and-full": f"a fox\nSteps: 5\n{_FOOTER}",
"two-bare": "a\nSteps: 1\nSteps: 2",
"template": f"a fox\n{_FOOTER}\nTemplate: {{a|b}}",
"both-templates": f"a\n{_FOOTER}\nTemplate: x\nNegative Template: y",
"templates-reversed": f"a\n{_FOOTER}\nNegative Template: y\nTemplate: x",
"two-templates": f"a\n{_FOOTER}\nTemplate: x\nTemplate: y",
"template-multiline": f"a\n{_FOOTER}\nTemplate: x\nmore",
"crlf": f"a fox\r\nNegative prompt: blur\r\n{_FOOTER}\r\n",
"cr-only": f"a fox\rNegative prompt: blur\r{_FOOTER}",
"cr-template": f"a\n{_FOOTER}\rTemplate: x",
"line-separator": f"a fox
{_FOOTER}",
"next-line": f"a fox\x85{_FOOTER}",
"spaced-steps": "a fox\nSteps:\n 20 \n, x",
"steps-newline-bare": "a fox\nSteps:\n20\n",
"arabic-digits": "a fox\nSteps: ٢٠, Sampler: euler",
"fullwidth-digits": "a fox\nSteps: 2, x",
"no-digits": "a fox\nSteps: , x",
"trailing-spaces": f"a fox \nNegative prompt: blur \n{_FOOTER} \n \n",
"empty-negative": f"a fox\nNegative prompt:\n{_FOOTER}",
"astral-within-limit": "\U0001f600" * 60_000 + f"\n{_FOOTER}",
"over-limit": "x" * 100_001,
"at-limit": "x" * (100_000 - len(_FOOTER) - 1) + "\n" + _FOOTER,
"empty": "",
"only-footer": _FOOTER,
"lone-surrogate": f"\ud800 fox\nNegative prompt: \udfff\n{_FOOTER}",
}
cases = {f"parameters-{name}": {"parameters": text} for name, text in texts.items()}
cases["parameters-with-execution"] = {
"parameters": texts["basic"],
EXECUTION_KEY: json.dumps({"version": 1, "nodes": {}}),
}
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 name, graph in group.items():
cases[f"select-{name}"] = {"prompt": json.dumps(graph, ensure_ascii=False)}
cases.update(_parameters())
return cases
class _Selection(_Builder):
"""Random graphs with several samplers, outputs, previews and detailers."""
def sampler(self):
rng = self.rng
positive = self.conditioning(0)
negative = self.conditioning(0) if rng.random() < 0.85 else None
name = rng.choice(["KSampler", "KSampler", "KSamplerAdvanced", "SamplerCustom"])
node_id = self.new_id()
self.nodes[node_id] = _sampler(positive, negative, name=name)
return node_id
def shared(self, ids):
"""A copy of an earlier sampler: an identical branch."""
node_id = self.new_id()
self.nodes[node_id] = json.loads(json.dumps(self.nodes[self.rng.choice(ids)]))
return node_id
def output(self, image):
rng = self.rng
choice = rng.randrange(5)
if choice == 4:
return
name = ["SaveImage", "SaveImage", "PreviewImage", "PreviewAny"][choice]
self.nodes[self.new_id()] = _save(image, name)
def detailer(self, image):
rng = self.rng
node_id = self.new_id()
positive = self.conditioning(1) if rng.random() < 0.5 else None
name = rng.choice(list(_DETAILER_NAMES))
node = _detailer(image, positive, self.conditioning(1), name=name)
if positive is None:
node["inputs"]["positive"] = self.reuse_positive()
if name == "UltimateSDUpscaleNoUpscale":
node["inputs"]["upscaled_image"] = node["inputs"].pop("image")
if name in ("FaceDetailer", "DetailerForEach") and rng.random() < 0.15:
node["inputs"]["wildcard"] = rng.choice(["", "x", ["4", 0]])
self.nodes[node_id] = node
return node_id
def reuse_positive(self):
samplers = [
node
for node in self.nodes.values()
if node.get("class_type")
in ("KSampler", "KSamplerAdvanced", "SamplerCustom")
]
if samplers:
return self.rng.choice(samplers)["inputs"]["positive"]
return self.conditioning(1)
def build(self):
rng = self.rng
ids = [self.sampler()]
for _ in range(rng.randrange(3)):
ids.append(self.shared(ids) if rng.random() < 0.4 else self.sampler())
for sampler_id in list(ids):
decoded = self.new_id()
self.nodes[decoded] = _decode(sampler_id)
image = [decoded, 0]
for _ in range(rng.randrange(3)):
if rng.random() < 0.5:
image = [self.detailer(image), 0]
else:
self.output(image)
self.output(image)
if rng.random() < 0.1:
self.nodes[sampler_id]["inputs"]["latent_image"] = [rng.choice(ids), 0]
graph = {
"4": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "m"}},
"5": {"class_type": "EmptyLatentImage", "inputs": {"width": 64}},
}
graph.update(self.nodes)
return graph
_DETAILER_NAMES = (
"FaceDetailer",
"DetailerForEach",
"UltimateSDUpscale",
"UltimateSDUpscaleNoUpscale",
)
_PARAMETER_LINES = [
"Negative prompt:",
"Negative prompt: blur",
"Steps: 20, Sampler: euler",
"Steps: 7",
"Steps: x",
"Template: t",
"Negative Template: n",
"Steps:٣, a",
]
def _parameter_text(rng):
parts = []
for _ in range(rng.randint(1, 5)):
if rng.random() < 0.5:
parts.append(rng.choice(_PARAMETER_LINES))
else:
parts.append(rng.choice(SPACES) + rng.choice(WORDS) + rng.choice(SPACES))
joints = ["\n", "\n", "\r\n", "\r", "
", " "]
return "".join(part + rng.choice(joints) for part in parts).rstrip(
rng.choice(["", "\n", " "])
)
def selection_fuzz_cases(seed, count):
"""Seeded several-sampler graphs (sometimes corrupted) and parameters texts."""
rng = random.Random(seed)
cases = {}
for index in range(count):
if rng.random() < 0.2:
cases[f"select-fuzz-{seed}-{index}"] = {"parameters": _parameter_text(rng)}
continue
builder = _Selection(rng)
graph = builder.build()
if rng.random() < 0.2:
builder.nodes = graph
builder.corrupt()
records = {"prompt": json.dumps(graph, ensure_ascii=rng.random() < 0.5)}
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)
-17
View File
@@ -868,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"))
-46
View File
@@ -486,30 +486,6 @@ class TestStaticRoutes(CoreRoutesTestCase):
self.assertEqual(resp.status, 200)
self.assertIn("application/javascript", resp.headers["Content-Type"])
async def test_inspection_semantics_table_is_the_one_json_served(self):
"""Local Inspect reads the node table the server resolves with (#249 PR L3).
Only that exact file is served; every other JSON stays forbidden.
"""
resp = await self.client.request(
"GET", "/prompt_manager/js/inspection-semantics.json"
)
self.assertEqual(resp.status, 200)
self.assertIn("application/json", resp.headers["Content-Type"])
table = Path(__file__).resolve().parents[1] / "web" / "js"
self.assertEqual(
await resp.read(), (table / "inspection-semantics.json").read_bytes()
)
for path in (
"js/shared/toolkit/provenance.json",
"lib/viewerjs/provenance.json",
"lib/inspection-semantics.json",
"js/workspace/..%2Finspection-semantics.json",
"js/INSPECTION-SEMANTICS.json",
):
resp = await self.client.request("GET", f"/prompt_manager/{path}")
self.assertEqual(resp.status, 403, path)
async def test_lib_served(self):
web_dir = self._fake_root()
(web_dir / "lib" / "x.css").write_text("body{}")
@@ -602,28 +578,6 @@ class TestStaticRoutes(CoreRoutesTestCase):
resp = await self.client.request("GET", "/prompt_manager/lib/link.js")
self.assertEqual(resp.status, 403)
async def test_inspection_semantics_symlink_escaping_web_dir_is_403(self):
"""The exact-path JSON gets the same realpath containment as any file."""
web_dir = self._fake_root()
secret = Path(self.tmpdir) / "secret.json"
secret.write_text('{"secret": true}')
try:
os.symlink(secret, web_dir / "js" / "inspection-semantics.json")
except (OSError, NotImplementedError):
self.skipTest("symlinks not available")
resp = await self.client.request(
"GET", "/prompt_manager/js/inspection-semantics.json"
)
self.assertEqual(resp.status, 403)
self.assertNotIn("secret", await resp.text())
async def test_inspection_semantics_missing_is_404(self):
self._fake_root()
resp = await self.client.request(
"GET", "/prompt_manager/js/inspection-semantics.json"
)
self.assertEqual(resp.status, 404)
async def test_empty_path_is_not_served(self):
for prefix in ("lib", "js"):
resp = await self.client.request("GET", f"/prompt_manager/{prefix}/")
-56
View File
@@ -1643,62 +1643,6 @@ class TestPromptInspector(ImageAPITestCase):
str(path), False, report_ambiguity=True
)
async def test_unhashable_workflow_node_type_is_skipped(self):
"""A UI workflow node whose ``type`` is a list or dict is skipped (#286).
The R1 check looks each node's ``type`` up among subgraph IDs; an
unhashable ``type`` used to raise there and the route answered 500.
Such a node can be neither a subgraph instance nor a PromptManager
node, so the walk skips it and a prompt that resolves still resolves.
"""
source = graph()
source["1"]["class_type"] = "PromptManager"
for kind in (["PromptManager"], {"name": "PromptManager"}):
with self.subTest(kind=type(kind).__name__):
workflow = json.dumps(
{"nodes": [{"id": 1, "type": kind, "widgets_values": ["x"]}]}
)
png(
self.output_dir / "image.png",
prompt=json.dumps(source),
workflow=workflow,
)
response, result = await self.inspect()
self.assertEqual(response.status, 200)
self.assertEqual(result["resolution"]["state"], "resolved")
self.assertEqual(result["resolution"]["sampler_id"], "3")
self.assertEqual(result["prompts"]["positive"]["status"], "resolved")
self.assertEqual(result["prompts"]["positive"]["text"], "portrait")
self.assertEqual(result["prompts"]["negative"]["text"], "blurry")
self.assertNotIn("unhashable", json.dumps(result))
async def test_unhashable_graph_class_type_is_not_a_preview_output(self):
"""A list or object ``class_type`` never names a preview output (#286).
With several samplers the inspector first drops stages that reach only
previews; looking such a ``class_type`` up among the preview classes
used to raise, and the route answered 500.
"""
for kind in (["PreviewImage"], {"name": "PreviewImage"}):
with self.subTest(kind=type(kind).__name__):
source = graph()
source["9"] = {
"class_type": "KSampler",
"inputs": {"positive": ["8", 0], "negative": ["2", 0]},
}
source["8"] = {
"class_type": "CLIPTextEncode",
"inputs": {"text": "cat"},
}
source["10"] = {"class_type": kind, "inputs": {"images": ["9", 0]}}
png(self.output_dir / "image.png", prompt=json.dumps(source))
response, result = await self.inspect()
self.assertEqual(response.status, 200)
self.assertEqual(
result["resolution"]["reason"], "Multiple sampler branches"
)
self.assertEqual(result["resolution"]["state"], "ambiguous")
class TestInspectorImageTags(ImageAPITestCase):
"""B3: the inspector envelope's ``image_tags`` facet (spec amendment S1)."""
-234
View File
@@ -1,234 +0,0 @@
"""A slow page-workflow reset reports where the server waited.
CI run 39904288262 had one ``/__test__/reset`` wait 16 s with nothing in the
server's stderr. tests/browser/slow_reset_watch.py reports any reset past
SLOW_RESET_SECONDS with every thread's stack, taken while it still waits.
These tests hold a real SQLite write lock behind a real test server's reset.
"""
import glob
import io
import os
import sqlite3
import subprocess
import sys
import tempfile
import threading
import time
import unittest
import urllib.request
from pathlib import Path
from tests.browser import slow_reset_watch
from tests.browser.slow_reset_watch import REPORT_PREFIX, ResetWatch, is_reset_path
REPO_ROOT = Path(__file__).resolve().parent.parent
SERVER_TIMEOUT_S = 60
HOLD_SECONDS = slow_reset_watch.SLOW_RESET_SECONDS + 4
# The reset may take a little longer than the hold, never the 30 s busy timeout.
RESET_MARGIN_SECONDS = 10
# While the reset waits, the server still answers other requests this fast.
# Reading the waiting connection from the report's thread froze it (the GIL is
# held while SQLite's connection mutex is awaited) until the lock was released.
RESPONSIVE_SECONDS = 2.0
PROBE_INTERVAL_SECONDS = 0.1
def blocked_here(gate: threading.Event) -> None:
gate.wait()
class TestResetWatch(unittest.TestCase):
def test_reset_paths_are_the_test_reset_routes(self):
self.assertTrue(is_reset_path("/__test__/reset"))
self.assertTrue(is_reset_path("/__test__/tagging/reset"))
self.assertFalse(is_reset_path("/prompt_manager/reset"))
self.assertFalse(is_reset_path("/__test__/reset/extra"))
def test_a_stalled_reset_reports_state_and_stacks_while_it_waits(self):
stream = io.StringIO()
reported = threading.Event()
class Stream(io.StringIO):
def flush(self):
reported.set()
stream = Stream()
gate = threading.Event()
worker = threading.Thread(target=blocked_here, args=(gate,), name="held-up")
worker.start()
self.addCleanup(worker.join)
self.addCleanup(gate.set)
watch = ResetWatch(
"POST /__test__/reset",
lambda: ["database: 1 connections"],
threshold=0.05,
stream=stream,
).start()
self.assertTrue(reported.wait(SERVER_TIMEOUT_S))
report = stream.getvalue().splitlines()
self.assertTrue(all(line.startswith(REPORT_PREFIX) for line in report))
text = "\n".join(report)
self.assertIn("POST /__test__/reset still running after", text)
self.assertIn("database: 1 connections", text)
self.assertIn(f"thread 0x{worker.ident:016x} is held-up", text)
self.assertIn("in blocked_here", text)
watch.record_call("seed", 0.25, 1.5)
watch.finish("answered 200")
self.assertIn("POST /__test__/reset answered 200 after", stream.getvalue())
self.assertIn(
"executor call seed: queued 0.250s, ran 1.500s", stream.getvalue()
)
def test_a_fast_reset_writes_nothing_and_stops_its_timer(self):
stream = io.StringIO()
watch = ResetWatch("reset", lambda: [], threshold=60, stream=stream).start()
watch.finish("answered 200")
self.assertTrue(watch._timer.finished.is_set())
self.assertEqual(stream.getvalue(), "")
def test_a_failing_state_probe_still_reports_the_stacks(self):
def broken():
raise RuntimeError("no state")
reported = threading.Event()
class Stream(io.StringIO):
def flush(self):
reported.set()
stream = Stream()
ResetWatch("reset", broken, threshold=0.01, stream=stream).start()
self.assertTrue(reported.wait(SERVER_TIMEOUT_S))
self.assertIn("state unavailable: RuntimeError('no state')", stream.getvalue())
self.assertIn("most recent call first", stream.getvalue())
class TestSlowResetOnTheServer(unittest.TestCase):
"""A reset behind another process's write lock, on tests/browser/server.py."""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
env = {**os.environ, "TMPDIR": self.tmp.name}
env.update(TEMP=self.tmp.name, TMP=self.tmp.name)
self.stderr = tempfile.TemporaryFile()
self.addCleanup(self.stderr.close)
process = subprocess.Popen(
[sys.executable, "-m", "tests.browser.server", "--port", "0"],
cwd=REPO_ROOT,
env=env,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=self.stderr,
)
def stop():
if process.poll() is None:
process.kill()
process.wait(timeout=SERVER_TIMEOUT_S)
for stream in (process.stdin, process.stdout):
stream.close()
self.addCleanup(stop)
line = process.stdout.readline().decode()
self.assertTrue(line.startswith("PM_TEST_SERVER_PORT="), line)
self.base = f"http://127.0.0.1:{int(line.split('=', 1)[1])}"
deadline = time.monotonic() + SERVER_TIMEOUT_S
while True:
try:
with urllib.request.urlopen( # nosec B310
self.base + "/prompt_manager/test", timeout=1
):
break
except OSError:
self.assertIsNone(process.poll(), "the server exited early")
self.assertLess(time.monotonic(), deadline, "never ready")
time.sleep(0.1)
[self.db_path] = glob.glob(
os.path.join(self.tmp.name, "pm-browser-*", "browser.db")
)
def reset(self) -> tuple[int, float]:
request = urllib.request.Request(
self.base + "/__test__/reset",
data=b"{}",
headers={"Content-Type": "application/json"},
method="POST",
)
start = time.monotonic()
with urllib.request.urlopen( # nosec B310
request, timeout=SERVER_TIMEOUT_S
) as response:
return response.status, time.monotonic() - start
def server_stderr(self) -> str:
self.stderr.seek(0)
return self.stderr.read().decode("utf-8", "replace")
def test_a_reset_held_behind_a_write_lock_names_the_wait_and_ends_with_it(self):
self.assertEqual(self.reset()[0], 200, self.server_stderr())
self.assertNotIn(REPORT_PREFIX, self.server_stderr())
held = threading.Event()
def hold_write_lock():
holder = sqlite3.connect(self.db_path, timeout=SERVER_TIMEOUT_S)
try:
holder.execute("BEGIN IMMEDIATE")
held.set()
time.sleep(HOLD_SECONDS)
holder.rollback()
finally:
holder.close()
lock = threading.Thread(target=hold_write_lock)
lock.start()
self.addCleanup(lock.join)
self.assertTrue(held.wait(SERVER_TIMEOUT_S))
outcome = []
resetting = threading.Thread(target=lambda: outcome.append(self.reset()))
resetting.start()
self.addCleanup(resetting.join)
slowest = 0.0
while resetting.is_alive():
start = time.monotonic()
with urllib.request.urlopen( # nosec B310
self.base + "/prompt_manager/test", timeout=SERVER_TIMEOUT_S
):
slowest = max(slowest, time.monotonic() - start)
time.sleep(PROBE_INTERVAL_SECONDS)
resetting.join()
lock.join()
self.assertEqual(len(outcome), 1, self.server_stderr())
[(status, elapsed)] = outcome
self.assertLess(slowest, RESPONSIVE_SECONDS)
self.assertEqual(status, 200)
self.assertGreaterEqual(elapsed, HOLD_SECONDS - 0.5)
self.assertLess(elapsed, HOLD_SECONDS + RESET_MARGIN_SECONDS)
report = [
line[len(REPORT_PREFIX) :]
for line in self.server_stderr().splitlines()
if line.startswith(REPORT_PREFIX)
]
text = "\n".join(report)
self.assertIn("POST /__test__/reset still running after", text)
self.assertIn("default executor:", text)
if sys.platform.startswith("linux"):
# SQLite's busy handler sleeps between its retries.
self.assertRegex(
text, r"kernel: asyncio_\d+ \(0x[0-9a-f]{16}\) state \w, waiting in "
)
# The plain reset's seed() waits on its first DELETE.
self.assertRegex(text, r"line \d+ in seed\n")
self.assertIn("POST /__test__/reset answered 200 after", text)
self.assertRegex(
text,
r"executor call create_app.<locals>.seed: queued \d+\.\d+s, ran \d+\.\d+s",
)
if __name__ == "__main__":
unittest.main()
-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"])
-614
View File
@@ -1,614 +0,0 @@
"""The read-only health check (System › Overview and Maintenance).
``run_health_checks`` reads one snapshot on its own ``mode=ro`` connection:
SQLite's integrity and foreign-key checks, stored prompt hashes against
``utils.hashing``, duplicate pair rows, the legacy consistency queries, image
links whose file is gone (only under readable sources) and the Needs-review
count when one is supplied. It never writes, and it never turns a check it
could not run into a count.
"""
import hashlib
import ntpath
import os
import sqlite3
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from database import health # noqa: E402
from database.operations import PromptDatabase # noqa: E402
from utils.hashing import generate_prompt_hash # noqa: E402
try:
from tests.open_handles import assert_closed # noqa: E402
except ImportError: # discovered with tests/ as the top-level directory
from open_handles import assert_closed # noqa: E402
CHECK_IDS = [
"integrity",
"foreign_keys",
"canonical_hashes",
"pair_uniqueness",
"missing_files",
"ambiguous_metadata",
"consistency",
]
def _digest(path):
if not os.path.exists(path):
return None
with open(path, "rb") as handle:
return hashlib.sha256(handle.read()).hexdigest()
class HealthTestCase(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.root = Path(os.path.realpath(self.temp.name))
self.output = self.root / "output"
self.output.mkdir()
self.db_path = str(self.root / "prompts.db")
self.db = PromptDatabase(self.db_path)
def tearDown(self):
self.db.close_all()
assert_closed(self, self.root)
self.temp.cleanup()
def save(self, text, **metadata):
return self.db.save_prompt(
text, prompt_hash=generate_prompt_hash(text), **metadata
)
def image(self, prompt_id, name, *, exists=True, folder=None):
folder = Path(folder or self.output)
path = folder / name
if exists:
path.write_bytes(b"png")
return self.db.link_image_to_prompt(prompt_id, str(path))
def run_checks(self, roots=None, **kwargs):
roots = [str(self.output)] if roots is None else roots
return self.db.run_health_checks(roots, **kwargs)
@staticmethod
def check(report, check_id):
return next(c for c in report["checks"] if c["id"] == check_id)
def raw(self):
"""A plain connection for planting damage the app itself never writes."""
conn = sqlite3.connect(self.db_path)
conn.execute("PRAGMA foreign_keys=OFF")
return conn
class TestHealthReport(HealthTestCase):
def test_clean_library_passes_except_unavailable_review(self):
prompt = self.save("A clean prompt")
self.db.save_prompt_pair("Cat", "Blur")
self.image(prompt, "a.png")
report = self.run_checks(review_count=0)
self.assertTrue(report["success"])
self.assertEqual([c["id"] for c in report["checks"]], CHECK_IDS)
self.assertEqual(
[c["label"] for c in report["checks"]],
[
"Integrity check",
"Foreign keys & pair references",
"Canonical hashes",
"Pair uniqueness",
"Missing files",
"Ambiguous metadata",
"Consistency",
],
)
for check in report["checks"]:
self.assertEqual(check["status"], "passed", check)
self.assertEqual(check["count"], 0, check)
self.assertEqual(report["status"], "passed")
self.assertRegex(report["checked_at"], r"^\d{4}-\d\d-\d\dT.*\+00:00$")
def test_health_check_never_writes(self):
prompt = self.save("Untouched")
self.image(prompt, "kept.png")
self.image(prompt, "gone.png", exists=False)
with self.raw() as conn:
conn.execute("UPDATE prompts SET hash='stale' WHERE id=?", (prompt,))
conn.close()
# The app's own connection stays open, as in the running server.
self.db.get_statistics()
files = [self.db_path, self.db_path + "-wal"]
before = [_digest(path) for path in files]
shm_size = os.path.getsize(self.db_path + "-shm")
media = sorted(os.listdir(self.output))
opened = []
real_connect = sqlite3.connect
class Recording(sqlite3.Connection):
changes_at_close = None
def close(self):
Recording.changes_at_close = self.total_changes
super().close()
def connect(*args, **kwargs):
conn = real_connect(*args, factory=Recording, **kwargs)
opened.append((args, kwargs, conn))
return conn
with patch.object(health.sqlite3, "connect", side_effect=connect):
report = self.run_checks()
self.assertEqual(report["status"], "incomplete")
self.assertEqual(len(opened), 1)
args, kwargs, conn = opened[0]
self.assertTrue(str(args[0]).endswith("?mode=ro"), args)
self.assertTrue(kwargs.get("uri"))
self.assertEqual(Recording.changes_at_close, 0)
with self.assertRaises(sqlite3.ProgrammingError): # closed in finally
conn.execute("SELECT 1")
self.assertEqual([_digest(path) for path in files], before)
# The -shm holds WAL read marks every reader updates; it never grows.
self.assertEqual(os.path.getsize(self.db_path + "-shm"), shm_size)
self.assertEqual(sorted(os.listdir(self.output)), media)
with self.db.model.get_connection() as live:
stored = live.execute(
"SELECT hash FROM prompts WHERE id=?", (prompt,)
).fetchone()[0]
self.assertEqual(stored, "stale")
def test_connection_closed_when_a_check_raises(self):
self.save("Any")
with (
patch.object(
health,
"_canonical_hashes",
side_effect=sqlite3.OperationalError("boom"),
),
self.assertLogs("prompt_manager.database.health", "ERROR"),
):
report = self.run_checks(review_count=0)
canonical = self.check(report, "canonical_hashes")
self.assertEqual(canonical["status"], "failed")
self.assertIsNone(canonical["count"])
self.assertEqual(canonical["detail"], ["The check could not finish."])
self.assertEqual(self.check(report, "integrity")["status"], "passed")
self.assertEqual(report["status"], "incomplete")
self.db.close_all()
assert_closed(self, self.root)
def test_unopenable_database_fails_every_database_check(self):
missing = PromptDatabase.__new__(PromptDatabase)
missing.model = type("M", (), {"db_path": str(self.root / "nope.db")})()
with self.assertLogs("prompt_manager.database.health", "ERROR"):
report = missing.run_health_checks([str(self.output)], review_count=0)
statuses = {c["id"]: c["status"] for c in report["checks"]}
self.assertEqual(statuses["integrity"], "failed")
self.assertEqual(statuses["missing_files"], "failed")
self.assertEqual(statuses["ambiguous_metadata"], "passed")
self.assertEqual(report["status"], "incomplete")
self.assertFalse(os.path.exists(self.root / "nope.db"))
def test_integrity_and_foreign_key_issues_reported(self):
prompt = self.save("Linked")
with self.raw() as conn:
conn.execute("CREATE TABLE probe(a)")
conn.execute("CREATE INDEX probe_a ON probe(a)")
conn.executemany("INSERT INTO probe VALUES(?)", [(i,) for i in range(16)])
conn.execute(
"INSERT INTO generated_images(prompt_id,image_path,filename,file_path)"
" VALUES(?,?,?,?)",
(9999, str(self.output / "orphan.png"), "orphan.png", "orphan"),
)
conn.execute(
"INSERT INTO prompt_tags(prompt_id,tag_id) VALUES(?,?)", (prompt, 777)
)
conn.close()
with self.raw() as conn:
conn.execute("PRAGMA writable_schema=ON")
conn.execute(
"UPDATE sqlite_master SET sql='CREATE INDEX probe_a ON probe(a DESC)'"
" WHERE name='probe_a'"
)
conn.close()
self.db.close_all() # the next read sees the edited schema
(self.output / "orphan.png").write_bytes(b"png")
with self.raw() as conn:
expected = [r[0] for r in conn.execute("PRAGMA integrity_check(100)")]
conn.close()
self.assertGreater(len(expected), 10)
report = self.run_checks(review_count=0)
integrity = self.check(report, "integrity")
self.assertEqual(integrity["status"], "issues")
self.assertEqual(integrity["count"], len(expected))
self.assertEqual(integrity["detail"], expected[:10])
self.assertIn("missing from index probe_a", integrity["detail"][0])
foreign = self.check(report, "foreign_keys")
self.assertEqual(foreign["status"], "issues")
self.assertEqual(foreign["count"], 2)
self.assertEqual(
sorted(foreign["detail"]),
[
"generated_images row 1 references a missing prompts row",
"prompt_tags row 1 references a missing tags row",
],
)
consistency = self.check(report, "consistency")
self.assertEqual(consistency["status"], "issues")
self.assertEqual(consistency["count"], 1)
self.assertEqual(
consistency["detail"], ["Image 1 references non-existent prompt 9999"]
)
self.assertEqual(report["status"], "issues")
def test_canonical_hash_mismatch_counts_rows(self):
ids = [self.save(f"Prompt {n}") for n in range(14)]
with self.raw() as conn:
for n, prompt_id in enumerate(ids[:12]):
conn.execute(
"UPDATE prompts SET hash=? WHERE id=?", (f"bad{n}", prompt_id)
)
conn.execute("UPDATE prompts SET hash=NULL WHERE id=?", (ids[12],))
conn.close()
# Normalised spellings hash alike: this one is not a mismatch.
self.save(" MIXED Case ")
report = self.run_checks(review_count=0)
canonical = self.check(report, "canonical_hashes")
self.assertEqual(canonical["status"], "issues")
self.assertEqual(canonical["count"], 13)
self.assertEqual(len(canonical["detail"]), 10)
self.assertEqual(
canonical["detail"][0],
f"Prompt {ids[0]}: the stored hash does not match its text",
)
def test_duplicate_pair_rows_reported(self):
pair = self.db.save_prompt_pair("Cat", "")
positive = self.db.get_prompt_pair(pair)["positive_prompt_id"]
self.db.save_prompt_pair("Dog", "Blur")
with self.raw() as conn:
# Only the partial index keeps positive-only pairs unique; a restored
# or hand-edited database may have lost it.
conn.execute("DROP INDEX idx_prompt_pairs_positive_only")
conn.executemany(
"INSERT INTO prompt_pairs(positive_prompt_id,negative_prompt_id)"
" VALUES(?,NULL)",
[(positive,), (positive,)],
)
conn.close()
report = self.run_checks(review_count=0)
pairs = self.check(report, "pair_uniqueness")
self.assertEqual(pairs["status"], "issues")
self.assertEqual(pairs["count"], 2)
self.assertEqual(
pairs["detail"],
[f"Positive prompt {positive} with no negative: 3 pair rows"],
)
def test_integrity_count_at_the_limit_is_flagged_capped(self):
class Rows:
def __init__(self, rows):
self.rows = rows
def execute(self, _sql):
return iter([(row,) for row in self.rows])
capped = health._integrity(Rows(["bad"] * health.INTEGRITY_LIMIT), [])
self.assertEqual(capped["status"], "issues")
self.assertEqual(capped["count"], health.INTEGRITY_LIMIT)
self.assertTrue(capped["capped"])
exact = health._integrity(Rows(["bad"] * 3), [])
self.assertEqual(exact["count"], 3)
self.assertFalse(exact["capped"])
self.assertFalse(health._integrity(Rows(["ok"]), [])["capped"])
def test_an_unexpected_error_fails_only_its_own_check(self):
self.save("Any")
with (
patch.object(health, "_pair_uniqueness", side_effect=KeyError("boom")),
self.assertLogs("prompt_manager.database.health", "ERROR"),
):
report = self.run_checks(review_count=0)
statuses = {c["id"]: c["status"] for c in report["checks"]}
self.assertEqual(statuses.pop("pair_uniqueness"), "failed")
self.assertEqual(set(statuses.values()), {"passed"})
class TestDatabaseUri(unittest.TestCase):
"""The ``mode=ro`` URI of the database file, for every path form SQLite opens."""
def test_posix_path_is_a_local_file_uri(self):
self.assertEqual(
health.readonly_uri("/data/my db#1?.db", windows=False),
"file:///data/my%20db%231%3F.db?mode=ro",
)
def test_windows_drive_path(self):
self.assertEqual(
health.readonly_uri(r"C:\Users\Me\prompts.db", windows=True),
"file:///C:/Users/Me/prompts.db?mode=ro",
)
def test_windows_unc_path_has_an_empty_authority(self):
# pathlib spells \\server\share as file://server/share, which SQLite
# rejects (a non-local authority); an empty one keeps the UNC path.
uri = health.readonly_uri(r"\\server\share\Comfy UI\prompts.db", windows=True)
self.assertEqual(uri, "file:////server/share/Comfy%20UI/prompts.db?mode=ro")
self.assertTrue(ntpath.isabs(r"\\server\share\Comfy UI\prompts.db"))
def test_uri_opens_a_path_with_uri_characters(self):
with tempfile.TemporaryDirectory() as temp:
# "?" is not a valid Windows file name character; "#", "%" and spaces are.
name = "a #b%20 c.db" if os.name == "nt" else "a #b?c%20.db"
path = os.path.join(os.path.realpath(temp), name)
conn = sqlite3.connect(path)
try:
conn.execute("CREATE TABLE t(a)")
conn.commit()
finally:
conn.close()
ro = sqlite3.connect(health.readonly_uri(path), uri=True)
try:
self.assertEqual(ro.execute("SELECT COUNT(*) FROM t").fetchone(), (0,))
with self.assertRaises(sqlite3.OperationalError):
ro.execute("INSERT INTO t VALUES(1)")
finally:
ro.close()
@unittest.skipUnless(os.name == "nt", "a native UNC path exists on Windows only")
def test_native_unc_path_opens(self):
with tempfile.TemporaryDirectory() as temp:
path = os.path.join(os.path.realpath(temp), "prompts.db")
conn = sqlite3.connect(path)
try:
conn.execute("CREATE TABLE t(a)")
conn.commit()
finally:
conn.close()
drive, rest = ntpath.splitdrive(path)
unc = "\\\\localhost\\" + drive[0] + "$" + rest
if not os.path.exists(unc):
self.skipTest("the administrative share is not reachable here")
ro = sqlite3.connect(health.readonly_uri(unc), uri=True)
try:
self.assertEqual(ro.execute("SELECT COUNT(*) FROM t").fetchone(), (0,))
finally:
ro.close()
class TestMissingFiles(HealthTestCase):
def test_missing_files_counted_under_readable_sources_only(self):
prompt = self.save("Images")
self.image(prompt, "here.png")
for n in range(12):
self.image(prompt, f"gone-{n}.png", exists=False)
elsewhere = self.root / "elsewhere"
elsewhere.mkdir()
self.image(prompt, "outside.png", exists=False, folder=elsewhere)
report = self.run_checks(review_count=0)
missing = self.check(report, "missing_files")
self.assertEqual(missing["status"], "issues")
self.assertEqual(missing["count"], 12)
self.assertEqual(len(missing["detail"]), 10)
self.assertRegex(missing["detail"][0], r"^Image \d+: gone-0\.png is missing$")
self.assertNotIn(str(self.root), " ".join(missing["detail"]))
with self.db.model.get_connection() as conn:
self.assertEqual(
conn.execute("SELECT COUNT(*) FROM generated_images").fetchone()[0],
14,
)
def test_links_outside_every_source_are_named_not_counted(self):
prompt = self.save("Images")
elsewhere = self.root / "elsewhere"
elsewhere.mkdir()
self.image(prompt, "outside.png", exists=False, folder=elsewhere)
missing = self.check(self.run_checks(review_count=0), "missing_files")
self.assertEqual(missing["status"], "passed")
self.assertEqual(missing["count"], 0)
self.assertEqual(
missing["detail"],
["1 link outside the image sources was not checked."],
)
def test_missing_file_vs_unavailable_root(self):
# Per source (S2 ruling): the readable source is counted, the absent
# one reports "unavailable" with no count, and the report is incomplete.
prompt = self.save("Images")
absent = self.root / "unmounted"
absent.mkdir()
self.image(prompt, "on-nas.png", folder=absent)
self.image(prompt, "gone.png", exists=False)
os.unlink(absent / "on-nas.png")
absent.rmdir()
report = self.run_checks([str(self.output), str(absent)], review_count=0)
missing = self.check(report, "missing_files")
self.assertEqual(missing["status"], "issues")
self.assertEqual(missing["count"], 1)
self.assertTrue(missing["partial"])
self.assertEqual(
missing["sources"],
[
{"name": "output", "status": "checked", "count": 1},
{"name": "unmounted", "status": "unavailable", "count": None},
],
)
self.assertEqual(
missing["detail"][0],
"1 image source is unavailable, so its files were not counted.",
)
self.assertRegex(missing["detail"][1], r"^Image \d+: gone\.png is missing$")
# The link under the unavailable source is neither counted nor "outside".
self.assertEqual(len(missing["detail"]), 2)
self.assertNotIn(str(self.root), " ".join(missing["detail"]))
self.assertEqual(report["status"], "incomplete")
def test_partial_sources_with_no_missing_file_are_not_passed(self):
prompt = self.save("Images")
self.image(prompt, "here.png")
absent = self.root / "unmounted"
missing = self.check(
self.run_checks([str(self.output), str(absent)], review_count=0),
"missing_files",
)
self.assertEqual(missing["status"], "unavailable")
self.assertEqual(missing["count"], 0)
self.assertTrue(missing["partial"])
self.assertEqual(
[s["status"] for s in missing["sources"]], ["checked", "unavailable"]
)
def test_every_source_unavailable_counts_nothing(self):
prompt = self.save("Images")
absent = self.root / "unmounted"
self.image(prompt, "gone.png", exists=False, folder=self.output)
report = self.run_checks([str(absent)], review_count=0)
missing = self.check(report, "missing_files")
self.assertEqual(missing["status"], "unavailable")
self.assertIsNone(missing["count"])
self.assertFalse(missing["partial"])
self.assertEqual(
missing["sources"],
[{"name": "unmounted", "status": "unavailable", "count": None}],
)
self.assertEqual(
missing["detail"],
["1 image source is unavailable, so missing files were not counted."],
)
def test_each_source_counts_its_own_missing_files(self):
prompt = self.save("Images")
archive = self.root / "archive"
archive.mkdir()
self.image(prompt, "a.png", exists=False)
self.image(prompt, "b.png", exists=False, folder=archive)
self.image(prompt, "c.png", exists=False, folder=archive)
missing = self.check(
self.run_checks([str(self.output), str(archive)], review_count=0),
"missing_files",
)
self.assertEqual(missing["count"], 3)
self.assertFalse(missing["partial"])
self.assertEqual(
[(s["name"], s["count"]) for s in missing["sources"]],
[("output", 1), ("archive", 2)],
)
def test_no_sources_is_unavailable(self):
self.save("Images")
missing = self.check(self.run_checks([], review_count=0), "missing_files")
self.assertEqual(missing["status"], "unavailable")
self.assertIsNone(missing["count"])
self.assertEqual(missing["detail"], ["No image source is available."])
self.assertEqual(missing["sources"], [])
self.assertFalse(missing["partial"])
def test_a_bad_stored_path_fails_only_missing_files(self):
prompt = self.save("Images")
self.image(prompt, "here.png")
with self.raw() as conn:
conn.execute(
"INSERT INTO generated_images(prompt_id,image_path,filename)"
" VALUES(?,?,?)",
(prompt, str(self.root / "else\x00where.png"), "bad.png"),
)
conn.close()
real_realpath = os.path.realpath
def realpath(path, *args, **kwargs):
# POSIX (and Windows before Python 3.11) raise for the NUL byte;
# newer ntpath returns it unchanged. Raise everywhere, as POSIX does.
if "\x00" in str(path):
raise ValueError("embedded null byte")
return real_realpath(path, *args, **kwargs)
with (
patch.object(health.os.path, "realpath", side_effect=realpath),
self.assertLogs("prompt_manager.database.health", "ERROR"),
):
report = self.run_checks(review_count=0)
statuses = {c["id"]: c["status"] for c in report["checks"]}
self.assertEqual(statuses.pop("missing_files"), "failed")
self.assertEqual(set(statuses.values()), {"passed"})
def test_files_are_checked_after_the_read_snapshot_ends(self):
prompt = self.save("Images")
self.image(prompt, "here.png")
self.image(prompt, "gone.png", exists=False)
opened = []
real_connect = sqlite3.connect
def connect(*args, **kwargs):
conn = real_connect(*args, **kwargs)
opened.append(conn)
return conn
real_exists = os.path.exists
in_snapshot = []
def exists(path):
for conn in opened:
try:
in_snapshot.append(conn.in_transaction)
except sqlite3.ProgrammingError: # closed: no snapshot
in_snapshot.append(False)
return real_exists(path)
with (
patch.object(health.sqlite3, "connect", side_effect=connect),
patch.object(health.os.path, "exists", side_effect=exists),
):
missing = self.check(self.run_checks(review_count=0), "missing_files")
self.assertEqual((missing["status"], missing["count"]), ("issues", 1))
self.assertEqual(len(opened), 1)
self.assertTrue(in_snapshot)
self.assertNotIn(True, in_snapshot)
class TestAmbiguousMetadata(HealthTestCase):
def test_ambiguous_metadata_unavailable_without_review_queue(self):
self.save("Any")
report = self.run_checks()
ambiguous = self.check(report, "ambiguous_metadata")
self.assertEqual(ambiguous["status"], "unavailable")
self.assertIsNone(ambiguous["count"])
self.assertEqual(ambiguous["detail"], ["Needs review is not available yet."])
self.assertEqual(report["status"], "incomplete")
def test_review_count_reports_images_needing_review(self):
report = self.run_checks(review_count=3)
ambiguous = self.check(report, "ambiguous_metadata")
self.assertEqual(ambiguous["status"], "issues")
self.assertEqual(ambiguous["count"], 3)
self.assertEqual(ambiguous["detail"], [])
self.assertEqual(report["status"], "issues")
def test_invalid_review_count_is_rejected(self):
for value in (-1, True, "3", 1.5):
with self.assertRaises(ValueError):
self.run_checks(review_count=value)
class TestOverallStatus(unittest.TestCase):
def test_overall_status_precedence(self):
def checks(*statuses):
return [{"status": status} for status in statuses]
self.assertEqual(health.overall_status(checks("passed", "passed")), "passed")
self.assertEqual(health.overall_status(checks("passed", "issues")), "issues")
self.assertEqual(
health.overall_status(checks("issues", "unavailable")), "incomplete"
)
self.assertEqual(health.overall_status(checks("failed")), "incomplete")
# A check that found issues under only some sources is incomplete too.
partial = [{"status": "issues", "partial": True}, {"status": "passed"}]
self.assertEqual(health.overall_status(partial), "incomplete")
if __name__ == "__main__":
unittest.main()
+32 -113
View File
@@ -299,126 +299,45 @@ class OutputWriterTests(fixtures.unittest.TestCase):
)
self.assertEqual(writer.context_budget.charged_bytes, 0)
def isolated_writer(self) -> HistoryWriter:
"""A ready writer whose budget no other writer or test can charge."""
writer = HistoryWriter(
lambda: self.db, clock=self.clock, context_budget=ContextBudget()
)
self.writers.append(writer)
writer.start()
self.wait_for(lambda: writer.status().accepting)
return writer
def rotate_between_transactions(self, writer, start, after, restore, hold=None):
"""Wrap ``after`` so a restore or invalidation lands right after it.
Returns the patch and an event set once that rotation has returned on
the worker. A restore is a full verify/migrate/backup/replace cycle, so
the handoff waits on this event with the generous ``BLOCK_SECONDS``
ceiling instead of the short worker-behavior deadline (TESTING.md,
Windows portability preflight).
"""
backup = os.path.join(self.directory.name, f"restore-{start.run_id}.db")
self.assertTrue(self.db.model.backup_database(backup))
original = getattr(self.db, after)
rotated = threading.Event()
def write(*args, **kwargs):
result = original(*args, **kwargs)
try:
if hold is not None:
entered, release = hold
entered.set()
release.wait(BLOCK_SECONDS)
if restore:
self.db.model.restore_from_file(backup)
else:
writer.invalidate()
finally:
rotated.set()
return result
return patch.object(self.db, after, write), rotated
def test_restore_or_generation_rotation_between_transactions_discards_evidence(
self,
):
for after in ("write_execution_finish", "write_execution_contexts_for_finish"):
for restore in (False, True):
with self.subTest(after=after, restore=restore):
# Each subtest owns its writer and budget and retires them
# before the next starts, so one slow restore cannot leave
# in-flight charges or an epoch rotation for the next.
writer = self.isolated_writer()
try:
start = self.start_command(len(self.writers))
self.db.write_execution_start(start)
rotation, rotated = self.rotate_between_transactions(
writer, start, after, restore
)
with rotation:
self.finalization(writer, start)
self.assertTrue(rotated.wait(BLOCK_SECONDS))
self.wait_for(lambda: writer.status().pending == 0)
self.assertEqual(
self.db.get_execution_outputs(start.run_id)["state"],
"not_captured",
)
self.assertEqual(
self.db.get_execution_contexts(start.run_id)["state"],
(
"complete"
if after == "write_execution_contexts_for_finish"
and not restore
else "not_captured"
),
)
self.assertEqual(writer.context_budget.charged_bytes, 0)
finally:
self.assertTrue(writer.close(BLOCK_SECONDS))
writer = self.writer()
start = self.start_command(len(self.writers))
self.db.write_execution_start(start)
backup = os.path.join(self.directory.name, "restore.db")
self.assertTrue(self.db.model.backup_database(backup))
original = getattr(self.db, after)
def test_slow_restore_between_transactions_holds_charge_only_while_in_flight(
self,
):
writer = self.isolated_writer()
start = self.start_command()
self.db.write_execution_start(start)
entered, release = self.block()
rotation, rotated = self.rotate_between_transactions(
writer, start, "write_execution_finish", True, (entered, release)
)
with rotation:
context = self.sealed(writer)
outputs = self.outputs(writer, context.lease.byte_count)
charge = writer.context_budget.charged_bytes
self.assertGreater(charge, 0)
self.assertTrue(
writer.submit_finalization(self.finish_command(start), context, outputs)
)
self.assertTrue(entered.wait(BLOCK_SECONDS))
# The worker is between transactions: the payload is owned, not leaked.
self.assertEqual(writer.status().pending, 1)
self.assertEqual(writer.context_budget.charged_bytes, charge)
release.set()
self.assertTrue(rotated.wait(BLOCK_SECONDS))
self.wait_for(lambda: writer.status().pending == 0)
self.assertEqual(writer.context_budget.charged_bytes, 0)
self.assertFalse(writer.status().accepting)
self.assertEqual(
self.db.get_execution_outputs(start.run_id)["state"], "not_captured"
)
# The restored epoch is probed again and new evidence is accepted.
self.clock.advance(5)
self.wait_for(lambda: writer.status().accepting)
restored = self.start_command(2)
self.db.write_execution_start(restored)
self.finalization(writer, restored)
self.wait_for(lambda: writer.status().pending == 0)
self.assertEqual(
self.db.get_execution_outputs(restored.run_id)["state"], "complete"
)
self.assertEqual(writer.context_budget.charged_bytes, 0)
self.assertTrue(writer.close(BLOCK_SECONDS))
def write(*args, **kwargs):
result = original(*args, **kwargs)
if restore:
self.db.model.restore_from_file(backup)
else:
writer.invalidate()
return result
with patch.object(self.db, after, write):
self.finalization(writer, start)
self.wait_for(lambda: writer.status().pending == 0)
self.assertEqual(
self.db.get_execution_outputs(start.run_id)["state"],
"not_captured",
)
self.assertEqual(
self.db.get_execution_contexts(start.run_id)["state"],
(
"complete"
if after == "write_execution_contexts_for_finish"
and not restore
else "not_captured"
),
)
self.assertEqual(writer.context_budget.charged_bytes, 0)
self.assertTrue(writer.close())
def test_invalidation_during_output_preparation_rechecks_actual_commit(self):
import database.execution_outputs as outputs_db
-45
View File
@@ -5,10 +5,7 @@ returns for the same input (tests/test_generation_params_js_parity.py checks
that directly when Node is available).
"""
import json
import os
import shutil
import subprocess # nosec B404 - runs a fixed Node one-liner
import sys
import unittest
@@ -20,7 +17,6 @@ from utils.generation_params import ( # noqa: E402
extract_generation_params,
extract_from_records,
parse_js_json,
parse_page_json,
)
try:
@@ -223,47 +219,6 @@ class ParsesLikeJavaScript(unittest.TestCase):
run("seed-beyond-double-precision")["seed"], 4974052037430000001.0
)
def test_page_parse_reads_nan_numbers_as_null(self):
# gallery.js and admin.js replace NaN with null before JSON.parse.
self.assertEqual(
parse_page_json('{"a": NaN, "b": [NaN], "t": "NaN"}'),
{"a": None, "b": [None], "t": "NaN"},
)
self.assertEqual(
parse_page_json('{"seed": 9007199254740993}'), {"seed": 9007199254740992.0}
)
for text in ('{"a": Infinity}', "[-Infinity]", "not json", "", None):
with self.subTest(text=text):
self.assertIsNone(parse_page_json(text))
@unittest.skipUnless(shutil.which("node"), "Node is not installed")
def test_page_parse_equals_the_legacy_pages_for_nan_numbers(self):
# gallery.js/admin.js: two NaN regexes, then JSON.parse (Infinity throws).
texts = [
'{"1": {"is_changed": [NaN]}, "2": {"is_changed": NaN, "v": 1.5}}',
'{"a":NaN,"b":[NaN, 2]}',
'{"a": Infinity}',
'{"seed": 9007199254740993}',
]
script = (
"const texts = JSON.parse(require('fs').readFileSync(0, 'utf8'));"
"console.log(JSON.stringify(texts.map((t) => { try { return {v: JSON.parse("
"t.replace(/:\\s*NaN\\b/g, ': null').replace(/\\bNaN\\b/g, 'null'))}; }"
" catch (e) { return null; } })));"
)
completed = subprocess.run( # nosec B603 B607 - fixed argv, no shell
["node", "-e", script],
input=json.dumps(texts),
capture_output=True,
text=True,
check=True,
timeout=60,
)
expected = json.loads(completed.stdout)
for text, page in zip(texts, expected):
with self.subTest(text=text):
self.assertEqual(parse_page_json(text), page and page["v"])
def test_duplicate_keys_keep_the_last_value(self):
self.assertEqual(parse_js_json('{"a": 1, "a": 2}'), {"a": 2})
+5 -72
View File
@@ -12,7 +12,6 @@ import json
import os
import sys
import tempfile
import threading
import types
import unittest
import unittest.mock as mock
@@ -369,29 +368,10 @@ class TestConsoleBanner(unittest.TestCase):
self.assertIn("Loaded:", banner)
class PriorFileHandler(logging.FileHandler):
"""A caller-owned sink that stays closed once its owner closes it.
``FileHandler.emit`` reopens the file when ``stream`` is None, so a record
that selected this handler before ``close()`` would reopen prior.log after
it, and Windows then refuses the temp directory cleanup (WinError 32).
``Handler.handle`` and ``FileHandler.close`` hold the same handler lock, so
checking ``_closed`` in ``emit`` is race free (as in the product's
``_OwnedRotatingFileHandler``).
"""
def emit(self, record):
if not self._closed:
super().emit(record)
class TestPackageLoggerIsolation(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
# Runs after restore_logging, before the cleanup Windows refuses while
# prior.log is open; /proc makes Linux fail the same way.
self.addCleanup(self.assert_tmp_has_no_open_handles)
self.logger = logging.getLogger("prompt_manager")
self.previous = (
self.logger.handlers[:],
@@ -401,7 +381,7 @@ class TestPackageLoggerIsolation(unittest.TestCase):
)
self.addCleanup(self.restore_logging)
self.path = os.path.join(self.tmp.name, "prior.log")
self.handler = PriorFileHandler(self.path)
self.handler = logging.FileHandler(self.path)
self.logger.handlers = [self.handler]
self.logger.setLevel(logging.DEBUG)
self.logger.propagate = True
@@ -418,20 +398,16 @@ class TestPackageLoggerIsolation(unittest.TestCase):
self.server.PromptServer.instance.prompt_queue.task_done = lambda *a, **k: None
def restore_logging(self):
# Detach before closing, so new records stop selecting these handlers.
retired = [h for h in self.logger.handlers if h not in self.previous[0]]
for handler in self.logger.handlers:
if handler not in self.previous[0]:
handler.close()
self.handler.close()
(
self.logger.handlers,
self.logger.level,
self.logger.propagate,
self.logger.disabled,
) = self.previous
for handler in retired:
handler.close()
self.handler.close()
def assert_tmp_has_no_open_handles(self):
self.assertEqual(open_handles_under(self.tmp.name), [])
def assert_logging_restored(self, handlers, registration):
for key, module in self.saved_modules.items():
@@ -480,49 +456,6 @@ class TestPackageLoggerIsolation(unittest.TestCase):
self.assertEqual(open_at_disposal, [True])
self.assert_logging_restored(handlers, registration)
def test_record_in_flight_at_restore_cannot_reopen_prior_log(self):
"""A record that selected prior.log before restore never reopens it.
Leaked daemon threads of other tests (image workers, watchdog) log
under ``prompt_manager.*``. One that reaches the prior handler while
restore_logging closes it would, with a plain FileHandler, reopen the
file on emit and keep it open through TemporaryDirectory.cleanup().
"""
message = "record selected before restore_logging"
selected = threading.Event()
resume = threading.Event()
errors = []
def pause_before_handler_lock(record):
if record.getMessage() == message:
selected.set()
if not resume.wait(5):
raise RuntimeError("logging thread was not released")
return True
def log_from_background_thread():
try:
logging.getLogger("prompt_manager.late_emitter").warning(message)
except BaseException as error:
errors.append(error)
self.handler.addFilter(pause_before_handler_lock)
worker = threading.Thread(target=log_from_background_thread, daemon=True)
worker.start()
try:
self.assertTrue(selected.wait(5), "record did not select prior.log")
self.restore_logging()
self.assertIsNone(self.handler.stream)
finally:
resume.set()
worker.join(5)
self.assertFalse(worker.is_alive(), "logging thread did not finish")
self.assertEqual(errors, [])
self.assertIsNone(self.handler.stream)
self.assert_tmp_has_no_open_handles()
with open(self.path, encoding="utf-8") as log:
self.assertNotIn(message, log.read())
def test_failed_package_import_closes_handlers_and_restores_logging(self):
name = "prompt_manager_pkg_logger_failure"
captured = {}
+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):
-97
View File
@@ -1706,103 +1706,6 @@ class TestLibrary(unittest.TestCase):
self.db.list_library_images(positive, pair_id=foreign, archive="all")
class LibraryCounts(unittest.TestCase):
"""System › Overview counts and usage statistics follow Library eligibility."""
setUp = TestLibrary.setUp
tearDown = TestLibrary.tearDown
save = TestLibrary.save
state = TestLibrary.state
def test_counts_use_library_eligibility(self):
self.db.save_prompt_pair("Cat", "Blur")
archived = self.db.save_prompt_pair("Cat", "Noise")
self.db.archive_prompt_pair(archived)
titled_pair = self.db.save_prompt_pair("Dog", "Titled negative")
titled = self.db.get_prompt_pair(titled_pair)["negative_prompt_id"]
self.state(titled, "A titled parent") # zero pairs of its own: counted
legacy = self.save("Legacy")
self.db.link_image_to_prompt(legacy, str(Path(self.temp.name) / "a.png"))
self.db.link_image_to_prompt(legacy, str(Path(self.temp.name) / "b.png"))
counts = self.db.library_counts()
# Cat, Dog, the titled negative and Legacy; "Blur" and "Noise" are
# negative-only.
self.assertEqual(counts["positive_prompts"], 4)
# Cat+Blur and Dog+Titled negative; the archived pair is excluded.
self.assertEqual(counts["negative_variants"], 2)
self.assertEqual(counts["images"], 2)
self.assertEqual(counts["rated_images"], 0)
listed = self.db.list_library()
self.assertEqual(counts["positive_prompts"], listed["total"])
self.assertEqual(counts["negative_variants"], listed["variant_total"])
def test_counts_of_an_empty_library_are_zero(self):
self.assertEqual(
self.db.library_counts(),
{
"positive_prompts": 0,
"negative_variants": 0,
"images": 0,
"rated_images": 0,
},
)
def test_rated_images_follow_their_linked_prompt_rating(self):
# An image carries no rating of its own: it shows its linked prompt's
# (the Images "Highest rated" sort), so "rated" counts those images.
folder = Path(self.temp.name)
rated = self.save("Rated", rating=4)
unrated = self.save("Unrated")
for name in ("a.png", "b.png", "c.png"):
self.db.link_image_to_prompt(rated, str(folder / name))
self.db.link_image_to_prompt(unrated, str(folder / "d.png"))
counts = self.db.library_counts()
self.assertEqual(counts["images"], 4)
self.assertEqual(counts["rated_images"], 3)
self.db.update_prompt_rating(unrated, 2)
self.assertEqual(self.db.library_counts()["rated_images"], 4)
def test_statistics_sum_real_run_counts(self):
a = self.save("Alpha", category="Landscapes", rating=5)
b = self.save("Beta", category="Landscapes", rating=4)
self.save("Gamma", category="Landscapes") # unrated: not in the average
c = self.save("Delta", category="Portraits", rating=3)
blank = self.save("Epsilon", category=" ")
uncategorised = self.save("Zeta")
self.db.record_prompt_use(a, times=3)
self.db.record_prompt_use(b, times=2)
self.db.record_prompt_use(c)
self.db.record_prompt_use(blank)
self.db.record_prompt_use(uncategorised, times=4)
# A negative-only prompt's runs are the pair's runs: never counted twice.
pair = self.db.save_prompt_pair("Alpha", "Only negative")
negative = self.db.get_prompt_pair(pair)["negative_prompt_id"]
with self.db.model.get_connection() as connection:
connection.execute(
"UPDATE prompts SET category='Landscapes',run_count=50 WHERE id=?",
(negative,),
)
rows = self.db.usage_by_category()
self.assertEqual(
rows,
[
{"category": "Landscapes", "runs": 5, "average_rating": 4.5},
{"category": None, "runs": 5, "average_rating": None},
{"category": "Portraits", "runs": 1, "average_rating": 3.0},
],
)
def test_counts_and_usage_write_nothing(self):
self.save("Alpha", category="A")
with self.db.model.get_connection() as connection:
before = connection.total_changes
self.db.library_counts()
self.db.usage_by_category()
with self.db.model.get_connection() as connection:
self.assertEqual(connection.total_changes, before)
self.assertFalse(connection.in_transaction)
class DisplayTitleUnits(unittest.TestCase):
"""The first-line title is cut like the Library's JavaScript title()."""
-335
View File
@@ -51,19 +51,12 @@ from utils.prompt_inspector import (
read_inspection_file,
)
from utils.prompt_inspector import inspect_prompt_evidence, unreadable_evidence
from utils.prompt_pair_resolver import ADDED_CONDITIONING_REASON, INSPECTION_PROFILE
try:
from tests import inspection_parity as parity
from tests import local_resolver_cases as resolver_cases
from tests import local_resolver_selection_cases as selection_cases
from tests.open_handles import assert_closed
from tests.test_utils_video_metadata import HAVE_FFMPEG, write_video
except ImportError: # pragma: no cover - discover -s tests puts tests/ first
import inspection_parity as parity
import local_resolver_cases as resolver_cases
import local_resolver_selection_cases as selection_cases
from open_handles import assert_closed
from test_utils_video_metadata import HAVE_FFMPEG, write_video
@@ -1118,333 +1111,5 @@ class LocalProjectionParityTests(unittest.TestCase):
self.assertTrue({"parsed", "malformed", "limited"} <= seen)
def _server_evidence(records):
return inspect_prompt_evidence(records, None, profile=INSPECTION_PROFILE)
class LocalResolverParityTests(unittest.TestCase):
"""``resolveLocalEvidence`` answers as ``inspect_prompt_evidence`` (L3 Tasks 8-9).
Python is the oracle: every expected value is the server's own answer on the
same record texts. Task 8 ported one sampler's tracing; Task 9 ports
candidate selection (several samplers, the identical-branch rule of #270,
preview-only stages, detailer stages) and A1111 ``parameters``, so no
record set is left to the server any more.
"""
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.addCleanup(assert_closed, self, temporary.name)
self.directory = Path(temporary.name)
def _compare(self, cases):
"""Assert each record set's local evidence equal; return the server answers."""
observed = _run_oracle(
self,
[
{"name": name, "mode": "resolve", "records": records}
for name, records in cases.items()
],
)
answers = {}
for name, records in cases.items():
with self.subTest(case=name):
local = observed[name]
server = _server_evidence(records)
answers[name] = server
self.assertEqual(
_canonical_bytes(local["evidence"]), _canonical_bytes(server)
)
return answers
def test_local_roles_match_execution_and_single_chain_vectors(self):
files = {}
for name in parity.COMMITTED:
files[name] = parity.fixture_path(name)
for name in parity.TEST_TIME:
path = parity.fixture_path(name, self.directory)
parity.write_png(path, parity.build(name))
files[name] = path
observed = _run_oracle(
self,
[
{"name": name, "mode": "resolve", "path": str(path)}
for name, path in files.items()
],
)
vectors = {
vector["fixture"]: vector
for vector in json.loads(parity.VECTORS_PATH.read_text(encoding="utf-8"))
if vector["profile"] == "inspection"
}
compared = 0
for name, path in files.items():
with self.subTest(fixture=name):
local = observed[name]
try:
records = read_inspection_file(path).metadata
except MetadataReadError:
self.assertTrue(local["unreadable"])
continue
# The single-chain subset (Task 8); the rest is Task 9's test below.
if not resolver_cases.single_chain(records):
continue
server = _server_evidence(records)
self.assertEqual(
_canonical_bytes(local["evidence"]), _canonical_bytes(server)
)
# The committed vector agrees with the server read here.
expect = vectors[name]["expect"]
self.assertEqual(server["resolution"]["state"], expect["state"])
self.assertEqual(
server["resolution"]["reason_code"], expect["reason_code"]
)
compared += 1
self.assertGreaterEqual(compared, 20)
def test_local_roles_match_inspection_vectors(self):
"""Every inspection vector: local prompts, resolution and facts equal Images'.
The facts are ``extractGenerationFacts`` (with the legacy fill) on the
local payload, against the facts the committed vectors pin for the
server's response on the same file.
"""
vectors = [
vector
for vector in json.loads(parity.VECTORS_PATH.read_text(encoding="utf-8"))
if vector["profile"] == "inspection"
]
files = {}
for vector in vectors:
name = vector["fixture"]
if name in parity.TEST_TIME:
path = parity.fixture_path(name, self.directory)
parity.write_png(path, parity.build(name))
else:
path = parity.fixture_path(name)
files[name] = path
observed = _run_oracle(
self,
[
{"name": name, "mode": "resolve", "path": str(path)}
for name, path in files.items()
],
)
for vector in vectors:
name = vector["fixture"]
with self.subTest(fixture=name):
local, expect = observed[name], vector["expect"]
try:
server = _server_evidence(
read_inspection_file(files[name]).metadata
)
except MetadataReadError:
server = unreadable_evidence()
self.assertTrue(local["unreadable"])
self.assertEqual(
_canonical_bytes(local["evidence"]), _canonical_bytes(server)
)
resolution, prompts = (
local["evidence"]["resolution"],
local["evidence"]["prompts"],
)
self.assertEqual(
(
resolution["state"],
resolution["reason"],
resolution["reason_code"],
),
(expect["state"], expect["reason"], expect["reason_code"]),
)
self.assertEqual(
[
parity._role_text(
prompts[role]["status"], prompts[role]["text"]
)
for role in ("positive", "negative")
],
[expect["positive"], expect["negative"]],
)
self.assertEqual(local["facts"], expect["facts"])
self.assertEqual(len(vectors), 28)
def test_local_roles_match_selection_record_sets(self):
answers = self._compare(selection_cases.selection_cases())
reasons = {answer["resolution"]["reason"] for answer in answers.values()}
self.assertTrue(
{
"Every sampler branch uses the same prompt pair",
"Multiple sampler branches",
"Sampler stages have different prompts",
"Cyclic sampler stage ancestry",
"Expected an image link",
"Unsupported sampler stage ancestry",
"Resolution depth limit reached",
"A1111 settings footer is not unique",
"Multiple negative prompt delimiters",
"Negative prompt delimiter is absent",
"Parameters exceed the length limit",
"No A1111 parameters",
}
<= reasons
)
# A stage reaching only previews is dropped: one sampler is named.
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 = {}
for seed in range(4):
cases.update(selection_cases.selection_fuzz_cases(seed, 150))
answers = self._compare(cases)
reasons = [answer["resolution"]["reason"] for answer in answers.values()]
self.assertGreater(reasons.count("Multiple sampler branches"), 20)
self.assertGreater(reasons.count("Sampler stages have different prompts"), 0)
self.assertGreater(
reasons.count("Every sampler branch uses the same prompt pair"), 0
)
self.assertGreater(reasons.count("A1111 settings footer is not unique"), 0)
def test_local_roles_match_golden_record_sets(self):
answers = self._compare(resolver_cases.golden_cases())
codes = {answer["resolution"]["reason_code"] for answer in answers.values()}
self.assertTrue(
{
"malformed",
"unsupported-node",
"runtime-value",
"composite",
"work-limit",
"depth-limit",
"text-limit",
"graph-ceiling",
"no-sampler",
None,
}
<= codes
)
sources = {
side["source"]
for answer in answers.values()
for side in answer["prompts"].values()
}
self.assertTrue(
{"execution", "graph-inferred", "zeroed-conditioning"} <= sources
)
def test_committed_resolver_vectors_are_current(self):
"""The Node unit test's committed answers are still the server's."""
committed = json.loads(resolver_cases.VECTORS_PATH.read_text(encoding="utf-8"))
self.assertEqual(
committed,
json.loads(json.dumps(resolver_cases.committed_vectors())),
"regenerate with: python3 -m tests.local_resolver_cases --write",
)
def test_local_roles_match_at_every_work_bound(self):
answers = self._compare(resolver_cases.boundary_cases())
for name in ("visits", "depth", "scanned"):
self.assertEqual(answers[f"{name}-edge"]["resolution"]["state"], "resolved")
self.assertIn(
answers[f"{name}-over"]["resolution"]["reason_code"],
("work-limit", "depth-limit"),
)
self.assertEqual(
answers["named-astral"]["resolution"]["node"]["class_type"],
"\U0001f600" * 64,
)
def test_local_json_depth_cap_at_its_boundary(self):
"""Both kinds of deep record at the browser's JSON depth cap (1,000).
Locally a record nested past the cap is invalid JSON: a prompt is
malformed and a workflow is ignored, as the server answers when
``json.loads`` raises RecursionError. The server's own limit depends on
the Python (TESTING.md, "Deep JSON"): about 990 on 3.10 and 3.11, so it
agrees past the cap there; about 10,000 on 3.12 and 3.13 and over
50,000 on 3.14, so it agrees up to the cap there and reads deeper.
"""
cases = resolver_cases.depth_cases()
observed = _run_oracle(
self,
[
{"name": name, "mode": "resolve", "records": records}
for name, _kind, _depth, records in cases
],
)
newer = sys.version_info >= (3, 12)
compared = 0
for name, kind, depth, records in cases:
with self.subTest(case=name):
local = observed[name]["evidence"]["resolution"]
within = depth <= resolver_cases.JSON_MAX_DEPTH
if kind == "prompt":
expected = (
("resolved", None) if within else ("unresolved", "malformed")
)
else:
# A workflow that is read finds the overwritten pair (R1).
expected = (
("unresolved", "malformed") if within else ("resolved", None)
)
self.assertEqual((local["state"], local["reason_code"]), expected)
server = _server_evidence(records)
if server["resolution"]["state"] == local["state"]:
self.assertEqual(
_canonical_bytes(observed[name]["evidence"]),
_canonical_bytes(server),
)
compared += 1
else:
# The documented divergence only: an older server refuses
# a little below the cap, a newer one reads past it.
self.assertEqual(within, not newer)
self.assertGreaterEqual(compared, 2)
def test_local_roles_match_fuzzed_record_sets(self):
cases = {}
for seed in range(4):
cases.update(resolver_cases.fuzz_cases(seed, 150))
answers = self._compare(cases)
states = [answer["resolution"]["state"] for answer in answers.values()]
# Coherent graphs reach real results, not only early refusals.
self.assertGreater(states.count("resolved"), len(states) // 5)
self.assertGreater(states.count("ambiguous"), 0)
self.assertGreater(states.count("unresolved"), len(states) // 10)
self.assertGreaterEqual(
len({answer["resolution"]["reason_code"] for answer in answers.values()}),
5,
)
if __name__ == "__main__":
unittest.main()
-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()
-116
View File
@@ -165,122 +165,6 @@ class TestPairScan(AdminAPITestCase):
)
self.assertEqual(self.api.db.get_recent_prompts()["prompts"], [])
def _nan_scanned(self, name, prompt_graph):
"""Scan ``prompt_graph`` as ComfyUI writes it: ``is_changed`` is NaN."""
stamped = {
node_id: {**node, "is_changed": [float("nan")]}
for node_id, node in prompt_graph.items()
}
text = json.dumps(stamped)
self.assertIn("NaN", text)
return self._scanned_text(name, text)
def _scanned_text(self, name, prompt_text):
meta = {"prompt": prompt_text}
return self.api._ingest_scanned_file(self.output_dir / name, meta)
@staticmethod
def _hires_fix_graph(positive):
"""A PromptManager prompt on a base sampler and a hires-fix sampler.
The base latent comes from a custom batch node and the hires pass from
a custom image filter: no sampler branch traces, so the scan links the
prompt the legacy pages show.
"""
return {
"1": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": "m.safetensors"},
},
"2": {
"class_type": "MyTriggerWords",
"inputs": {"trigger_words": ["1", 2]},
},
"3": {
"class_type": "PromptManager",
"inputs": {
"text": positive,
"prepend_text": ["2", 0],
"append_text": "",
"clip": ["1", 1],
},
},
"4": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "blur", "clip": ["1", 1]},
},
"5": {"class_type": "MyLatentBatch", "inputs": {"batch_size": 6}},
"6": {
"class_type": "KSampler",
"inputs": {
"seed": 7,
"model": ["1", 0],
"positive": ["3", 0],
"negative": ["4", 0],
"latent_image": ["5", 0],
},
},
"7": {
"class_type": "VAEDecode",
"inputs": {"samples": ["6", 0], "vae": ["1", 2]},
},
"8": {"class_type": "MyImageFilter", "inputs": {"images": ["7", 0]}},
"9": {
"class_type": "VAEEncode",
"inputs": {"pixels": ["8", 0], "vae": ["1", 2]},
},
"14": {
"class_type": "KSampler",
"inputs": {
"seed": 7,
"denoise": 0.25,
"model": ["1", 0],
"positive": ["3", 0],
"negative": ["4", 0],
"latent_image": ["9", 0],
},
},
"15": {
"class_type": "VAEDecode",
"inputs": {"samples": ["14", 0], "vae": ["1", 2]},
},
"16": {"class_type": "SaveImage", "inputs": {"images": ["15", 0]}},
}
def test_hires_fix_prompt_links_with_or_without_nan_is_changed(self):
# Legacy parity: the legacy pages read NaN as null before parsing, so they
# showed this prompt; the scan left NaN-stamped files in review.
for name, scan in (("plain.png", None), ("stamped.png", "nan")):
with self.subTest(name=name):
g = self._hires_fix_graph("cat on a sofa")
if scan:
status = self._nan_scanned(name, g)
else:
status = self._scanned(name, g)
self.assertIn(status, ("added", "linked"))
self.assertEqual(self._texts(), ["cat on a sofa"])
def test_untraced_single_sampler_links_the_legacy_prompt_with_nan(self):
g = self._hires_fix_graph("cat on a sofa")
for node_id in ("7", "8", "9", "14", "15"):
del g[node_id]
g["16"]["inputs"]["images"] = ["6", 0]
self.assertEqual(self._nan_scanned("single.png", g), "added")
self.assertEqual(self._texts(), ["cat on a sofa"])
def test_nan_inside_prompt_text_is_kept(self):
# Only the NaN number reads as null; legacy's regex also rewrote text.
g = self._hires_fix_graph("NaN cat, value: NaN")
self.assertEqual(self._nan_scanned("text.png", g), "added")
self.assertEqual(self._texts(), ["NaN cat, value: NaN"])
def test_infinity_is_still_unreadable_like_the_legacy_pages(self):
text = json.dumps(self._hires_fix_graph("cat")).replace(
'"seed": 7', '"seed": Infinity'
)
self.assertIn(self._scanned_text("inf.png", text), ("ambiguous", "unresolved"))
self.assertEqual(self._texts(), [])
def test_raw_png_workflow_is_stored_as_object_and_repeat_is_unchanged(self):
path = self.output_dir / "workflow.png"
workflow = {"nodes": [{"id": 1, "type": "PromptManagerPair"}]}
+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()
-479
View File
@@ -1,479 +0,0 @@
"""System routes (py/api/system.py): health check job, Overview and statistics.
The health check runs off-loop as a ``read`` registry job of kind ``health``
(one at a time, beside a scan), and the API keeps its last report. The
Overview reads only real values: Library-eligible counts, indexed images, the
last health result or null, no backup until one exists, and the effective
sources and whether they were auto-detected. The missing-thumbnail count is not
part of it: the Overview reads ``/images/thumbnails/status`` on its own (its
walk of every source must not hold the other values back).
"""
import asyncio
import os
import sqlite3
import sys
import threading
from pathlib import Path
from unittest.mock import MagicMock, patch
_mock_server = MagicMock()
_mock_server.PromptServer.instance.routes = MagicMock()
sys.modules.setdefault("server", _mock_server)
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from py.api.jobs import HEALTH_JOB_KIND # noqa: E402
from py.config import GalleryConfig # noqa: E402
from utils.hashing import generate_prompt_hash # noqa: E402
try:
from tests.test_api_admin import AdminAPITestCase # noqa: E402
except ImportError: # discovered with tests/ as the top-level directory
from test_api_admin import AdminAPITestCase # noqa: E402
WAIT = 5
class SystemAPITestCase(AdminAPITestCase):
async def get_application(self):
app = await super().get_application()
# Auto-detection searches the disk; here it finds the fake ComfyUI only.
self.api._find_comfyui_output_dir = lambda: (
str(self.output_dir) if self.output_dir.is_dir() else None
)
return app
def save(self, text, **metadata):
return self.api.db.save_prompt(
text, prompt_hash=generate_prompt_hash(text), **metadata
)
def image(self, prompt_id, name, *, exists=True, folder=None):
path = Path(folder or self.output_dir) / name
if exists:
path.write_bytes(b"png")
return self.api.db.link_image_to_prompt(prompt_id, str(path))
async def json(self, method, url, status=200):
response = await self.client.request(method, url)
self.assertEqual(response.status, status, await response.text())
return await response.json()
async def restore_snapshot(self):
"""Restore the live database from a copy of itself through the legacy route."""
from contextlib import closing
from aiohttp import FormData
snapshot = os.path.join(self.tmpdir, "snapshot.db")
with closing(sqlite3.connect(self.api.db.model.db_path)) as src:
with closing(sqlite3.connect(snapshot)) as dst:
src.backup(dst)
with open(snapshot, "rb") as handle:
upload = handle.read()
form = FormData()
form.add_field("database_file", upload, filename="upload.db")
response = await self.client.request(
"POST", "/prompt_manager/restore", data=form
)
self.assertEqual(response.status, 200, await response.text())
async def run_health(self):
body = await self.json("POST", "/prompt_manager/system/health", 202)
job = self.api._jobs.get(body["job"]["id"])
await asyncio.wait_for(asyncio.shield(job.task), WAIT)
return body["job"], job
class TestHealthRoute(SystemAPITestCase):
async def test_no_report_before_a_check(self):
body = await self.json("GET", "/prompt_manager/system/health")
self.assertEqual(body, {"success": True, "report": None})
async def test_health_check_runs_as_a_read_job_and_keeps_its_report(self):
prompt = self.save("Images")
self.image(prompt, "kept.png")
self.image(prompt, "gone.png", exists=False)
summary, job = await self.run_health()
self.assertEqual(summary["kind"], HEALTH_JOB_KIND)
self.assertEqual(summary["title"], "Health check")
self.assertEqual(summary["admission"], "read")
self.assertFalse(summary["cancellable"])
self.assertEqual(summary["counters"], {})
self.assertEqual(job.state, "finished")
self.assertEqual(job.progress, 100)
self.assertEqual(job.status_line, "Health check incomplete")
report = (await self.json("GET", "/prompt_manager/system/health"))["report"]
self.assertEqual(report["status"], "incomplete")
missing = next(c for c in report["checks"] if c["id"] == "missing_files")
self.assertEqual((missing["status"], missing["count"]), ("issues", 1))
ambiguous = next(c for c in report["checks"] if c["id"] == "ambiguous_metadata")
self.assertEqual(ambiguous["status"], "unavailable")
self.assertNotIn(self.tmpdir, str(report))
async def test_health_check_is_single_flight_and_runs_beside_a_scan(self):
release = threading.Event()
entered = threading.Event()
real = self.api.db.run_health_checks
def gated(*args, **kwargs):
entered.set()
release.wait(WAIT)
return real(*args, **kwargs)
scan_gate = asyncio.Event()
async def scanning(job):
await scan_gate.wait()
scan = self.api._jobs.start(
"scan", "Metadata scan", admission="records", runner=scanning
)
with patch.object(self.api.db, "run_health_checks", side_effect=gated):
first = await self.json("POST", "/prompt_manager/system/health", 202)
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, entered.wait, WAIT)
busy = await self.json("POST", "/prompt_manager/system/health", 409)
self.assertEqual(busy["code"], "busy")
self.assertEqual(
busy["running"], {"id": first["job"]["id"], "title": "Health check"}
)
release.set()
await asyncio.wait_for(self.api._jobs.get(first["job"]["id"]).task, WAIT)
scan_gate.set()
await asyncio.wait_for(scan.task, WAIT)
async def test_failed_check_keeps_the_previous_report(self):
await self.run_health()
before = (await self.json("GET", "/prompt_manager/system/health"))["report"]
with (
patch.object(
self.api.db, "run_health_checks", side_effect=RuntimeError("boom")
),
self.assertLogs("prompt_manager.api", "ERROR"),
):
body = await self.json("POST", "/prompt_manager/system/health", 202)
job = self.api._jobs.get(body["job"]["id"])
await asyncio.wait_for(asyncio.shield(job.task), WAIT)
self.assertEqual(job.state, "failed")
after = (await self.json("GET", "/prompt_manager/system/health"))["report"]
self.assertEqual(after, before)
async def test_absent_configured_source_is_reported_per_source(self):
prompt = self.save("Images")
self.image(prompt, "gone.png", exists=False)
absent = self.comfy_dir / "output" / "unmounted"
GalleryConfig.MONITORING_DIRECTORIES = [
self._root(self.output_dir),
str(absent),
]
await self.run_health()
report = (await self.json("GET", "/prompt_manager/system/health"))["report"]
missing = next(c for c in report["checks"] if c["id"] == "missing_files")
# Per source: the readable output is counted, the absent one is not.
self.assertEqual(missing["status"], "issues")
self.assertEqual(missing["count"], 1)
self.assertTrue(missing["partial"])
self.assertEqual(
[(s["status"], s["count"]) for s in missing["sources"]],
[("checked", 1), ("unavailable", None)],
)
self.assertEqual(report["status"], "incomplete")
async def test_restore_clears_health_report(self):
self.save("Any")
await self.run_health()
self.assertIsNotNone(
(await self.json("GET", "/prompt_manager/system/health"))["report"]
)
await self.restore_snapshot()
body = await self.json("GET", "/prompt_manager/system/health")
self.assertEqual(body, {"success": True, "report": None})
overview = await self.json("GET", "/prompt_manager/system/overview")
self.assertIsNone(overview["health"])
async def test_check_finishing_after_a_restore_is_not_kept(self):
# A check that started on the old database describes it, not the new one.
self.save("Any")
release = threading.Event()
entered = threading.Event()
real = self.api.db.run_health_checks
def gated(*args, **kwargs):
entered.set()
release.wait(WAIT)
return real(*args, **kwargs)
with patch.object(self.api.db, "run_health_checks", side_effect=gated):
body = await self.json("POST", "/prompt_manager/system/health", 202)
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, entered.wait, WAIT)
await self.restore_snapshot()
release.set()
job = self.api._jobs.get(body["job"]["id"])
await asyncio.wait_for(asyncio.shield(job.task), WAIT)
self.assertEqual(job.state, "finished")
body = await self.json("GET", "/prompt_manager/system/health")
self.assertEqual(body, {"success": True, "report": None})
# A check started on the restored database is kept again.
await self.run_health()
self.assertIsNotNone(
(await self.json("GET", "/prompt_manager/system/health"))["report"]
)
async def test_health_sources_resolve_off_the_event_loop(self):
loop_thread = threading.get_ident()
threads = []
real = self.api._health_roots
def recording():
threads.append(threading.get_ident())
return real()
with patch.object(self.api, "_health_roots", side_effect=recording):
await self.run_health()
self.assertEqual(len(threads), 1)
self.assertNotEqual(threads[0], loop_thread)
async def test_health_start_failure_is_an_error_envelope(self):
with (
patch.object(self.api, "_health_roots", side_effect=RuntimeError("secret")),
self.assertLogs("prompt_manager.api", "ERROR"),
):
body = await self.json("POST", "/prompt_manager/system/health", 500)
self.assertEqual(
body,
{
"success": False,
"code": None,
"error": "Failed to start the health check",
},
)
self.assertEqual(self.api._jobs.list(), [])
class TestOverviewRoute(SystemAPITestCase):
async def test_overview_reports_real_counts(self):
self.api.db.save_prompt_pair("Cat", "Blur")
archived = self.api.db.save_prompt_pair("Cat", "Noise")
self.api.db.archive_prompt_pair(archived)
legacy = self.save("Legacy")
self.image(legacy, "a.png")
self.image(legacy, "b.png")
body = await self.json("GET", "/prompt_manager/system/overview")
self.assertTrue(body["success"])
self.assertEqual(
body["library"], {"positive_prompts": 2, "negative_variants": 1}
)
self.assertEqual(body["images"], {"indexed": 2, "rated": 0})
self.assertEqual(
set(body),
{
"success",
"library",
"images",
"health",
"backup",
"needs_review",
"sources",
},
)
async def test_overview_rated_counts_images_of_rated_prompts(self):
rated = self.save("Rated", rating=5)
self.image(rated, "a.png")
self.image(rated, "b.png")
self.image(self.save("Unrated"), "c.png")
body = await self.json("GET", "/prompt_manager/system/overview")
self.assertEqual(body["images"], {"indexed": 3, "rated": 2})
async def test_overview_never_fabricates_backup_or_health(self):
body = await self.json("GET", "/prompt_manager/system/overview")
self.assertIsNone(body["health"])
self.assertIsNone(body["backup"])
self.assertEqual(body["needs_review"], {"pending": None})
self.save("Any")
await self.run_health()
report = (await self.json("GET", "/prompt_manager/system/health"))["report"]
body = await self.json("GET", "/prompt_manager/system/overview")
self.assertEqual(
body["health"],
{"checked_at": report["checked_at"], "status": "incomplete", "issues": 0},
)
self.assertIsNone(body["backup"])
async def test_overview_health_counts_checks_with_issues(self):
prompt = self.save("Images")
self.image(prompt, "gone.png", exists=False)
self.image(prompt, "gone-too.png", exists=False)
await self.run_health()
body = await self.json("GET", "/prompt_manager/system/overview")
self.assertEqual(body["health"]["issues"], 1)
async def test_overview_sources_distinguish_auto_detect(self):
body = await self.json("GET", "/prompt_manager/system/overview")
self.assertEqual(
body["sources"], {"configured": 0, "effective": 1, "auto_detected": True}
)
second = self.output_dir / "archive"
second.mkdir()
GalleryConfig.MONITORING_DIRECTORIES = [
self._root(self.output_dir),
self._root(second),
]
body = await self.json("GET", "/prompt_manager/system/overview")
self.assertEqual(
body["sources"], {"configured": 2, "effective": 2, "auto_detected": False}
)
# Every configured source unavailable: ComfyUI's output is used instead.
GalleryConfig.MONITORING_DIRECTORIES = [str(self.comfy_dir / "absent")]
body = await self.json("GET", "/prompt_manager/system/overview")
self.assertEqual(
body["sources"], {"configured": 1, "effective": 1, "auto_detected": True}
)
async def test_overview_never_walks_the_sources_for_thumbnails(self):
(self.output_dir / "one.png").write_bytes(b"png")
with patch.object(
self.api,
"_plan_gallery_thumbnails",
side_effect=AssertionError("walked"),
):
body = await self.json("GET", "/prompt_manager/system/overview")
self.assertTrue(body["success"])
self.assertNotIn("thumbnails", body)
async def test_overview_sources_resolve_off_the_event_loop(self):
loop_thread = threading.get_ident()
threads = []
real = self.api._output_dirs_with_origin
def recording():
threads.append(threading.get_ident())
return real()
with patch.object(self.api, "_output_dirs_with_origin", side_effect=recording):
body = await self.json("GET", "/prompt_manager/system/overview")
self.assertEqual(body["sources"]["effective"], 1)
self.assertEqual(len(threads), 1)
self.assertNotEqual(threads[0], loop_thread)
async def test_overview_failure_is_an_error_envelope(self):
with (
patch.object(
self.api.db, "library_counts", side_effect=RuntimeError("secret")
),
self.assertLogs("prompt_manager.api", "ERROR"),
):
body = await self.json("GET", "/prompt_manager/system/overview", 500)
self.assertEqual(
body,
{"success": False, "code": None, "error": "Failed to read the overview"},
)
class TestStatisticsRoute(SystemAPITestCase):
async def test_statistics_sum_real_run_counts(self):
a = self.save("Alpha", category="Landscapes", rating=5)
b = self.save("Beta", category="Portraits", rating=2)
self.api.db.record_prompt_use(a, times=3)
self.api.db.record_prompt_use(b)
body = await self.json("GET", "/prompt_manager/system/statistics")
self.assertEqual(
body["by_category"],
[
{"category": "Landscapes", "runs": 3, "average_rating": 5.0},
{"category": "Portraits", "runs": 1, "average_rating": 2.0},
],
)
self.assertEqual(body["totals"], self.api.db.get_statistics())
self.assertTrue(body["success"])
async def test_statistics_lists_rated_images_for_library_totals(self):
# D96: the Statistics dialog's "Library totals" reads the rated count
# with the runs (one read), from the same images the Overview counts.
rated = self.save("Rated", rating=4)
self.image(rated, "a.png")
self.image(self.save("Unrated"), "b.png")
body = await self.json("GET", "/prompt_manager/system/statistics")
self.assertEqual(body["rated_images"], 1)
self.assertEqual(
set(body), {"success", "by_category", "totals", "rated_images"}
)
async def test_statistics_failure_is_an_error_envelope(self):
with (
patch.object(
self.api.db, "usage_by_category", side_effect=RuntimeError("secret")
),
self.assertLogs("prompt_manager.api", "ERROR"),
):
body = await self.json("GET", "/prompt_manager/system/statistics", 500)
self.assertEqual(
body,
{"success": False, "code": None, "error": "Failed to read statistics"},
)
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
unittest.main()
-28
View File
@@ -1,9 +1,7 @@
"""The concept fixture files carry the concept inspector facts (app.js:400-414)."""
import json
import math
import tempfile
import time
import unittest
from pathlib import Path
@@ -17,8 +15,6 @@ except ImportError: # discovered with tests/ as the top-level directory
from tests.browser.workspace_concept_fixture import (
CONCEPT_MODEL,
CONCEPT_PROMPTS,
MAX_EPOCH_SECONDS,
_instant,
write_concept_png,
)
from utils.prompt_inspector import inspect_prompt_evidence, read_inspection_file
@@ -84,29 +80,5 @@ class TestWorkspaceConceptFixture(unittest.TestCase):
self.assertEqual((image["width"], image["height"]), size)
class TestConceptFixtureInstant(unittest.TestCase):
"""One "now" for every relative time the fixture writes (PR #284's midnight)."""
def test_a_chosen_instant_is_kept(self):
self.assertEqual(_instant(1_792_713_598), 1_792_713_598.0)
self.assertEqual(_instant(1_792_713_598.25), 1_792_713_598.25)
def test_the_wall_clock_when_omitted(self):
before = time.time()
now = _instant(None)
self.assertLessEqual(before, now)
self.assertLessEqual(now, time.time())
def test_a_non_number_is_refused(self):
for value in (True, "1792713598", [1]):
with self.subTest(value=value), self.assertRaises(TypeError):
_instant(value)
def test_an_instant_out_of_range_is_refused(self):
for value in (0, -1, MAX_EPOCH_SECONDS, math.inf, math.nan):
with self.subTest(value=value), self.assertRaises(ValueError):
_instant(value)
if __name__ == "__main__":
unittest.main()
-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()
+1 -24
View File
@@ -6,7 +6,7 @@ import { once } from 'node:events';
import { readFileSync } from 'node:fs';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { setTimeout as delay } from 'node:timers/promises';
import { startBrowserServer, createServerSupervisor, attachServerStderr, forwardPrefixedLines, SLOW_RESET_PREFIX } from '../../tools/vendor/start-browser-server.mjs';
import { startBrowserServer, createServerSupervisor, attachServerStderr } from '../../tools/vendor/start-browser-server.mjs';
import { workflowWorkers } from '../../tools/vendor/test-workers.mjs';
const fakeServer = fileURLToPath(new URL('./fake-browser-server.mjs', import.meta.url));
@@ -193,29 +193,6 @@ test('a failed test gets the server stderr written during it attached; a passing
assert.equal(String(attached[0].body), 'Traceback (most recent call last): boom\n');
});
test('only complete slow-reset report lines are forwarded, however the chunks split them', () => {
const written = [];
const forward = forwardPrefixedLines(SLOW_RESET_PREFIX, text => written.push(text));
forward(`aiohttp access line\n${SLOW_RESET_PREFIX}POST /__test__/reset still`);
assert.deepEqual(written, [], 'a report line waits for its newline');
forward(` running after 2.000s\nplain stderr\n${SLOW_RESET_PREFIX}Thread 0x1 [asyncio_0]\n${SLOW_RESET_PREFIX.slice(0, 5)}`);
forward(`${SLOW_RESET_PREFIX.slice(5)} File "server.py", line 1\nnot a report ${SLOW_RESET_PREFIX}\n`);
assert.deepEqual(written, [
`${SLOW_RESET_PREFIX}POST /__test__/reset still running after 2.000s\n${SLOW_RESET_PREFIX}Thread 0x1 [asyncio_0]\n`,
`${SLOW_RESET_PREFIX} File "server.py", line 1\n`,
]);
});
test('a slow-reset report on server stderr reaches the worker stderr; other stderr does not', async t => {
const reported = [];
const server = await startBrowserServer({ ...fake(), report: text => reported.push(text) });
t.after(() => server.stop());
await writeStderr(server, 'Traceback: ordinary stderr\n');
await writeStderr(server, `${SLOW_RESET_PREFIX}POST /__test__/reset answered 200 after 16.064s\n`);
assert.deepEqual(reported, [`${SLOW_RESET_PREFIX}POST /__test__/reset answered 200 after 16.064s\n`]);
assert.ok(server.stderrTail().includes('Traceback: ordinary stderr'), 'the stderr tail still keeps everything');
});
test('a server exits with its worker even when the worker is SIGKILLed', async () => {
const pid = await serverOfKilledWorker(`{ command: process.execPath, args: port => [${JSON.stringify(fakeServer)}, '--port', String(port), '--exit-on-stdin-eof'] }`);
await waitFor(() => !alive(pid), `server ${pid} to exit after its worker died`);
-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 },
}];
+5 -77
View File
@@ -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,
@@ -181,7 +181,7 @@
},
"workspace/file-inspector.mjs": {
"lines": 96,
"branches": 93,
"branches": 92,
"functions": 92,
"statements": 96
},
@@ -296,7 +296,7 @@
"workspace/inspect.mjs": {
"lines": 98,
"branches": 92,
"functions": 93,
"functions": 92,
"statements": 98
},
"workspace/inspection-state.mjs": {
@@ -341,42 +341,12 @@
"functions": 98,
"statements": 98
},
"workspace/local-parameters.mjs": {
"lines": 98,
"branches": 94,
"functions": 98,
"statements": 98
},
"workspace/local-projection.mjs": {
"lines": 97,
"branches": 96,
"functions": 98,
"statements": 97
},
"workspace/local-resolver-stages.mjs": {
"lines": 94,
"branches": 93,
"functions": 98,
"statements": 94
},
"workspace/local-resolver-support.mjs": {
"lines": 96,
"branches": 80,
"functions": 98,
"statements": 96
},
"workspace/local-resolver.mjs": {
"lines": 96,
"branches": 86,
"functions": 98,
"statements": 96
},
"workspace/local-semantics.mjs": {
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"workspace/local-session.mjs": {
"lines": 98,
"branches": 92,
@@ -401,12 +371,6 @@
"functions": 98,
"statements": 98
},
"workspace/preferences.mjs": {
"lines": 98,
"branches": 96,
"functions": 98,
"statements": 98
},
"workspace/prompt-blocks.mjs": {
"lines": 90,
"branches": 92,
@@ -425,12 +389,6 @@
"functions": 98,
"statements": 98
},
"workspace/py-json.mjs": {
"lines": 97,
"branches": 92,
"functions": 98,
"statements": 97
},
"workspace/recorded-run.mjs": {
"lines": 95,
"branches": 83,
@@ -449,36 +407,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,
"functions": 89,
"statements": 98
},
"workspace/system-overview.mjs": {
"lines": 96,
"branches": 92,
"functions": 98,
"statements": 96
},
"workspace/system-state.mjs": {
"lines": 98,
"branches": 94,
"functions": 98,
"statements": 98
},
"workspace/system.mjs": {
"lines": 98,
"branches": 92,
"functions": 85,
"statements": 98
},
"workspace/theme.mjs": {
"lines": 98,
"branches": 93,
-19
View File
@@ -17,22 +17,6 @@ const HOST = '127.0.0.1';
// tests/browser/server.py prints this once it holds its listening socket. Only a
// complete line counts: a chunk may end mid-number.
const PORT_ANNOUNCEMENT = /^PM_TEST_SERVER_PORT=(\d+)\r?\n/m;
// tests/browser/slow_reset_watch.py starts every line of a slow-reset report with
// this. Those lines go to the worker's stderr, so the job log shows a slow reset
// even when its test passes.
export const SLOW_RESET_PREFIX = 'pm-slow-reset| ';
// A stderr chunk handler that passes every complete line starting with `prefix`
// to `write`. A line split across chunks is held until its newline arrives.
export function forwardPrefixedLines(prefix, write) {
let pending = '';
return chunk => {
const lines = (pending + chunk).split('\n');
pending = lines.pop().slice(-STDERR_TAIL_BYTES);
const matched = lines.filter(line => line.startsWith(prefix));
if (matched.length) write(matched.map(line => `${line}\n`).join(''));
};
}
// The server exits when its stdin closes, so a worker that dies without teardown
// (SIGKILL) leaves no server behind; the launcher holds the pipe's other end.
@@ -56,7 +40,6 @@ export async function startBrowserServer({
cwd = repositoryRoot,
timeoutMs = STARTUP_TIMEOUT_MS,
stderrLogChars = STDERR_LOG_CHARS,
report = chunk => process.stderr.write(chunk),
} = {}) {
if (!Number.isInteger(port) || port < 0 || port > 65535) {
throw new Error(`startBrowserServer needs a port from 0 to 65535, got ${port}`);
@@ -67,9 +50,7 @@ export async function startBrowserServer({
let stderr = '';
let stderrTotal = 0;
child.stderr.setEncoding('utf8');
const forwardReport = forwardPrefixedLines(SLOW_RESET_PREFIX, report);
child.stderr.on('data', chunk => {
forwardReport(chunk);
stderr = (stderr + chunk).slice(-Math.max(stderrLogChars, STDERR_TAIL_BYTES));
stderrTotal += chunk.length;
});
+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:
-25
View File
@@ -28,7 +28,6 @@ __all__ = [
"extract_generation_params",
"extract_from_records",
"parse_js_json",
"parse_page_json",
]
PLACEHOLDERS = MappingProxyType(
@@ -954,30 +953,6 @@ def parse_js_json(text):
return None
def _nan_as_null(name):
"""The legacy pages' NaN cleaning; Infinity still throws in ``JSON.parse``."""
if name == "NaN":
return None
raise ValueError(f"{name} is not JSON")
def parse_page_json(text):
"""A metadata record as the legacy gallery and admin pages parsed it.
They replace ``NaN`` with ``null`` and then ``JSON.parse`` (ComfyUI writes
``"is_changed": [NaN]`` for nodes that always re-run, PromptManager among
them). Only the NaN number reads as null here: the pages' regex also
rewrote the word inside strings, which changed prompt text. None where the
parse throws.
"""
if not isinstance(text, str):
return None
try:
return json.loads(text, parse_constant=_nan_as_null, parse_int=_double_integer)
except (ValueError, RecursionError):
return None
def extract_from_records(prompt_text, workflow_text):
"""``extract_generation_params`` for raw ``prompt``/``workflow`` record text.
+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
)
+1 -2
View File
@@ -20,7 +20,6 @@ from .prompt_pair_resolver import (
_FOOTER,
EMPTY_POSITIVE_REASON,
INSPECTION_PROFILE,
MALFORMED_EVIDENCE_REASON,
ResolverProfile,
resolve_prompt_pair,
)
@@ -549,7 +548,7 @@ def inspect_prompt_evidence(
resolution = {
"state": "unresolved",
"sampler_id": None,
"reason": MALFORMED_EVIDENCE_REASON,
"reason": "Prompt evidence is malformed",
"reason_code": "malformed",
"node": None,
}
+25 -294
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.
@@ -1478,13 +1237,10 @@ class _GraphResolver:
def non_file_outputs(self) -> set:
names = self.semantics.non_file_outputs
# Only a string names a class: a list or object is never looked up (#286).
return {
node_id
for node_id, node in self.graph.items()
if isinstance(node, dict)
and isinstance(node.get("class_type"), str)
and node["class_type"] in names
if isinstance(node, dict) and node.get("class_type") in names
}
def producers_of(self, starts: list, previews: set) -> set:
@@ -1622,13 +1378,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 +1429,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 +1438,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 +1466,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 +1479,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()
@@ -2094,8 +1832,6 @@ def _shared_pair(candidates: list) -> Optional[Dict[str, Any]]:
# Bounds for walking a UI workflow's subgraph instances (R1 only).
_MAX_SUBGRAPH_DEPTH = 8
# The inspector's own line for evidence it cannot read (``inspect_prompt_evidence``).
MALFORMED_EVIDENCE_REASON = "Prompt evidence is malformed"
_MAX_WORKFLOW_NODES = 100_000
@@ -2126,9 +1862,7 @@ def _prompt_manager_widgets(workflow: Any) -> Dict[str, str]:
Nodes inside a subgraph run as ``"<instance>:<inner>"`` API IDs (nested
instances chain their IDs); a definition without an instance runs nothing.
The walk is bounded in depth and in nodes visited. A node whose ``type`` is
a list or an object is skipped: it is neither a subgraph instance nor a
PromptManager node (#286).
The walk is bounded in depth and in nodes visited.
"""
nodes = workflow.get("nodes") if isinstance(workflow, dict) else None
if not isinstance(nodes, list):
@@ -2148,9 +1882,6 @@ def _prompt_manager_widgets(workflow: Any) -> Dict[str, str]:
if not isinstance(node_id, (str, int)) or isinstance(node_id, bool):
continue
api_id = f"{prefix}{node_id}"
if isinstance(kind, (list, dict)):
# JSON's unhashable values are never a node type (#286).
continue
if kind in subgraphs and depth < _MAX_SUBGRAPH_DEPTH:
stack.append((subgraphs[kind], api_id + ":", depth + 1))
continue
+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",
-10
View File
@@ -336,16 +336,6 @@ export function createActivityStore({ api, scope, connect = defaultConnect, noti
return { busy: store.running()[0] ?? null, message: error.message };
}
},
/**
* Follow a job another route started (its summary from that route's answer, e.g. the health check's
* `POST /system/health`): it is listed and its stream opened at once, with no read and no start toast.
*/
track(raw) {
const job = parseJob(raw);
if (!job) throw new TypeError('Invalid job');
upsert(job);
return job;
},
/** Ask a running job to stop at its next file boundary; false once it has ended. */
async cancel(id) {
const result = await api.post(`${JOBS_URL}/${encodeURIComponent(id)}/cancel`, {}, { signal: scope.signal });
+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();
+6 -63
View File
@@ -5,8 +5,6 @@ import { createSendButton } from './prompt-sender.mjs';
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.';
@@ -28,26 +26,6 @@ const REVEAL_BUSY = 'The folder is already opening.';
const REVEAL_FAILED = 'The folder could not be opened. Try again.';
const FACTS = [['seed', 'Seed'], ['steps', 'Steps'], ['cfg', 'CFG'], ['sampler', 'Sampler'], ['scheduler', 'Scheduler'], ['model', 'Model']];
// D117 (LI-4): the legacy metadata page's COPY ALL (web/metadata.html copyAllData), its lines and placeholders.
const SUMMARY_LINES = [['Checkpoint', 'model'], ['Positive Prompt', 'positive'], ['Negative Prompt', 'negative'], ['Steps', 'steps'],
['CFG Scale', 'cfg'], ['Sampler', 'sampler'], ['Seed', 'seed']];
const SUMMARY_MISSING = {positive:'No prompt found', negative:'No negative prompt found'};
/**
* The one-click settings summary (D117): the legacy COPY ALL text for what this inspector shows. A confirmed prompt
* and a known fact are copied verbatim; anything else, an explicitly empty prompt included, takes the legacy page's
* placeholder, as its parse does (`hasValue` treats '' as absent).
*/
export function settingsSummary(record) {
const roles = promptRoles(record?.prompts);
const value = key => {
if (Object.hasOwn(SUMMARY_MISSING, key)) return roles[key] ? roles[key] : SUMMARY_MISSING[key];
const fact = record?.generation?.[key];
return fact?.status === 'known' && typeof fact.value === 'string' && fact.value !== '' ? fact.value : 'Unknown';
};
return SUMMARY_LINES.map(([label, key]) => `${label}: ${value(key)}`).join('\n');
}
function safeFilename(value) {
return String(value || 'file').replace(/[\\/\x00-\x1f\x7f<>:"|?*]/g, '_').replace(/^\.+|[. ]+$/g, '') || 'file';
}
@@ -156,10 +134,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 +175,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 = () => {}}) {
if (parentScope.signal.aborted) return {setState() {}, dispose() {}};
const scope = childScope(parentScope);
let contentScope;
@@ -212,7 +184,7 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
scope.listen(container, 'keydown', onInteraction);
scope.listen(container, 'click', onInteraction);
scope.listen(container, 'wheel', onInteraction, {passive:true});
scope.cleanup(() => {contentScope?.dispose(); container.replaceChildren(); container.classList.remove('file-inspector'); container.removeAttribute('aria-busy');});
scope.cleanup(() => {contentScope?.dispose(); container.replaceChildren(); container.classList.remove('file-inspector');});
const full = (variant ?? (inline ? 'panel' : 'full')) === 'full';
function statusView(state, parent) {
@@ -231,8 +203,6 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
if (scope.signal.aborted) return;
contentScope?.dispose(); contentScope = childScope(scope);
container.replaceChildren();
// A local read resolves on this thread in bounded steps; the busy state stays until its answer lands.
if (state.loading) container.setAttribute('aria-busy', 'true'); else container.removeAttribute('aria-busy');
render(state);
}
/**
@@ -242,13 +212,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;
@@ -281,7 +244,7 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
inner.append(tags);
}
inner.append(...generationFacts(record, 'meta-grid', 'small'));
if (full) inner.append(summaryButton(record), provenance(record, badge), rawMetadata(record));
if (full) inner.append(provenance(record, badge), rawMetadata(record));
const footer = full ? container : el('div', undefined, 'detail-footer'), roles = promptRoles(record.prompts);
if (Object.hasOwn(roles, 'positive') && Object.hasOwn(roles, 'negative')) {
const pair = createButton('', 'send');
@@ -301,23 +264,6 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
}
if (!full && footer.childNodes.length) container.append(footer);
}
/**
* D117 (LI-4): "Copy settings summary" after the six facts, the legacy COPY ALL. It copies as the concept's prompt
* copy does: the shared toast, or the select-to-copy dialog where the clipboard is unavailable or refused.
*/
function summaryButton(record) {
const button = createButton('Copy settings summary', 'settings-summary'), text = settingsSummary(record);
contentScope.listen(button, 'click', async () => {
try {
if (!navigator.clipboard) throw new Error('Clipboard requires a secure context');
await navigator.clipboard.writeText(text);
if (!contentScope.signal.aborted) showWorkspaceToast('Settings summary copied.');
} catch {
if (!contentScope.signal.aborted) copyFallback(text, {scope:contentScope, trigger:button, title:'Copy settings summary'});
}
});
return button;
}
/** The concept's plain cover; the card image opens the viewer (D19). Video keeps its own element. */
function cover(record) {
const {file} = record, video = file?.media_type === 'video';
@@ -340,10 +286,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 +295,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'));
+12 -33
View File
@@ -1,6 +1,6 @@
import '../api-client.js';
import { createButton, createScope } from '../shared/toolkit/core.js';
import { routes, mountRouter, summarizeStats, routeTitle, loadNavCounts,
import { routes, mountRouter, summarizeStats, routeTitle, isHandoff, loadNavCounts,
LIBRARY_WRITE_COUNTS, NAV_COUNT_ROUTES, FAVORITE_WRITE_COUNTS, FAVORITE_COUNT_DELAY_MS } from './state.mjs';
import { mountWorkspaceTheme } from './theme.mjs';
import { mountImages, onFavoriteChange } from './images.mjs';
@@ -16,7 +16,6 @@ import { mountActivityPage, mountActivityTopbar } from './activity.mjs';
import { mountActivityJobView } from './activity-job-view.mjs';
import { mountInspect } from './inspect.mjs';
import { createPromptSender } from './prompt-sender.mjs';
import { mountSystem } from './system.mjs';
const { createApiClient, resolveUrl } = window.ApiClient;
@@ -29,8 +28,7 @@ function element(tag, text, className) {
function legacyLink(route) {
const link = element('a', route.action, 'workspace-link');
// A native card (System, Q12) links its workspace hash; a legacy tool's page goes through the mount prefix.
link.href = route.native ? route.href : resolveUrl(route.href);
link.href = resolveUrl(route.href);
return link;
}
@@ -134,33 +132,11 @@ function mountWorkspace() {
refreshCounts(NAV_COUNT_ROUTES);
let viewScope;
let firstRender = true;
// D62: a page whose teardown must wait (Inspect, while a video is still starting) holds the next route until it
// settles; the latest route wins.
let leaveGuard = null, renderToken = 0;
scope.cleanup(() => viewScope?.dispose());
// The mounted System page: another of its sections is shown in place, keeping the shell and the drafts.
let system = null;
function render(route) {
const token = ++renderToken;
const hold = leaveGuard?.();
if (hold) {
hold.then(() => { if (token === renderToken && !scope.signal.aborted) renderNow(route); });
return;
}
renderNow(route);
}
function renderNow(route) {
leaveGuard = null;
// A shared page dialog never stays open over the next route.
closeWorkspaceDialog();
if (route.id === 'system' && system && currentKey === 'system') {
system.show(route.section);
return;
}
system = null;
viewScope?.dispose();
images = null;
viewScope = createScope();
@@ -261,16 +237,19 @@ function mountWorkspace() {
else mountActivityPage(main, { store: activity, scope: view });
return;
}
if (route.id === 'system') {
// The concept's System (app.js renderSystem): its heading description, section nav and Save row.
description.textContent = route.description;
system = mountSystem(main, api, view, { section: route.section, activity });
return;
}
if (route.id === 'inspect') {
// The concept's Inspect a file (app.js renderMetadata): a local file, read only in this browser.
description.textContent = route.description;
({ leaveGuard } = mountInspect(main, { parentScope: view, api, sender: createPromptSender({ scope: view }) }));
mountInspect(main, { parentScope: view, api, sender: createPromptSender({ scope: view }) });
return;
}
if (isHandoff(route)) {
description.textContent = route.description;
const panel = element('section', undefined, 'surface handoff');
panel.append(element('h2', 'Your current tools are ready'),
element('p', 'This workspace is being introduced in stages. Continue in the existing tool with your saved data.'),
legacyLink(route));
main.append(panel);
return;
}
description.textContent = 'A home for your prompts, images and creative history.';
+26 -71
View File
@@ -7,7 +7,6 @@ import { pageHeading } from './heading.mjs';
import { createWorkspaceIcon } from './icons.mjs';
import { createLocalSession, LOCAL_SESSION_MESSAGES } from './local-session.mjs';
import { localFileAdapter } from './local-inspection.mjs';
import { loadInspectionSemantics } from './local-semantics.mjs';
import { createMediaViewer } from './media-viewer.mjs';
import { showWorkspaceToast, TOAST_MS } from './toast.mjs';
@@ -40,34 +39,44 @@ function element(tag, className, text) {
// The status shown for the selection; AVI/WMV's note and an empty status keep the toast's "metadata is available".
const TOASTABLE = new Set(['', LOCAL_SESSION_MESSAGES.containerOnly]);
// How long a replaced video still starting may stay in place before it is released anyway.
const STARTING_VIDEO_MS = 15000;
// How long a released video still starting may take to settle before its deferred reset is dropped.
const DEFERRED_RESET_MS = 15000;
/** A video whose pipeline is still starting: it has a source but neither metadata nor an error yet. */
function isStarting(node) {
return node instanceof HTMLMediaElement && node.hasAttribute('src') && node.readyState < HTMLMediaElement.HAVE_METADATA && node.error === null;
/**
* Resets a released video once it settles. Removing `src` alone never aborts a load already under way, and WebKit
* (GStreamer) blocked the page when a pipeline still starting was reset, so load() waits for metadata or an error.
* The fallback only drops the waiting listeners (a WebKit load cut off by the revoked URL stalls and never settles),
* so the detached node is never pinned.
*/
function resetWhenSettled(node) {
const waiting = new AbortController();
const fallback = setTimeout(() => waiting.abort(), DEFERRED_RESET_MS);
const reset = () => { waiting.abort(); clearTimeout(fallback); node.load(); };
for (const type of ['loadedmetadata', 'error']) node.addEventListener(type, reset, { once: true, signal: waiting.signal });
}
/**
* Stops a preview from holding its blob: URL: its own listeners end, media is paused, its src dropped and it is reset
* with load() (which frees its decoder), and the node leaves the page, all before its URL is revoked.
* Stops a preview from holding its blob: URL: its own listeners end, media is paused and its src dropped before the
* session revokes the URL, and the node leaves the page. A settled video (metadata read, or failed) is then reset with
* load() to free its decoder at once; one still starting is reset as soon as it settles.
*/
function releaseMedia(preview) {
if (!preview) return;
const { node, listeners } = preview;
listeners.abort();
if (node instanceof HTMLMediaElement) {
node.pause(); node.removeAttribute('src'); node.load();
const settled = node.readyState >= HTMLMediaElement.HAVE_METADATA || node.error !== null;
node.pause(); node.removeAttribute('src');
if (settled) node.load();
else resetWhenSettled(node);
} else node.removeAttribute('src');
node.remove();
}
/**
* Mounts the page into `main` below the heading the shell rendered. `sender` is the shared prompt sender; `api` is
* only handed to the viewer (the local adapter never calls it). Returns `{dispose, leaveGuard}` (`{dispose}` when
* `parentScope` has already ended); the page also ends with `parentScope`. `leaveGuard()` is a promise while a video
* on the page is still starting (D62): the shell renders the next route once it settles, since detaching it then
* deadlocked WebKit.
* only handed to the viewer (the local adapter never calls it). Returns `{dispose}`; the page also ends with
* `parentScope`.
*/
export function mountInspect(main, { parentScope, api, sender }) {
const scope = createScope();
@@ -75,8 +84,6 @@ export function mountInspect(main, { parentScope, api, sender }) {
if (parentScope.signal.aborted) { scope.dispose(); return { dispose }; }
parentScope.signal.addEventListener('abort', dispose, { once: true });
scope.cleanup(() => parentScope.signal.removeEventListener('abort', dispose));
// The node table the local roles resolve with, read before any file is chosen (one request per page).
loadInspectionSemantics();
// The concept DOM: section.surface > h2, p; div.file-drop.spaced-20 > icon, label, input, span.small; #local-preview.
const surface = element('section', 'surface');
@@ -101,35 +108,7 @@ export function mountInspect(main, { parentScope, api, sender }) {
// The session ends after the viewer has closed and the inline media is gone, so no element holds its URL then.
// The page owns page leave for the same reason: the session listens for no `pagehide` of its own.
const sessionScope = createScope();
// Replaced videos still starting, by URL. WebKit (GStreamer) deadlocked the page when a video left the page or
// the viewport before its pipeline had started, so such a video stays where it is, inert, until it reports
// metadata or an error; only then is it released and its URL, which the session asked to revoke, revoked.
const retiring = new Map();
function finishRetiring(url) {
const entry = retiring.get(url);
if (!entry) return;
retiring.delete(url);
entry.waiting.abort(); clearTimeout(entry.timer);
releaseMedia(entry.preview);
if (entry.revoke) URL.revokeObjectURL(url);
entry.done();
}
function retire(preview, url) {
const waiting = new AbortController();
const entry = { preview, waiting, revoke: false, timer: setTimeout(() => finishRetiring(url), STARTING_VIDEO_MS) };
entry.finished = new Promise(resolve => { entry.done = resolve; });
retiring.set(url, entry);
preview.listeners.abort();
preview.node.inert = true; preview.node.setAttribute('aria-hidden', 'true');
for (const type of ['loadedmetadata', 'error']) preview.node.addEventListener(type, () => finishRetiring(url), { once: true, signal: waiting.signal });
}
const finishAllRetiring = () => { for (const url of [...retiring.keys()]) finishRetiring(url); };
const revokeObjectURL = url => {
const entry = retiring.get(url);
if (entry) entry.revoke = true;
else URL.revokeObjectURL(url);
};
const session = createLocalSession({ parentScope: sessionScope, pageTarget: null, revokeObjectURL });
const session = createLocalSession({ parentScope: sessionScope, pageTarget: null });
const viewer = createMediaViewer({ api, container: main, parentScope: scope, inspectionController: session.controller, sender,
adapter: localFileAdapter, onRestoreFocus: () => { if (open.isConnected) open.focus({ preventScroll: true }); } });
@@ -174,9 +153,7 @@ export function mountInspect(main, { parentScope, api, sender }) {
function showMedia(state) {
const url = state.preview?.url ?? null;
if (url === mediaUrl) return;
if (media && isStarting(media.node) && !retiring.has(mediaUrl)) retire(media, mediaUrl);
else releaseMedia(media);
media = null; mediaUrl = url;
releaseMedia(media); media = null; mediaUrl = url;
if (!url) return;
const video = state.preview.media === 'video';
const node = element(video ? 'video' : 'img', 'local-file-preview');
@@ -196,10 +173,7 @@ export function mountInspect(main, { parentScope, api, sender }) {
}, { once: true, signal: current.listeners.signal });
node.src = url;
media = current;
// A video still starting keeps its place, so the new preview goes after it and never moves it.
const anchor = [...retiring.values()].map(entry => entry.preview.node).filter(item => item.parentNode === preview).at(-1);
if (anchor) anchor.after(node);
else preview.prepend(node);
preview.prepend(node);
}
scope.cleanup(session.subscribe(state => {
showMedia(state);
@@ -216,7 +190,6 @@ export function mountInspect(main, { parentScope, api, sender }) {
// viewer (on abort), drops the media, then ends the session.
scope.cleanup(() => viewer.dispose());
scope.cleanup(() => { releaseMedia(media); media = null; mediaUrl = null; });
scope.cleanup(finishAllRetiring);
scope.cleanup(() => sessionScope.dispose());
// Page leave: the viewer closes and the inline media goes before the session revokes the URL, and the chooser is
@@ -225,7 +198,6 @@ export function mountInspect(main, { parentScope, api, sender }) {
clearRejection();
viewer.close();
releaseMedia(media); media = null; mediaUrl = null;
finishAllRetiring();
session.clear();
chosen = null; input.value = '';
}
@@ -266,22 +238,5 @@ export function mountInspect(main, { parentScope, api, sender }) {
if (open.getAttribute('aria-disabled') === 'true' || !entry || session.state.phase !== 'ready') return;
viewer.open({ items: [entry], selectedKey: entry.key, trigger: open });
});
/** Settles when no video on the page is still starting; `null` when none is. */
function leaveGuard() {
if (scope.signal.aborted) return null;
const waits = [];
for (const entry of retiring.values()) waits.push(entry.finished);
if (media && isStarting(media.node)) {
const { node } = media;
waits.push(new Promise(resolve => {
const settled = new AbortController();
const done = () => { settled.abort(); clearTimeout(timer); resolve(); };
const timer = setTimeout(done, STARTING_VIDEO_MS);
for (const type of ['loadedmetadata', 'error']) node.addEventListener(type, done, { once: true, signal: settled.signal });
scope.signal.addEventListener('abort', done, { once: true, signal: settled.signal });
}));
}
return waits.length ? Promise.all(waits) : null;
}
return { dispose, leaveGuard };
return { dispose };
}

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