Files
ComfyAssets-ComfyUI_PromptM…/TESTING.md
T
Vito 86d82186ee feat(images): concept Images page — toolbar, cards, star, viewer, compared against the mock (#258)
* feat(workspace): shared concept toolbar and dialogs

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

Refs #249

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

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

Refs #249

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Refs #249

* feat(images): concept hover zoom

* feat(images): favorite images from the card

* feat(images): concept viewer shell

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

A longer paste was sent as is, the API rejected it and the collection
showed "Images could not be loaded". The legacy search capped it.
2026-10-20 11:31:53 -07:00

72 KiB

Testing and merge confidence

Routine changes should merge after the required checks pass and the diff is reviewed. Add a regression test for each bug; prove it fails before the fix.

What runs

Layer Real components Replaced boundaries
Python tests Domain logic, SQLite, API handlers, filesystem checks ComfyUI host and ML runtimes where needed
JavaScript tests Frontend modules and controller methods DOM/network fixtures for focused edge cases
Viewer checks Production Viewer.js and frontend methods in Chromium desktop/touch Controlled images and page fixtures
Real ComfyUI Pinned CPU host, actual extension, node definitions, widgets, routes, SQLite and real execution lifecycle Stock UI cases isolate host writes; the execution suite queues deterministic test-only nodes without models
Page workflows Production HTML, startup, click handlers, HTTP routes, SQLite, PNG files, thumbnails ComfyUI host services and the tagging model

Page workflows cover save/edit/reload, duplicate-edit rejection, gallery image identity, Tags selection and viewer cleanup, delayed responses, auto-tag review and persistence, and infinite scrolling without replacing existing cards. Data safety workflows download and upload actual SQLite backups, verify restored prompts/tags/image links, reject corrupt uploads without changing live data, and exercise cancellation and network-failure recovery for restores and deletion. Duplicate cleanup verifies only the selected file disappears; prompt deletion verifies the other prompt and original image files survive. They run in Chromium, Firefox, WebKit, and Chromium touch emulation. No GPU, model downloads, running ComfyUI instance, or personal configuration is required. The server binds to localhost and creates disposable directories and a database; its reset endpoint exists only in the test harness.

Run locally

Use a Python environment containing requirements-dev.txt and Node.js 24:

python -m unittest discover -s tests -q
npm ci --prefix tools/vendor --ignore-scripts
npx --prefix tools/vendor playwright install --with-deps chromium firefox webkit
npm run --prefix tools/vendor test
npm run --prefix tools/vendor test:coverage
npm run --prefix tools/vendor test:workflows -- --project firefox --project webkit

PM_TEST_PYTHON selects the Python executable; PM_TEST_PORT selects the local port (default 8765). Set these environment variables when Python is outside PATH or the port is occupied. Test servers are never reused. On unsupported Linux distributions, browser system dependencies may need manual installation. For a quick browser run, use test:workflows -- --project chromium.

CI runs the browser jobs inside the pinned image mcr.microsoft.com/playwright:v<@playwright/test version>-noble (tests/vendor/browser-image.test.mjs keeps the tag in step with the lockfile). Concept comparisons measure geometry, and geometry follows the fonts a browser falls back to: the concept names Inter, which neither the image nor a stock runner ships. On a plain ubuntu-latest runner the fallback is DejaVu Sans, whose wider metrics make the concept's own Tags table overflow a 390px viewport, so the 390px comparisons are only meaningful in the pinned image. To reproduce CI, run the suites in that image.

Managed real ComfyUI

The shared engine comes from an exact commit of ComfyAssets Toolkit, recorded in tools/vendor/package.json and its integrity-checked lockfile. The local adapter sets PromptManager's database/configuration and monitoring policy; source discovery, coverage mapping/gates and process lifecycle are shared code. KikoTools remains the reference and is unchanged by this integration.

Use a dedicated disposable ComfyUI checkout at the revision in tools/vendor/comfyui-runtime.json. With a separate Python environment, install that checkout's requirements, this repo's requirements-dev.txt, and the exact CPU torch/torchvision pins from the JSON (using PyTorch's CPU wheel index). Install Chromium using the Playwright command above, then run:

PM_COMFY_ROOT=/path/to/disposable/ComfyUI \
PM_TEST_PYTHON=/path/to/cpu-env/bin/python \
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. 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 CPU/offline with only PromptManager enabled and removes owned state on shutdown. The upstream checkout must also be disposable: ComfyUI may initialize local runtime files there. Browser host-settings writes are intercepted; actual PromptManager routes use the disposable host and database.

real-comfyui-reports retains startup logs, version attachments, screenshots and traces for 14 days. Update upstream/frontend/CPU pins together after reviewing and running both startup projects; never replace the SHA with a moving branch. Update the toolkit via an exact reviewed commit archive and commit both npm manifest and lockfile. No toolkit or npm installation is needed by end users.

Coverage and failures

Python CI enforces 85% combined statement/branch coverage. Frontend coverage merges Node V8 unit coverage with Chromium page-workflow coverage through c8. Every web/js/**/*.js and web/js/**/*.mjs file appears, including untouched files at zero coverage. The controller tests provide V8 filenames so their executed code is counted too.

tools/vendor/frontend-coverage-baseline.json holds a floor for every file and metric. CI rejects regressions, missing measurements, and new files without a reviewed baseline. These floors reflect current coverage, with two percentage points of allowance for runtime/timing differences; they do not claim 80% coverage of the large controllers. New files need at least 80% line coverage when added via the baseline updater. Raise floors with npm run --prefix tools/vendor coverage:update; it reruns the suites and cannot lower an existing floor. Review baseline changes like code changes.

The viewerjs check runs on every PR, including backend-only changes. It is an aggregate job that passes only when the frontend coverage (chromium and touch) job and both page workflows (firefox) and page workflows (webkit) jobs succeed; the browsers run on separate runners in parallel. GitHub artifacts named frontend-test-reports contain HTML coverage, a JSON summary, and the Chromium/touch failure screenshots/traces; browser-traces-firefox and browser-traces-webkit hold the other browsers' traces. Open traces with Playwright's show-trace tool.

The real-host suite covers both Classic and Nodes 2.0 startup, switching both ways, actual database prompt selection, execution-text serialization, workflow reload, widget/style cleanup, node schemas and standalone-page opening. It does not certify every existing feature, arbitrary host versions, GPU/model changes, cross-pack installation or visual design changes. Those still need targeted validation.

Performance budgets

A spec performance budget gets two tests:

  • A structural test that always runs, on every OS/Python job and under the coverage tracer. It asserts counts, never time: SELECTs per request, the EXPLAIN QUERY PLAN of the hot query, cache builds on cold and warm requests, and calls of the sort key per cursor page. Example: test_filter_work_is_bounded in tests/test_api_workspace_images.py.
  • A wall-clock test that runs only with PROMPTMANAGER_PERF_BUDGET=1 and without a tracer (sys.gettrace() or a sys.monitoring tool). It skips otherwise. It warms up once, then measures three rounds and asserts the best round's p95; the failure message lists every timing. Example: test_filter_budget (Images filters, spec D12: p95 < 150 ms at 50,000 files and 20,000 links).

The perf budget job in .github/workflows/test.yml is the reference measurement: ubuntu-latest, one Python, no coverage, only the wall-clock tests. It runs on every push to main and on workflow_dispatch, never on pull requests, because shared-runner timing must not block a merge. Dispatch it on a branch to measure before merging. Shared Windows and macOS runners do not run them; the structural tests cover portability. Run it locally with PROMPTMANAGER_PERF_BUDGET=1 python -m unittest tests.test_api_workspace_images.TestWorkspaceImages.test_filter_budget and quote the printed p95 in the PR body.

A red perf budget run on main means the code got slower: fix the code (or change the budget in the spec with a reason) in a follow-up PR. Never rerun it to green. It is not a required check, and it cannot be one while it skips pull requests (.github/branch-protection.json).

Windows portability preflight

Before changing paths, SQLite, streams, restore or cleanup:

  • Follow the boundary's existing native canonical/identity contract; Path.resolve() alone does not fold Windows case. Keep lexical aliases distinct from resolved targets. Cover mixed case, drive and UNC spellings, plus foreign-namespace denial with an allowed native control. See Python's normcase documentation.
  • Close every owned database/model connection, worker and stream handle before unlink or TemporaryDirectory.cleanup(). Await worker/stream retirement. A SQLite with conn manages transactions, not connection closure, and addCleanup runs after teardown; use explicit finally cleanup when teardown removes files first.
  • Windows can reject unlink of an open file; Unix can remove its directory entry while the handle remains usable. Preserve the shared behavior assertions and exercise the actual native branch, then prove handle release and successful cleanup. Do not hide failures with blanket skips or retries. See Python's remove/unlink semantics.
  • Concurrent SQLite tests must not depend on how fast the host commits. Managed connections use WAL with synchronous=NORMAL (see configure_connection) so commits do not hold the write lock across a flush. Make real overlap deterministic (for example hold the first writer open with FirstWriterHold) and give peers that must all succeed the ordinary busy timeout; cross-thread handoffs wait on events with generous ceilings, never a short wall clock (#242).
  • Run native Windows CI early for these changes and include a Windows impact and evidence note in the PR. All twelve OS/Python combinations and every applicable exact-head check must pass before merge. Portable ntpath/PureWindowsPath controls do not establish native filesystem, locking or cleanup success.

Required merge checks

.github/branch-protection.json records the required checks: all twelve Python/OS combinations, Python coverage, formatting, frontend modules, viewerjs, real ComfyUI compatibility, and bandit (high severity). The branch must be current with main, and administrators follow the same checks. It does not require an additional human approval.

A repository administrator applies the configuration after the new workflow is passing, then verifies it:

gh api --method PUT repos/ComfyAssets/ComfyUI_PromptManager/branches/main/protection \
  --input .github/branch-protection.json
gh api repos/ComfyAssets/ComfyUI_PromptManager/branches/main/protection/required_status_checks

Update the manifest whenever required job names change. Keep the frontend check free of path filters so a required check is never silently skipped.

Unified workspace

The opt-in /prompt_manager/workspace page opens the native Images workspace. Overview retains real /prompt_manager/stats counts. Both use the shipped toolkit. Existing admin, gallery and metadata URLs remain available while replacement workspaces are developed. Browser smoke tests in tests/browser/workspace.spec.mjs cover navigation/history, keyboard skip links, mobile layout, theme persistence/fallback, loading/retry and stale responses. The Images cases in tests/browser/workspace-images.spec.mjs additionally cover server-side filters, cursor append/retry, Grid/List/Masonry, bounded DOM, keyboard navigation, restored browsing context, Viewer.js cleanup and actual video playback. tests/test_api_workspace_images.py checks signed cursors, source identities, legacy compatibility and header-only dimensions. Frontend stream/layout units cover cancellation, malformed responses and bounded visibility lookup. These run automatically in the existing four-browser-project workflow and frontend coverage job. tests/comfyui/workspace.spec.mjs verifies the page against the managed host with both renderer startup configurations.

The shared inline and collection inspector reads the current rooted file's supported embedded records, exact generation facts and recorded or inferred prompt evidence. tests/browser/full-inspector.spec.mjs covers original/metadata/workflow downloads, response limits, source changes, nested sending, matched dark/light and mobile geometry, and the loaded-collection viewer. viewer matches the concept shell and toolbar order measures the concept's #viewer-dialog shell, header copy, toolbar order and frame geometry in the concept and in production at 1600 and 390 pixels; its #249 D34 mapping keeps production's Download link, the appended position and reason lines, coarse 40px targets and the toolbar inside .viewer-left (Viewer.js's own .viewer-toolbar rule lifts the concept's across the dialog). The viewer never fetches another page or preloads all originals. Its explicit slideshow waits 3.5 seconds after image readiness, pauses at native video without advancing on ended, and resumes on a manually selected image only while prior intent remains. Pause/Stop, inspector reading/copy/download/send, hidden document, media failure, close, query/route disposal and pagehide cancel that intent; returning visibility or closing the sender does not resume it. Stop leaves native video playback alone. Video uses only the existing normalized gallerySettings autoplay/mute/loop values, read without writes; denied/malformed storage falls back to autoplay off, mute on and loop on. Rejected autoplay keeps native controls usable.

The inspector does not establish historical file identity, infer run membership, edit image tags, export all EXIF, or load/execute a workflow. Saved vocabulary is labelled by its recipe or positive-prompt scope. Limited raw evidence is omitted atomically, with JSON exports unavailable and the rooted original still downloadable. The inspection API uses one bounded read with per-record budgets: role records (prompt, parameters, the pair execution record) share 2 MiB and exceeding it fails the read; the workflow record has its own 4 MiB and past it only the workflow becomes record format limited (no raw text, graph limited, roles and facts unaffected); other text chunks are skipped unread; byte-identical duplicates are accepted within a bounded read budget, while differing duplicates (and any duplicate role record over that budget) still fail. Other read bounds are unchanged: 8 MiB non-PNG header, 10,000 PNG chunks and a three-second ffprobe. Resolution under INSPECTION_PROFILE bounds work, not graph size: 2,000 visits per candidate chain, 20,000 in total, 64 link/JSON-container levels, 100,000 resolved characters, and a two 20,000-node ceilings: one over the top-level entries of the API prompt, and one over the workflow graph's UI nodes plus subgraph-definition nodes (past it the workflow graph and its facts become limited). The serialized response stays under 8 MiB by omitting, in order, the workflow graph, then the prompt graph, then all raw records atomically. Retry does not raise these limits. Ingestion (scan, live monitor, admin import) and queue-time run counting keep the default profile, including its 500-node gate. Only INSPECTION_PROFILE reads the node semantics table web/js/inspection-semantics.json (at most 1 MiB and eight nesting levels, loaded on first use). With it, SamplerCustomAdvanced follows its guider link (through Reroutes) to CFGGuider (positive and negative) or BasicGuider (positive only; the negative is absent, "Guider has no negative input"). Any other guider, such as DualCFGGuider, is unsupported-node. Conditioning is traced through the table's pass-throughs (FluxGuidance, ReferenceLatent, the ConditioningSet* nodes, ControlNet apply nodes, InpaintModelConditioning and WanImageToVideo by output slot). ConditioningZeroOut is an explicit empty text with source zeroed-conditioning. easy ifElse with a literal boolean follows that branch only; with a linked boolean both branches are traced, equal texts resolve and different texts make the sampler ambiguous with one candidate per choice (at most 32, else work-limit). ConditioningCombine/Concat/Average resolve only when every part has the same text, otherwise unresolved with composite naming the node. Results that depend on a forced choice are never memoized. A FaceDetailer (detailer-stage) is a sampler stage: the samplers reached through its image ancestry are compared with it. Equal texts resolve with the detailer as the sampler; different texts are ambiguous with multistage-differs and one candidate per stage; an unreadable stage makes the chain unresolved. Unanchored, a sampler whose output only feeds detailers (or previews) is not a candidate of its own. A sampler's latent ancestry is walked through every link input of non-sampler nodes within the same visit and depth budgets: with no earlier sampler it is a single stage whatever the origin (VAEEncode, EmptyLatentBatch, EmptyFlux2LatentImage, EmptyHunyuanLatentVideo, SetLatentNoiseMask, InpaintModelConditioning or WanImageToVideo slot 2). An earlier sampler (img2img from a decode, hires fix) stays unresolved "Multistage sampler ancestry is ambiguous" until stage texts are compared; an unlisted node with sampler-shaped inputs (positive, negative, guider, latent_image, a pipe, video embeds, custom-sampler parts, or a model link together with conditioning, neg_conditioning, samples or latent) is unsupported-node naming it, here and in a detailer's image ancestry; a latent that is not a link or reaches its own sampler is malformed; running out of budget is work-limit or depth-limit, never single stage. Each sampler's walk runs once per resolution; selector branch runs share it and other candidates are charged its visit cost. Text encoders: single encoders (the Qwen image encoders, TextEncodeQwenImage21 slot 0 prompt and slot 1 negative_prompt, KikoTextEncode) read one string input unchanged. parts-equal encoders (CLIPTextEncodeSDXL, Flux, SD3) resolve when every part is identical or exactly one part is non-empty (exact "", whitespace is text); otherwise composite. String sources reproduce the pinned KJNodes code from literal settings only: StringConstant is its string; StringConstantMultiline with a literal true strip_newlines is replace("\n", "").strip(); JoinStringMulti keeps string_1 (even empty), appends string_2..string_N (N = inputcount) after the literal delimiter unless missing or exactly "", and ignores later inputs. A linked setting is unsupported-node; a list output (return_list true or linked) and PromptSearchList are runtime-value ("Text is decided at run time"), never one list item. Unverified text nodes (LoRAfromText, RegexReplace, XYZPrompt, ImpactWildcardProcessor) stay unsupported-node. Every intermediate text is length-checked and charged to the scanned-character budget. If the table cannot be loaded, graph inspection is unresolved ("Inspection node semantics are unavailable") and ingestion is unaffected, because it never imports the table.

tools/inspection_corpus.py --root DIR --out JSONL [--workers N] [--baseline JSONL] [--summary TXT] [--code TREE] measures a media folder read-only against the #240 invariants H1 to H8. It runs the inspection route pipeline and the workspace JS facts of --code (default: its own tree), opens media only for reading, follows no symlinks, imports no database, and refuses --out/--summary inside the root or a code tree. Its rows hold statuses, sizes, timings and SHA-256 digests of resolved role texts, never file names or prompt text unless --with-paths is given; digests of short prompts can still be guessed, so the JSONL stays local and only the aggregate summary goes into a PR body. Run it on the base commit first and pass that JSONL as --baseline for H1 and H8 (2x the baseline p99, never below 50 ms). It exits 1 when an invariant fails, including the harness line: a tool exception or facts that were not computed (no Node.js) fail the run. Unreadable or unsupported files are inspector outcomes, not harness errors. Never point it at a live install's folders from an automated job. Installing the Python inspection/source-scope changes requires a maintainer ComfyUI restart. Automated tests use disposable hosts and never restart the running personal ComfyUI.

Run the focused page checks with:

npm run --prefix tools/vendor test:workflows -- workspace.spec.mjs workspace-images.spec.mjs
node --test tests/js/workspace-*.test.js

Use the disposable fixture and managed-host prerequisites above. A maintainer live check needs a ComfyUI restart after adding the Python route, then opening /prompt_manager/workspace; the mockup preview server is a separate design tool.

Concept shell and comparison method (#249)

CSS base and drift check. web/js/workspace/concept.css is a byte-identical copy of the approved concept's docs/concepts/workspace/style.css. Never edit the copy; it is only rewritten from the concept with npm run --prefix tools/vendor sync:concept. npm run --prefix tools/vendor check runs sync-concept-css.mjs --check, which fails on a missing copy or names the first differing byte. workspace.html loads, in order, the toolkit controls, Viewer.js, concept.css, the page stylesheets that their own PRs retire, and the production overlay workspace.css last. tests/vendor/concept-css.test.mjs asserts that order, the drift check, and that no other page or canvas script loads workspace CSS.

Overlay decision-marker lint. lintOverlay in tools/vendor/sync-concept-css.mjs fails any workspace.css rule whose selector is also a concept selector unless the rule carries a /* D<n> */ comment, before its selector or inside its block. The D-number must be one of this spec's (#249): a comment that cites any other issue, such as D5 (#245), is that issue's ruling and is not a marker, so write #245 decision 5 for those. The lint test fails if workspace.css is missing. The match is by exact selector, so a narrower selector over a concept rule (for example .ca-tools dialog.prompt-sender) passes the lint. Such rules, and the page-stylesheet leftovers such as library.css .row-actions, name the PR that retires them, by convention.

The lint also runs over every page stylesheet that loads before the overlay and is still kept (images.css, library.css, inspector.css; test kept page stylesheets mark their concept-selector overrides). After PR C, images.css holds only the side panel, the prompt blocks and the prompt sender, which PR D moves to the concept panel and deletes with the file. Its three remaining concept-selector overrides (.prompt-text, .prompt-actions, .ca-tools .prompt-actions button) are marked #249 D22 interim until then, and images.css keeps no retired Images page rules keeps the replaced card, grid, filter, toolbar, preview and facts rules out.

Comparison method. Production must equal the concept except for the recorded deviations in the #249 design (spec §8, D-numbers). It is never compared against blessed production screenshots.

  • The fixture is tests/browser/workspace_concept_fixture.py, reset through POST /__test__/concept/reset. It holds the concept's six prompts and pairs, and 24 PNGs with real links, runs and the concept's inspector facts. The sidebar counts are therefore the concept's own: 24, 6 and 15.
  • The shared inventory is tests/browser/concept-inventory.mjs. One self-contained in-page measurer runs the same concept selectors in the immutable concept (/__test__/canonical/workspace) and in production. An absent element becomes null, so the diff names its path (for example chrome.breadcrumbs) instead of failing the measurement.
  • Each page's map<Page>Concept(reference) applies only the recorded deviations, each commented with its D-number. diffInventory must then report nothing.
  • Sizes, offsets and heights that follow from a removed element are derived from the concept's own measurements, never copied from production.
  • mutate(page, {css, remove}) appends a stylesheet and detaches elements, and returns an undo. Each comparison has a mutation check proving it fails. For the shell, a .sidebar nav a { min-height: 30px } rule must fail at chrome.navs.0.rows.0.minHeight and .height, and removing .breadcrumbs must fail at chrome.breadcrumbs.
  • Paired concept/production captures are attached as test artifacts. There are no PNG baselines.

tests/browser/shell-concept.spec.mjs compares the shell: the sidebar, topbar, page heading and footer, at 1600x1100 and 390x844, in dark and light, under normal and reduced motion, on Images, Prompt library and Tags. The heading is compared in full on Tags; on Images and Library only its frame and h1 are compared, because their copy and actions belong to their own PRs. The spec also compares the hover state of the first non-current nav row, the Theme select and the ComfyUI link, and the keyboard rings of the first nav link and heading button.

Recorded shell deviations:

  • D4: a "Theme" select with Dark, Light and System. System follows the ComfyUI palette, then the OS.
  • D5: no Activity button or Activity row until an activity center exists.
  • D28: no SAVED VIEWS or COLLECTIONS, and no Design review. The bottom nav is System plus the production Overview hub (its own outline home glyph, not the concept's activity pulse, and no count). The connection reads "Your library, on your server" with a neutral muted dot.
  • D29: no prototype copy. There is no "Interactive concept" badge and no "What changes & why" button. The footer reads "ComfyAssets · Local tools. Your library.", and the title has no " concept" suffix.
  • D31: at max-width: 950px the overlay caps the one-column shell at minmax(0, 1fr), so extreme zoom cannot widen it to the sidebar's min-content.
  • #245 D2: no "Auto-tag images" on the Tags heading until the image-annotation slice.
  • Brand name: not a deviation. The brand link's accessible name matches the concept's, mark included, because production's .brand-mark is not aria-hidden either. The inventory compares brand.name and brand.mark.hidden.
npm run --prefix tools/vendor check
npm test --prefix tools/vendor
npm run --prefix tools/vendor test:workflows -- shell-concept.spec.mjs \
  --project chromium --project touch --project firefox --project webkit

tests/browser/images-concept.spec.mjs compares the Images page (#249 C7): heading, toolbar, filter row, caption, grid, the first six cards, the continuation and the panel box, in Grid, List, Masonry and the Large and Compact sizes, at 1600x1100 and 390x844 in both themes and motion settings; grid, cards and panel also at 1200x900 (2 columns, 275px panel) and 1800x1100 (4 columns, 310px panel). The concept is compared once its own Load more has loaded all 24 fixture images (D37: it loads 12 per batch, production 40); the Load more row is compared before the press in both documents. Behaviors: hover zoom, the 2nd card opens the viewer titled "