Compare commits

..
Author SHA1 Message Date
Vito Sansevero 82ec8782b4 fix(browser): server shutdown and its tests on Windows, Python < 3.13 and CI
Three CI failures on ed809f7:

- Windows: server.py exited 1 on stdin EOF because create_app works with
  the temp root as its cwd, and Windows cannot remove a process's cwd.
  main() now restores the original cwd before the root is removed.
- Python 3.10 to 3.12: the lifetime test's cleanup called communicate()
  after closing stdin, which flushes the closed pipe (ValueError); 3.13
  tolerates it. It now waits and closes the pipes itself, and the exit
  assertion shows the server's stderr. Under 3.12: errors=2 before, OK
  after.
- Tooling (CI container): the SIGKILLed-worker tests treated an exited
  server as alive, because the container's PID 1 does not reap orphans
  and kill(pid, 0) succeeds on a zombie. Reproduced locally with docker
  without --init (2 failed); a zombie now counts as exited (2 passed).
2026-10-21 22:57:03 -07:00
Vito Sansevero 14cfb8aedf docs(testing): server lifetime, stderr attachments and the final measured CI times
TESTING.md describes the stdin-EOF shutdown and the server-stderr
attachment, states that the npm cache key names the job's image tag
(now tested), quotes run 39725176320 at cbc8a61 (8m38s) and explains the
20-minute browser job limit. The workflow timing comment quotes the same
run.
2026-10-21 22:37:49 -07:00
Vito Sansevero ed809f7fa4 test(browser): server stderr on failures, no orphan servers, waits on state
Final review fixes for the per-worker test servers (#274):

- A failed test gets the stderr its server wrote during that test
  attached as `server-stderr`, so an aiohttp traceback behind a 500
  reaches the CI report. The launcher keeps the newest 256 KB with a
  stream position per test (stderrMark/stderrSince), and the fixture
  attaches it only when the test did not end as expected.
- The port announcement counts only as a complete line; a chunk ending
  mid-number was read as a shorter port.
- server.py --exit-on-stdin-eof shuts down gracefully (database closed,
  temp root removed) when stdin closes. The launcher holds that pipe,
  so a SIGKILLed worker leaves no server behind, on every OS.
- settled() gives up after 600 frames with the observation stats
  instead of hanging to the test timeout.
- The Classic text-sync test waits for the page's 50 ms timer to
  install serializeValue instead of sleeping 60 ms. With that timer run
  100 ms late (a scratch copy), the old wait failed 20/20 and the poll
  passed 20/20; the spec passed 240/240 at 8 workers x10 and 240/240
  at 1 CPU with 4 workers.
- The workflow test checks each npm cache key names its job's pinned
  image tag.

Every new unit test failed before its change (missing export, the old
regex timing out on the split announcement, an unknown server flag, a
hung settle, a mismatched cache key).
2026-10-21 22:37:48 -07:00
Vito Sansevero cbc8a61b1d test(browser): stable under parallel workers
Document how to run the browser suites in parallel and sharded, with the
measured times, and what keeps specs stable beside each other: wait for
a rendering frame, a response or a visible state, never for time.

With b665aa6 the full workflows suite passed on chromium, touch, firefox
and webkit with 8 workers, each test 3 times (6132 passed, 0 failed,
24 minutes), and once at CI size (docker --cpus=4, 3 workers: 2044
passed, 25.5 minutes).

The npm download cache removed the npm ci stall: 0 to 2 s in every job
of run 39722172529, against about 300 s in most jobs before. The
workflow's timing comment now records that run; the whole workflow took
8m54s against about 33 minutes before sharding. Shard imbalance (shard 1
carries the concept-comparison specs) is documented, not tuned.
2026-10-21 21:55:21 -07:00
Vito Sansevero b665aa687d test(browser): wait for rendering frames, not timers or luck, under load
Two Library workflows failed in CI's webkit shard 2/2 (run 39714840809)
under parallel load:

- "library infinite loading ..." counted cards right after the last
  page hid "Load more". That render queues the window update for the
  next frame, so a slow frame left the 120 cards of the all-rows
  layout in place (received 120, expected < 80). It now waits for the
  next frame, which runs after every callback the page queued.
- "row measurement preserves anchor ..." settled with a 60 ms timer. A
  scroll event is delivered in a rendering frame, so on a starved
  renderer the timer ended before the scroll's frame and no card was in
  view (null anchor). settleLibrary now waits for two consecutive
  native frames with no Library frame queued, checked inside the frame
  callback.

Before/after: a scratch copy that delays every frame by 120 ms (as a
starved runner) failed the old helpers 19 of 40 runs, at the CI lines,
and passed the new ones 40 of 40 on chromium and webkit. Throttled to
1 CPU with 4 workers, the original spec failed 1 of 42 for each test.

The Tags viewer workflow closed the viewer as soon as its image was
visible, before ViewerJS rendered the title, so CI's coverage never ran
the page's title callback (tags-page.js functions 26/46 = 56.52%, one
function above the floor of 56; locally 27/46). The test now asserts the
title names the shown file, which always runs the callback.
2026-10-21 21:22:52 -07:00
Vito Sansevero f85b45d9a8 ci(viewerjs): cache npm downloads and size timeouts from the first sharded run
The first sharded run lost a webkit shard to its 12-minute limit after all
267 tests passed, and npm ci stalled for 5 minutes in six of eight jobs. Each
npm ci now restores npm's download cache keyed on the vendor lockfile, and
browser job timeouts are the measured times plus at least 50%, never under 20
minutes. The workflow comment records the measurements.
2026-10-21 20:03:13 -07:00
Vito Sansevero 21a2cc0932 ci(viewerjs): run browser suites with workers and shards
Frontend coverage runs as three shards (PM_TEST_WORKERS=3 each) that upload
their raw V8 files as coverage-raw-N; a merge job downloads them, fails unless
all three arrived with raw files and no clashing names, then runs
coverage:merge. Firefox and WebKit page workflows run as two shards each with
three workers. The required viewerjs context keeps its name and now needs the
shards, the merge and the browser shards, failing unless every one succeeded.
2026-10-21 19:41:54 -07:00
Vito Sansevero 3ebf5c48cf ci(coverage): shard and merge modes for the frontend coverage run
frontend-coverage.mjs now plans its steps from the command line.
No arguments still runs everything, then reports and checks.
--shard I/N runs one slice of the chromium+touch workflows into
coverage/js/raw without wiping it or reporting; shard 1 also runs the
node units and the viewer suite. --merge-only reports and checks over raw
coverage the shards wrote, with --update-baseline as before, and refuses
an empty or missing raw dir. npm scripts: test:coverage:shard and
coverage:merge.
2026-10-21 19:34:08 -07:00
Vito Sansevero 0156d3fadb test(browser): test servers bind an OS-chosen port themselves
tests/browser/server.py accepts --port 0: it binds 127.0.0.1:0 before
building the app, holds that socket for its whole life and prints
PM_TEST_SERVER_PORT=<n> to stdout. startBrowserServer defaults to port 0
and reads the announced port, so no port is reserved and released before
the server binds and two launchers can no longer race for one. The
"address already in use" retry and free-port.mjs are gone; --port N still
works and is checked against the announcement.
2026-10-21 18:53:35 -07:00
Vito Sansevero 357c0f09e7 test(browser): one test server per Playwright worker
The page workflows started one global webServer and ran with one worker.
Each worker now owns its own tests/browser/server.py on an OS-chosen port,
with its own temp root and database, through a shared `test` in
tests/browser/fixtures.mjs. baseURL, and so page, request and
browser.newContext, point at that worker's server. A server that dies
fails the running test with its stderr tail and the next test gets a
fresh one. The worker count comes from PM_TEST_WORKERS, else 1 under CI,
else a quarter of the CPUs up to 8.

Chromium workflows in the pinned image: 510 tests in 606 s with one
worker on main, 511 (one new isolation spec) in 119 s with eight.
2026-10-21 18:30:22 -07:00
Vito 2cecfcf0ac feat(activity): scan new and unresolved files by default, and download a job report (#273)
* feat(activity): scan new and unresolved files by default, all files on request

Scan for metadata now skips, before reading any metadata, the files the
Library already links with a resolved link (one generated_images row with
exactly one pair link), found with one streamed query that keeps only the
scanned paths and compares paths with os.path.normcase on both sides.
Skipped files count as Already known ("Skipped · already linked").
Unlinked, fallback-linked, ambiguous and unresolved files are read again.

POST /prompt_manager/jobs/scan takes {scope: "new" | "all"} (default
"new", anything else is a 400). The review dialog shows the concept's
"New and unresolved files" and adds an "All files" switch (D58). The
legacy /prompt_manager/scan route keeps scanning all files with unchanged
SSE payloads.

* feat(activity): download a job report

GET /prompt_manager/jobs/{id}/report returns the summary, counters, every
outcome group's listed rows with truncated flags, the scope and the log as a
JSON attachment (prompt-manager-<kind>-<YYYYMMDD-HHMM>.json), with no absolute
paths. The job view's Download report saves it through the api-client URL, and
the scan log opens at its newest line and follows new lines only while the
reader is at the bottom.

* fix(activity): re-read known files whose size changed, and review follow-ups

The new scope now skips a known, resolved file only while its stored
generated_images.file_size equals its size on disk; a NULL stored size is
re-read. A file replaced at the same path is read again and its conflict
shows under Needs review. The scan stores each file's size when it ingests
it, and both sides of the known-set match go through normalize_image_path.

A failed known-set read logs and falls back to reading every file, and an
empty request body asks for the default scope. The All files switch is
described by its hint, a lost job's Download report is disabled, the report
toast is skipped once the view is gone, and the scan log opens at its
newest line every time. Fixtures use the real scope shape.

* test(activity): wait for the job view's settled detail read, not a fixed delay

The 200-row cap test now starts counting detail reads once one read has
returned the full listing and none is in flight. The job view re-reads after
an in-flight read only when the summary still lists more than that read
returned, so no redundant read is left due after opening.
2026-10-21 18:07:16 -07:00
Vito a6f6697c50 feat(activity): shared job registry, Activity page and Scan for metadata (#269)
* feat(activity): shared session job registry with the scan as its first kind

The output scan now runs as the first kind of an in-memory JobRegistry
(py/api/jobs.py, replacing py/api/scan_job.py). Each job keeps exact
counters, at most 200 outcome rows per group and a log capped at 2,000
lines, with root-relative paths and public root labels only. New routes
list, inspect, follow (SSE) and cancel jobs and start the scan
(POST /prompt_manager/jobs/scan, 409 busy while another record-changing
job runs). The legacy /prompt_manager/scan routes attach to the same job
and stream unchanged payloads.

* feat(activity): concept Activity page, top-bar button and progress pill

The workspace gains the concept's Activity page (renderActivity), its
top-bar button and sidebar row, fed by one session job store: a /jobs
read, one SSE stream per running job, a 1-30 s capped backoff after a
dropped stream and a 15 s list poll while visible. A job the server no
longer lists shows as ended, so no pill stays stuck after a restart.

D52: the counts show running jobs and hide at 0; the phone top-bar row
may wrap. D53: the mockup's progress pill links the running job. D54:
real job items with state chips, detail lines and Cancel job. Start and
finish toasts follow the spec copy. Fake browser jobs come from
tests/browser/workspace_activity_fixture.py.

* feat(activity): Scan for metadata with its review dialog and job view

Images -> Import keeps Rescan and adds the concept's "Review scan" row as
"Scan for metadata". It opens the concept's "Review metadata scan" with the
real source count, the fixed "All files" scope, a backup box that downloads
the existing database backup, and Cancel / Start scan. Start scan starts the
session scan job and opens its view; a start refused by another
record-changing job toasts its title as a link.

#/activity/<id> is now the approved job-view mockup: the heading with
Download report (disabled until Activity-2) and Cancel scan, the Progress
surface with the five counter tiles, the Results surface with the outcome
list, path filter and capped rows (Added and Already known open the viewer
through the listing identity), and the collapsed Scan log. Activity item
titles link their views. The scan's progress line groups thousands.

Deviations D55-D57 are recorded in TESTING.md.

* fix(activity): stop the job store on pagehide and let proxied routes outlive teardown

CI's touch coverage run failed "a resolver failure for the ComfyUI link never
stops the shell from mounting" with "route.fulfill: Fetch response has been
disposed": its trace shows two Library thumbnail requests started by the last
route still in the spec's proxy-mount handler while the page fixture was torn
down.

- The proxy-mount handler ignores only that teardown error, and the workspace
  and Activity specs unroute with ignoreErrors before teardown.
- The Activity store now ends its work on pagehide: the poll and backoff timers
  stop, the job streams close and a list read in flight is aborted (an older
  read is also aborted when a newer one starts). A pageshow from the
  back-forward cache reads the list again.

* test(activity): compare the job view log's line height to the hundredth of a pixel

WebKit reports the mockup's 11px/1.7 line height as 18.700001px, so the job view comparison failed on WebKit only. The measurement now rounds it, as other engine-dependent values are normalized.

* fix(activity): final review round for the job store, job view and scan rows

- Store: one failed list read no longer marks running jobs as ended; two in
  a row, or a good list without them, do (M1). A good read resets the
  backoff, except for a stream that keeps dropping while the list reads fine
  (M2). Finished jobs the server pruned are dropped (M3). Numbers group as
  en-US, like the server's lines (M13).
- Job view: a job the store's list no longer has shows the not-found state
  (M3). Focus moves to the job heading when Cancel scan goes away, and to the
  item's title link in the list (M4). The detail is read only when its listed
  rows change or the open log grows, using the summary's new listed and
  log_length (M12).
- Viewer rows open by exact identity: each scan row records its root's
  listing index, the source must still carry the row's label, and the
  workspace listing gains an exact `path` filter (M8, M9).
- Scan rows name the prompt by its Library title, stored title first (M10).
- A toast with a link stays 20 s, and any toast stays while hovered or
  focused (M14).
- A real two-batch scan cancelled after batch 1 keeps partial counts (M7).
- TESTING.md: D54, D56 and D57 updated (M5, M6).

* test(activity): cover the held and linked toast and a job view pruned by the server

Unit tests for the toast's 6.5 s and 20 s timeouts and its hover and focus hold, and a browser test for a finished job's open view after the server stops listing it. Coverage floors are only raised.
2026-10-21 14:22:30 -07:00
Vito 6e9d8f81fd ci(coverage): fast check that every frontend file has a coverage baseline (#271)
The frontend coverage job only reports a missing baseline entry after its
full browser run. coverage:baseline-check compares the files the gate
measures (same discoverSources) with frontend-coverage-baseline.json in
milliseconds and also flags entries for files that no longer exist. npm test
runs the same check.
2026-10-21 13:08:38 -07:00
Vito 454ac12e1d fix(images): list sources and folders that load after Refine opened (#272)
* fix(images): list sources and folders that load after Refine opened

"Refine this view" copied the discovered sources and folders when it
opened, so opening Filters before /gallery/subfolders answered left
Source and Folder at "All sources" / "All output folders" until the
dialog was reopened. On a slow CI runner this stalled the touch
"collection viewer follows filtered loaded order beyond mounted cards"
workflow in selectOption('0') until the 30s timeout.

openRefineDialog now returns setOptions(key, options); the Images page
calls it when discovery settles, and the open dialog refills Source and
its Folder list, showing the value it opened with (or the one chosen
since) once offered. It does nothing after the dialog closed or was
replaced.

* test(library): read filtered shelf titles after the cards' frame

The Library concept comparison read card titles as soon as the caption
changed and the card count dropped below six. Production empties the
shelf when a filter applies, writes the new caption when the page
answers and places the cards in the next animation frame, so on a slow
CI runner the empty shelf satisfied "fewer than six" and the titles
read as []. Settle one frame pair before reading, as the Images and
Inspector comparisons already do.
2026-10-21 12:36:38 -07:00
Vito 27b4895795 fix(resolver): identical sampler branches resolve to their shared prompt pair (#270)
* fix(resolver): identical sampler branches resolve to their shared prompt pair

A LoRA epoch-compare grid runs one sampler per epoch, every one fed by the
same positive encode and the same ConditioningZeroOut negative, and saves
one concatenated image. `_resolve` returned "Multiple sampler branches"
(ambiguous) whenever more than one sampler was found, even though every
branch used the same pair, so the workspace inspector fell back to the
legacy parser and the image could not be paired.

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

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

Tests that encoded the old behavior (identical branches => ambiguous):
- test_prompt_pair_resolver: test_multiple_samplers_are_ambiguous_even_when_texts_match
  -> test_multiple_samplers_with_matching_texts_resolve_to_the_shared_pair
  (plus a new differing-texts case that stays ambiguous);
  test_raw_and_detailed_saves_stay_ambiguous_without_an_anchor (same prompt)
  -> ..._of_one_prompt_share_it_without_an_anchor (anchored cases unchanged).
- test_pair_scan: two samplers sharing one prompt now ingest the pair
  ("cat"/"blur") instead of the positive-only fallback.
- test_api_prompt_inspector, test_inspection_outcomes, test_pair_image_linking:
  their "ambiguous" fixtures now use samplers with different prompts.
No parity vector changes (f01/f09 branches are not all resolved).

* fix(inspector): never show the positive as a zeroed negative from the legacy fill

When the server leaves the negative undecided, the workspace fills it from
the legacy browser parser (comfy-metadata.js), which follows
ConditioningZeroOut back to its input and so reads the positive text as
the negative. The workspace now checks the prompt graph first:

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

comfy-metadata.js is untouched; the legacy pages keep their reading.

* fix(images): explain why Set category has nothing to set

Set category on a selection where no image is linked to a Library prompt
said only "None of the selected images has a linked prompt." It now says
why and what it means: "The selected images aren't in your Library yet,
so there is no prompt category to set." Unit and page-workflow tests
follow the copy.

* fix(inspector): check only the negative the legacy parse read for a zero-out

Review I1/M2/M3/M5 of the zero-out legacy-fill guard. The guard decided
over every node with a `negative` input, so a graph whose read sampler
encodes "blurry" lost that legacy-filled negative when another branch
zeroed its own (fewer than the legacy pages: below the parity floor).

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

Tests: the reversed mixed graph shows "blurry"; the mixed graph whose
read sampler is zeroed, the zero-out-only grid and a direct workflow
zero-out show the zeroed empty negative; a combined zero-out and a
zero-out subgraph instance stay unfilled; bypassed and unconnected
workflow zero-outs keep the legacy reading. An API-graph Reroute in
front of a zero-out gives the legacy parse no negative, so the server
answer stays (that expectation changed from zeroed).

* fix(images): name the missing link in the Set category toast

Review M4: say that no selected image is linked to a saved prompt, in
the same terms as the partial-success toast and right for one image.

Also review M1: a monitor test that two parallel samplers sharing one
positive and one negative write that pair (the resolver collapse).
2026-10-21 10:55:35 -07:00
Vito e9996996e2 feat(views): saved views and Favorites on Images and Library (#268)
* feat(views): saved-views store and API

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

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

Refs #249

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* test(history): concept comparison for prompt history

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* feat(history): concept negative variants panel

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

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

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

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

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

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

* feat(history): compare negative variants

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* docs(inspector): concept panel and viewer details

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* test(library): concept comparison for the shelf

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

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

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

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

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

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

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

- D44: the shared toolbar's Filters button reads "Filters · N" while N
  "Refine this view" filters are active (Library: Tags; Images: Source,
  Folder, Media, Tags), so a Browse-set Tags filter is visible on the
  shelf. The text is the accessible name. Unit and browser tests cover
  Browse -> "Filters · 1" -> Clear -> "Filters" and the Images counts.
- Renumber the alphabetical tag chips deviation D42 -> D43 (D42 is #264's).
- The caption count is a role=status region written only when it changes
  and kept shown while the shelf reloads.
- usedLabel names the year for a use in another calendar year (D23).
- prompt-blocks: same `title` option API as #264 (`subject` suffix); the
  region-name helper's parameter no longer shadows it.
- Scope library.css .row-actions rules to the history panels; fix stale
  CSS and spec comments.
- Retry re-reading a failed category list and the no-op Apply/search
  paths are covered by a browser test.
- TESTING.md: D23 amendments (year, button vs link trade-off), D44, the
  legacy Library link wording.
2026-10-20 23:26:05 -07:00
132 changed files with 15009 additions and 997 deletions
+132 -22
View File
@@ -16,9 +16,19 @@ concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
# Timeouts are the measured job time plus at least 50%, and never under 20 minutes for a browser job,
# because runner speed varies. Before sharding (main 2cecfcf, one worker): frontend coverage 32m10s,
# page workflows firefox 18m59s, webkit 19m26s. First sharded run (PR #274 run 39712909047, 3 workers,
# no npm cache, where npm ci stalled for 5m in six of eight jobs): coverage shards 11m19s/10m47s/10m49s
# (tests 5m20s/4m42s/4m46s), merge 5m52s (report and check 4s), firefox shards 7m53s/9m27s (tests
# 4m41s/3m29s), webkit shards 12m25s+ (cancelled at the old 12-minute limit after its 267 tests passed in
# 6m29s) and 4m53s (tests 3m08s). With the npm cache hit (run 39725176320 at cbc8a61, npm ci 0-2s):
# coverage shards 7m34s/6m04s/5m22s (tests 6m38s/4m53s/4m04s), merge 54s, firefox 7m57s/5m21s (tests
# 7m08s/4m34s), webkit 8m16s/5m03s (tests 7m26s/3m58s); a cache miss adds up to 5m to npm ci, which
# the 20-minute limits absorb.
jobs:
frontend-coverage:
name: frontend coverage (chromium and touch)
name: frontend coverage (shard ${{ matrix.shard }}/3)
runs-on: ubuntu-latest
# Layout comparisons depend on the fonts each browser falls back to, so browser jobs run in the
# same pinned Playwright image as local runs (tag = @playwright/test in tools/vendor/package-lock.json;
@@ -26,12 +36,15 @@ jobs:
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble
options: --user 1001
# This job runs every page workflow twice (chromium, then touch) on one worker with V8 coverage on, so it
# takes about twice a single-browser job. Measured 2026-10-20: whole job 18m26s-24m57s (main 5ded3cf
# 22m15s; PR head 6359999 was cancelled at the old 25-minute limit while still passing), coverage step
# alone 20m55s-22m17s+, against ~12-15 minutes for the firefox/webkit jobs. 40 minutes leaves headroom
# for suite growth while still stopping a hung run; re-measure if a run passes 30 minutes.
timeout-minutes: 40
timeout-minutes: 20
# Public-repo ubuntu-latest runners have 4 vCPUs: three Playwright workers, each with its own test
# server, leave one CPU for the servers and the runner.
env:
PM_TEST_WORKERS: 3
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3]
steps:
- uses: actions/checkout@v7
# The image's own Python 3.12 and Node 24: setup-python/setup-node install the runner's toolcache
@@ -50,24 +63,107 @@ jobs:
"$venv/bin/python" -m pip install --no-cache-dir -r requirements-dev.txt
echo "$venv/bin" >> "$GITHUB_PATH"
- run: python --version && node --version && node --version | grep -q '^v24\.'
# npm's download cache, keyed on the lockfile; inside the container ~ is the job's HOME (/github/home),
# which npm also uses. node_modules itself is still built fresh by npm ci.
- name: Cache npm downloads
uses: actions/cache@v6
with:
path: ~/.npm
key: npm-${{ runner.os }}-playwright-v1.64.1-${{ hashFiles('tools/vendor/package-lock.json') }}
- run: npm ci --prefix tools/vendor --ignore-scripts
- run: npm run --prefix tools/vendor check
- run: npm run --prefix tools/vendor test
- name: Frontend unit, viewer, workflow, and coverage checks
run: npm run --prefix tools/vendor test:coverage
- name: Upload frontend coverage and Chromium traces
# Shard 1 also runs the node units and the viewer suite, so the vendor pins and the tooling tests
# run on the lightest shard instead.
- if: matrix.shard == 3
run: npm run --prefix tools/vendor check
- if: matrix.shard == 3
run: npm run --prefix tools/vendor test
- name: Page workflows with coverage, shard ${{ matrix.shard }}/3
run: npm run --prefix tools/vendor test:coverage:shard -- ${{ matrix.shard }}/3
- name: Upload raw coverage
uses: actions/upload-artifact@v7
with:
name: coverage-raw-${{ matrix.shard }}
path: coverage/js/raw/
if-no-files-found: error
retention-days: 3
- name: Upload Chromium and touch traces
if: always()
uses: actions/upload-artifact@v7
with:
name: frontend-traces-shard-${{ matrix.shard }}
path: tools/vendor/test-results/
retention-days: 14
frontend-coverage-merge:
name: frontend coverage (merge)
needs: frontend-coverage
runs-on: ubuntu-latest
# Same image as the shards: the raw files name sources by the container's workspace path.
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble
options: --user 1001
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- run: node --version && node --version | grep -q '^v24\.'
# npm's download cache, keyed on the lockfile; inside the container ~ is the job's HOME (/github/home),
# which npm also uses. node_modules itself is still built fresh by npm ci.
- name: Cache npm downloads
uses: actions/cache@v6
with:
path: ~/.npm
key: npm-${{ runner.os }}-playwright-v1.64.1-${{ hashFiles('tools/vendor/package-lock.json') }}
- run: npm ci --prefix tools/vendor --ignore-scripts
# Each artifact lands in its own coverage-raw-N directory, so the next step can count them.
- uses: actions/download-artifact@v8
with:
pattern: coverage-raw-*
path: ${{ runner.temp }}/coverage-raw
# coverage:merge only checks that raw files exist; merging two of three shards would report
# partial coverage, so a missing or empty shard artifact fails here.
- name: Verify every shard's raw coverage arrived
shell: bash
env:
STAGED_RAW: ${{ runner.temp }}/coverage-raw
EXPECTED_SHARDS: 3
run: |
found=0
for shard in $(seq 1 "$EXPECTED_SHARDS"); do
directory="$STAGED_RAW/coverage-raw-$shard"
count=0
if [ -d "$directory" ]; then count=$(find "$directory" -maxdepth 1 -type f -name '*.json' | wc -l); fi
echo "coverage-raw-$shard: $count raw files"
if [ "$count" -gt 0 ]; then found=$((found + 1)); fi
done
echo "shard artifacts with raw coverage: $found of $EXPECTED_SHARDS"
if [ "$found" -ne "$EXPECTED_SHARDS" ]; then
echo "::error::Expected raw coverage from $EXPECTED_SHARDS shards, got $found of $EXPECTED_SHARDS"
exit 1
fi
staged=$(find "$STAGED_RAW" -mindepth 2 -maxdepth 2 -type f -name '*.json' | wc -l)
mkdir -p coverage/js/raw
# cp -n never overwrites; a clash shows up as fewer merged files than staged ones.
find "$STAGED_RAW" -mindepth 2 -maxdepth 2 -type f -name '*.json' -exec cp -n -t coverage/js/raw {} + || true
merged=$(find coverage/js/raw -maxdepth 1 -type f -name '*.json' | wc -l)
if [ "$merged" -ne "$staged" ]; then
echo "::error::Shards wrote clashing raw file names: $staged staged, $merged merged"
exit 1
fi
echo "merged $merged raw files"
- name: Frontend coverage report and baseline check
run: npm run --prefix tools/vendor coverage:merge
- name: Upload frontend coverage report
if: always()
uses: actions/upload-artifact@v7
with:
name: frontend-test-reports
path: |
tools/vendor/test-results/
coverage/js/
!coverage/js/raw/**
retention-days: 14
browser-workflows:
name: page workflows (${{ matrix.browser }})
name: page workflows (${{ matrix.browser }}, shard ${{ matrix.shard }}/2)
runs-on: ubuntu-latest
# Layout comparisons depend on the fonts each browser falls back to, so browser jobs run in the
# same pinned Playwright image as local runs (tag = @playwright/test in tools/vendor/package-lock.json;
@@ -75,11 +171,16 @@ jobs:
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble
options: --user 1001
timeout-minutes: 25
timeout-minutes: 20
# Public-repo ubuntu-latest runners have 4 vCPUs: three Playwright workers, each with its own test
# server, leave one CPU for the servers and the runner.
env:
PM_TEST_WORKERS: 3
strategy:
fail-fast: false
matrix:
browser: [firefox, webkit]
shard: [1, 2]
steps:
- uses: actions/checkout@v7
# The image's own Python 3.12 and Node 24: setup-python/setup-node install the runner's toolcache
@@ -98,22 +199,29 @@ jobs:
"$venv/bin/python" -m pip install --no-cache-dir -r requirements-dev.txt
echo "$venv/bin" >> "$GITHUB_PATH"
- run: python --version && node --version && node --version | grep -q '^v24\.'
# npm's download cache, keyed on the lockfile; inside the container ~ is the job's HOME (/github/home),
# which npm also uses. node_modules itself is still built fresh by npm ci.
- name: Cache npm downloads
uses: actions/cache@v6
with:
path: ~/.npm
key: npm-${{ runner.os }}-playwright-v1.64.1-${{ hashFiles('tools/vendor/package-lock.json') }}
- run: npm ci --prefix tools/vendor --ignore-scripts
- name: Page workflows in ${{ matrix.browser }}
run: npm run --prefix tools/vendor test:workflows -- --project ${{ matrix.browser }}
- name: Page workflows in ${{ matrix.browser }}, shard ${{ matrix.shard }}/2
run: npm run --prefix tools/vendor test:workflows -- --project ${{ matrix.browser }} --shard ${{ matrix.shard }}/2
- name: Upload browser traces
if: always()
uses: actions/upload-artifact@v7
with:
name: browser-traces-${{ matrix.browser }}
name: browser-traces-${{ matrix.browser }}-shard-${{ matrix.shard }}
path: tools/vendor/test-results/
retention-days: 14
# Branch protection requires the exact context "viewerjs". This job keeps that
# name and fails unless every frontend job above succeeded, including when one
# was cancelled or skipped.
# name and fails unless every frontend job above succeeded (every shard, the
# coverage merge and every browser shard), including when one was cancelled or skipped.
viewerjs:
needs: [frontend-coverage, browser-workflows]
needs: [frontend-coverage, frontend-coverage-merge, browser-workflows]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
@@ -121,8 +229,10 @@ jobs:
- name: Require every frontend job to succeed
env:
COVERAGE_RESULT: ${{ needs.frontend-coverage.result }}
MERGE_RESULT: ${{ needs.frontend-coverage-merge.result }}
WORKFLOWS_RESULT: ${{ needs.browser-workflows.result }}
run: |
echo "frontend-coverage: $COVERAGE_RESULT"
echo "frontend-coverage-merge: $MERGE_RESULT"
echo "browser-workflows: $WORKFLOWS_RESULT"
test "$COVERAGE_RESULT" = success && test "$WORKFLOWS_RESULT" = success
test "$COVERAGE_RESULT" = success && test "$MERGE_RESULT" = success && test "$WORKFLOWS_RESULT" = success
+583 -41
View File
@@ -39,9 +39,44 @@ 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
`PM_TEST_PYTHON` selects the Python executable; set it when Python is outside
PATH. Every Playwright worker starts its own `tests/browser/server.py --port 0`,
which binds a port the OS picks and prints it as `PM_TEST_SERVER_PORT=<n>`, with
its own temp root and database (`tests/browser/fixtures.mjs`), so
parallel workers and concurrent runs never share state. `PM_TEST_WORKERS` sets the
worker count (default 1 under `CI`, otherwise a quarter of the CPUs, at most 8).
Test servers are never reused across runs. The launcher holds each server's
stdin open and the server (`--exit-on-stdin-eof`) shuts down cleanly, removing its
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).
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`:
```sh
PM_TEST_WORKERS=8 npm run --prefix tools/vendor test:coverage # whole run, one report
npm run --prefix tools/vendor test:coverage:shard -- 1/3 # node units, viewer suite, workflows 1/3
npm run --prefix tools/vendor test:coverage:shard -- 2/3 # workflows 2/3 (likewise 3/3)
npm run --prefix tools/vendor coverage:merge # report and baseline check of every raw file
npm run --prefix tools/vendor test:workflows -- --project webkit --shard 2/2
```
`coverage:merge` refuses an empty raw directory; it does not count shards, so run
every shard first (CI's merge job checks all three arrived). Measured on a 32-CPU
machine in the pinned image (2026-10-21): `test:coverage` takes about 5 minutes
with 8 workers and 24 minutes with 1; all four projects, 8 workers, each test 3
times (6132 tests), 24 minutes. A CI-sized run (`docker run --cpus=4`,
`PM_TEST_WORKERS=3`, all four projects once, 2044 tests) takes 25.5 minutes.
Specs must pass beside each other, not only alone. Wait for the page's own
signal, never for time: a rendering frame (`requestAnimationFrame`) for layout
work queued by a render or a scroll, a response or a visible state for data. A
timer that "usually" covers a frame is a race on a loaded runner. Library
virtualization specs settle with `libraryObservation.settled()` (two consecutive
frames with no Library frame queued). To look for such races, repeat a spec under
load (`--workers 8 --repeat-each 10`, and in the image with `--cpus=1` or `2`).
On unsupported Linux
distributions, browser system dependencies may need manual installation. For a
quick browser run, use `test:workflows -- --project chromium`.
@@ -109,14 +144,44 @@ 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.
`npm run --prefix tools/vendor coverage:baseline-check` runs in well under a
second without a browser and fails, naming the files, when a measured
`web/js` file has no baseline entry or the baseline lists a file that no longer
exists. It uses the gate's own `discoverSources`, and `npm test` runs the same
check, so run it before pushing a new or deleted frontend file instead of
waiting for the `frontend coverage` jobs.
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.
aggregate job that passes only when every frontend job succeeds: the three
`frontend coverage (shard N/3)` jobs, `frontend coverage (merge)`, and the four
`page workflows (firefox|webkit, shard N/2)` jobs. A failed, cancelled or skipped
job fails it. Every browser job runs with `PM_TEST_WORKERS: 3` (the public
runners have 4 vCPUs). Each coverage shard runs `test:coverage:shard -- N/3`
(shard 1 also runs the node units and the viewer suite, shard 3 the vendor pins
and tooling tests) and uploads its raw V8 files as `coverage-raw-N` (kept 3
days). The merge job downloads them, fails unless all three arrived with raw
files and without clashing names, then runs `coverage:merge` (report plus
baseline check). `tests/vendor/ci-workflow.test.mjs` checks this shape. GitHub
artifacts named `frontend-test-reports` contain the merged HTML coverage and
JSON summary; `frontend-traces-shard-N` hold the Chromium/touch failure
screenshots/traces and `browser-traces-<browser>-shard-N` the other browsers'
traces. Open traces with Playwright's `show-trace` tool.
Every `npm ci` first restores npm's download cache (`~/.npm`), keyed on the vendor
lockfile and the job's pinned image tag (`tests/vendor/ci-workflow.test.mjs` checks
the key names the same tag as the job's `container.image`). Without it, `npm ci` stalled for about 300 s
(npm's fetch timeout) in most of the eight concurrent jobs; with a hit it takes
0 to 2 s. A lockfile change misses the cache once, so expect one slow run after it.
Measured on PR #274 (run 39725176320 at cbc8a61, cache hit): the whole workflow
took 8m38s, against about 33 minutes with one worker and no shards. Jobs: coverage
shards 7m34s/6m04s/5m22s (tests 6m38s/4m53s/4m04s), merge 54s, firefox shards
7m57s/5m21s (tests 7m08s/4m34s), webkit shards 8m16s/5m03s (tests 7m26s/3m58s).
Browser jobs keep a 20-minute limit: the slowest job is about 8.5 minutes, a cache
miss adds up to 5 to `npm ci`, and runner speed varies (the first sharded run was
cancelled at a 12-minute limit).
Playwright shards by test count, whole files at a time, so shard 1 carries the
slow concept-comparison specs (`*-concept.spec.mjs`, alphabetically first) and
runs longer; the remedy, if it starts to matter, is more shards, not reordering.
The real-host suite covers both Classic and Nodes 2.0 startup, switching both
ways, actual database prompt selection, execution-text serialization, workflow
@@ -225,6 +290,19 @@ managed host with both renderer startup configurations.
The shared inline and collection inspector reads the current rooted file's supported
embedded records, exact generation facts and recorded or inferred prompt evidence.
It renders in two concept surfaces (#249 D20–D22, D38). The Images side panel shows
only the concept content: cover, title, filename, the evidence badge with a category
chip, the + and − prompt blocks with copy and send, tag chips (the linked positive's
own tags filter Images, D21), the six facts, and a footer with "Send to ComfyUI" and
"Prompt history" only when prompt roles resolved or a link is saved; the sticky panel
scrolls inside the window. The viewer's details (`#viewer-details`) repeat that
content without the cover, then "File & provenance" (the honest file facts, the
`Prompt evidence` line, Download original and, for a listed file, D38's local-only
"Show in folder" after the Path) and "Workflow & raw metadata" (raw records and their
two downloads), then the pair send and Prompt history buttons. D22 words an empty
positive explicitly and makes confirmed prompt text focusable.
`tests/browser/inspector-concept.spec.mjs` compares both surfaces with the concept
(see the D20–D39 notes under Images).
`tests/browser/full-inspector.spec.mjs` covers original/metadata/workflow downloads,
response limits, source changes, nested sending, matched dark/light and mobile
geometry, and the loaded-collection viewer. `viewer matches the concept shell and
@@ -348,8 +426,8 @@ of the approved concept's `docs/concepts/workspace/style.css`. Never edit the co
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.
the toolkit controls, Viewer.js, `concept.css` and the production overlay `workspace.css`
last; every page stylesheet that waited for its page PR is retired.
`tests/vendor/concept-css.test.mjs` asserts that order, the drift check, and that no
other page or canvas script loads workspace CSS.
@@ -360,19 +438,28 @@ D-number must be one of this spec's (#249): a comment that cites any other issue
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.
`.ca-tools dialog.prompt-sender`) passes the lint; by convention such rules carry a
marker too.
The lint also runs over every page stylesheet that loads before the overlay and is
still kept (`library.css`; test `kept page stylesheets mark their concept-selector
overrides`). PR D deleted `images.css` and `inspector.css` (test `the retired shell,
vocabulary, editor, Images and inspector stylesheets stay deleted`); their few
production-only rules with a live consumer (inspector wrapping and the inert download,
the narrow Images container, the Library and recorded-run prompt blocks, the Archive
negative's `section.prompt-block`, the prompt sender) moved to the overlay under
`#249 D3`, and `the overlay keeps no retired Images page or inspector rules` keeps the
replaced card, filter, toolbar, preview, facts and inspector rules out.
PR D deleted `images.css` and `inspector.css`, and PR F-b deleted `library.css`, the
last page stylesheet (test `the retired shell, vocabulary, editor, Images, inspector and
library stylesheets stay deleted`). `the overlay keeps no retired Images page or
inspector rules` keeps the replaced card, filter, toolbar, preview, facts and inspector
rules out, and `the overlay keeps no rule waiting for a page rebuild` keeps out the
interim guards and the history page's private role colors. Every rule that stayed has
a live consumer and a marker: the Library rows windowed (D3) and the shelf's states
(D40), the history panel's wrap, archived-lane and coarse-target rules (D45), and under
**D48** the production surfaces the concept never shows, drawn in the concept's tokens
(`--green`, `--red`, `--ca-*`): recorded executions and the Recorded run dialog (#209,
D26), `pre.prompt-text` for stored text in the frozen field provenance and the Archive
negative confirmation, whose `section.prompt-block` (#235) keeps its rules too, and the
acknowledged prompt sender (spec §2.2). Rules that repeated the concept
(the `.library-grid` tracks and breakpoints, the history `.row-actions` flex row and
button size, `summary` cursors, `h2` margins, the sender and Recorded run dialogs'
colors and border) or had no effect (`min-inline-size` on block children, a
`.recorded-run-dialog pre` limit, a 700px padding that a later rule overrode) were
deleted; the workspace browser specs on Chromium and the concept specs on touch prove
the pages unchanged.
**Comparison method.** Production must equal the concept except for the recorded
deviations in the #249 design (spec §8, D-numbers). It is never compared against
@@ -408,14 +495,255 @@ 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).
- **D5**: retired by Activity (#249 Activity-1). The concept's Activity button and
Activity row are shown; their differences are D52 to D54.
- **D28**: no COLLECTIONS, and no Design review. The bottom nav is
the concept's Activity and System, plus the production Overview hub (its own
outline home glyph, not the concept's `activity` pulse, and no count).
The connection reads "Your library, on your server" with a neutral muted dot.
- **D49** (saved views, #249 D16): the SAVED VIEWS group label has no "PROPOSED"
badge, because saved views are persisted (`/prompt_manager/saved-views`). Its rows are
the concept's `links()` rows: the search icon, the view name and no count, linking
`#/saved/<id>`. Opening one shows its page with the saved toolbar value and Refine
filters, and the view's name as the h1, breadcrumb and title. A value its page no
longer accepts (a removed Source or Folder) stays default; an unknown id opens Images.
A toolbar change made while the sources are still loading keeps the saved Source and
Folder until discovery settles. A saved view, like Favorites, never replaces the plain
Images or Library return state. Writes (POST, PATCH) validate strictly; a state over
4 KB is refused in the browser with "The view is too large to save. Shorten its
search, folder or tags and try again." Startup and restore check only the table's
structure and that each row's state is a JSON object of at most 4 KB, never the value
rules, so a row saved under older rules (a missing or retired key, a sort no longer
offered) never stops the database from opening: reads fill missing keys with their
defaults, drop unknown keys, default invalid values and make the name displayable
(`tests/test_saved_views.py`). A live row whose state is not a JSON object never
blocks startup either: its id is logged as a warning, the row is left in place, the
listing and rename skip it, and its `#/saved/<id>` link behaves like a missing view.
Restore stays strict and rejects such a backup ("Saved views contains invalid values"). Only request validation is a 400; any other failure is
a logged 500 with a generic message. The Favorites count re-read after starring is
coalesced to one read 300 ms after the last toggle.
`a saved view row matches the concept's` saves one view in both documents and
compares the rows; `tests/browser/workspace-saved-views.spec.mjs` covers saving,
reopening, the defaults that replace rejected values, and failed reads and saves.
- **D50**: no "Needs review" row in SAVED VIEWS until the tag-review slice (maintainer
ruling, 2026-10-21). The comparison maps only that row out.
- **D51** (maintainer ruling, 2026-10-21, beyond the concept): SAVED VIEWS starts with
the concept's Favorites row (star icon, name, count). Its count is the Favorites
listing's own `limit=1` total (`/images/output?mode=workspace&favorites=1`), follows
the nav-count rules (hidden until loaded, absent when its read fails, never a
placeholder) and is re-read after every confirmed star or unstar, on any page.
`#/favorites` is Images filtered to favorites, named "Favorites" (concept
`renderImages`); `favorites=1` keeps only the selected roots' favorites, read per root
from `image_favorites` and matched against the scan with the toggle's case folding,
and a failed read is an error, never an unfiltered page. Unlike the concept, an
unstarred card stays on Favorites (showing ☆) until the next load, so the star can be
undone in place. Favorites neither restores nor replaces the Images return state.
Save view there saves `favorites: true`, so the view reopens the favorites. An open
saved view's heading leads with **Edit**: "Edit saved view" with "View name" and
Delete… (danger), Cancel and Save. Save re-reads the list, retitles the page and
toasts "Saved view renamed."; an empty name keeps the dialog open. Delete… swaps the
same dialog to "Delete saved view" (Cancel / Delete; never `window.confirm`; its Cancel
closes the dialog without deleting rather than returning to Edit); Delete
removes the view, replaces the dead route with the view's plain page and toasts
"Saved view deleted.". Failures toast "The view could not be renamed. Try again." or
"The view could not be deleted. Try again."; a view another tab already deleted counts
as deleted. `tests/browser/workspace-saved-views.spec.mjs` covers the row, count,
route, star updates, Save view on Favorites, Edit, and the failure paths;
`tests/test_image_favorites.py` covers `favorites=1`.
- **D52** (Activity, #249 Activity-1): the top-bar `button#activity-open` and the
sidebar's Activity row (the concept's `activity` pulse, first in `system-nav`) count
this session's *running* jobs. The concept counts
every sample job. `span.count#activity-count` is `hidden` at 0 (its text stays "0"),
and the row has no `.nav-count` then. The Activity heading has no "Unified activity ·
proposed" chip (prototype copy). At `max-width: 700px` the full-width `.top-actions`
row may wrap (`flex-wrap: wrap`), so the button, Theme and ComfyUI stay inside a
zoomed phone. The shell comparison hides the concept's count at runtime
(`#activity-count { display: none }`), so the concept's own CSS lays out its button
without it, and maps the row's count to none, the button's count to "0" and the
phone row's `flexWrap` to `wrap`.
- **D53**: while a job runs, the approved Activity mockup's progress pill sits before
the Activity button on every page: `a#activity-pill.activity-pill`, "Scanning · 41% ·
8,240 / 19,622" (the verb by job kind, the server's floored percentage, and the file
counts once the total is known), a 70px mini bar, `href="#/activity/<id>"` and the same
text as its `aria-label`. With no running job it is `hidden`, which the shell
comparison maps as a hidden item before the button. At `max-width: 700px` it keeps the
verb and percentage only; on coarse pointers it is 40px tall like the concept's
buttons. Its rules are in `workspace.css` under D53.
- **D54**: each `.activity-item` is a real job of this session (`GET
/prompt_manager/jobs`), running first, then finished newest first. The concept's
structure and CSS stay: `.panel-heading` > `h3` "✓ <title>" for a completed job and
"○ <title>" otherwise, plus `span.chip`, then `p`, and for a running job `<progress
max=100>` ("<title> progress") and `.row-actions`. The chip is green "Completed" or
blue "Running", "Cancelled", "Failed" or "Ended". The line reads "Started HH:MM · K
output folders · closing this view does not cancel it" while running, "Finished HH:MM
· N added · M errors", "Cancelled HH:MM at a file boundary · N added · completed work
is kept", "Stopped on an error at HH:MM · N added · M errors", or "This job ended when
ComfyUI restarted." The running actions are "Cancel job" only (no "Simulate
completion"); a failed cancel toasts "The job could not be cancelled. Try again.". With
no job the section says "No jobs have run since ComfyUI started. Scans and other long
operations appear here." `#/activity/<id>` is that job's own view (D56). When a job
ends while its Cancel job has focus, focus moves to the item's title link.
The Activity store reads the list once, then follows each running job over
`/prompt_manager/jobs/<id>/events`. A dropped stream re-reads the list after 1 s,
doubling to at most 30 s; a received event resets the wait, and so does a good list
read after failed ones or after any read that was not for a dropped stream. A running
job the server no longer lists, or one whose server could not be read twice in a row,
is shown as ended, so no pill stays; one failed read (a proxy's 502) changes nothing.
A finished job the server no longer lists (it keeps 20) is dropped. Numbers are
grouped "en-US", as the server's lines are. While the tab is visible the list is read again every 15 s (and whenever
Activity opens), so a job started in another tab or on the legacy admin page appears
without a reload. Toasts: "Scan started." after this tab's own start; "Scan finished
· N added · M errors", "Scan cancelled · N added" or "Scan failed · N added · M
errors" when a job this tab saw running ends (plural helper for "error").
`tests/js/workspace-activity-store.test.js` covers the store, its backoff and the
copy; `tests/browser/workspace-activity.spec.mjs` covers the list against the
concept's own items (`renderActivity` and `startJob`), the pill and counts, a phone,
reload and a second tab, Cancel job, a restart and an unreachable server, and the
toasts, with fake jobs from `tests/browser/workspace_activity_fixture.py`
(`/__test__/jobs/*`, including a simulated restart that drops the job streams).
- **D55** (Scan for metadata, Activity-1 Task 3): Images → Import keeps Rescan as the
quick refresh (D15) and then shows the concept's own "Scan an output folder → Review
scan" row as "Scan for metadata" ("Discover and enrich existing files using the
configured sources.", button "Review scan"). It opens the concept's `scan-review`,
"Review metadata scan": no sample-plan `.demo-note` (prototype copy); Sources and
Scope in a `dl.meta-grid` (the concept's maintenance plan uses `dl`; `scan-review`
uses a `div`) reading "N output folders" (the discovered sources) and the concept's
"New and unresolved files" (the D58 checkbox switches it to "All files"); the
concept's sentence; then
a backup `.notice` (the concept's System notice box): `h3` "Back up the database first
(recommended)", "Downloads a copy of the prompts database before the scan changes any
records." and "Back up now", which downloads the existing `/prompt_manager/backup`
copy (as the legacy admin page does), reads "Backing up…" while aria-disabled, keeps
the dialog open and toasts "Database backup downloaded." or "The database could not be
backed up. Try again."; then Cancel / Start scan (primary; the concept's "Simulate
scan"). Start scan closes the dialog, starts the session scan job (`POST
/prompt_manager/jobs/scan`), toasts "Scan started." and opens `#/activity/<id>`; a
failed start toasts "The scan could not start. Try again.". The scan's progress line
now groups thousands ("Processing file 8,240 of 19,622"), as the mockup does.
`tests/browser/activity-concept.spec.mjs` compares the dialog with the concept's
`scan-review` and its box with the concept's System `.notice` (dark and light,
Chromium and touch, with a CSS and a DOM mutation check); `images-concept.spec.mjs`
maps the Import row; `workspace-images.spec.mjs` covers the dialog, the backup and
Cancel.
- **D56**: one job's view at `#/activity/<id>`, the approved mockup
(`activity-scan.html`, "100% spot on"), which the concept does not have. The page
heading names the job (h1, document title "<title> · Prompt Manager"; the breadcrumb
stays "Activity"), its line is the D54 item line, and its actions are Download report
(disabled until Activity-2) and, while the job runs, "Cancel scan" (`.danger`; "Cancel
job" for another kind; a failed request toasts the D54 message). Then `section.surface`
"Progress": `.panel-heading` (h2, `span.small` with the server's "Processing file N of
M · about X min left", or after the end "N of M files processed", "Cancelled after N
of M files", the error line, or the restart line), `<progress max=100>` ("Scan
progress") and `dl.stat-row` tiles in order: Added to Library (`.added`, green),
Already known, Needs review (`.warn`, warning), No usable metadata, Errors (`.bad`,
red). Then `section.surface` "Results": `label.search-field` "Filter by file path…"
(`aria-label` "Filter results", case-folded match on the root label and path), and
`.outcomes` with `.outcome-list` (`button[aria-pressed]` label + count, in the
mockup's order Added, Needs review, Errors, No usable metadata, Already known; the
view opens on the first with files) beside `.outcome-files`: one `.file-row` (`.path`
root-relative, `.why` the server's detail) per listed file. The detail wording is the
builder's (approved with Task 1): "Added to the Library as “title”", "Linked to
“title”", "Already linked to “title”", "Needs review: …", "Error: …", where the title
is the prompt's Library title (its stored title, else its text's first line), not the
mockup's sample "→ “title” · new positive + negative". Each log line is "HH:MM:SS
<prefix> <root label>/<path> · <detail>" rather than the mockup's "[HH:MM:SS] + path →
prompt #4812 (new)". The header's actions sit in the page heading's
`.heading-actions`, where the mockup has a `.panel-heading > .row-actions`. An
empty outcome shows "No files in this group."
or "No listed file matches this filter.", and the note "Showing N of M" when fewer
than the counter are shown (the server lists 200 per outcome), with "each row opens
the image in the viewer" for Added and Already known. Then `details.term`, collapsed:
"● Scan log — raw detail, click to show" and `pre[tabindex=0]` with the raw lines.
The summary follows the store and carries `listed` (rows per outcome) and
`log_length`; the rows and log are read from `GET /prompt_manager/jobs/<id>` once on
opening, then at most once a second only while what it lists changes (or, with the
log open, its length), so a job whose lists are capped and whose log is closed is not
re-read; opening the log reads its new lines. When the job ends while Cancel scan has
focus, focus moves to the job's heading. An unknown id, or a job the store's list no
longer has, shows "This job is not in this session’s Activity. Jobs are kept until
ComfyUI restarts." and "All activity"; a job lost with a restart keeps what was read.
At `max-width: 700px` the tiles take two columns and the outcome list stacks above
the files. Each Activity item's title is now a link to its view, in the title's
colour. The rules are in `workspace.css` under D56. `activity-concept.spec.mjs`
restates the mockup's structure and CSS values and checks them with mutations;
`workspace-activity.spec.mjs` covers the counters, live updates, viewer rows, the
filter, the 200-row cap, Cancel scan, the busy start and an unknown or lost job.
- **D57**: rows under Added and Already known are `button.file-row`s that open the
existing media viewer on that one file, by the identity Images opens it by
(`root_index:path`). Each scan row records its root's listing index (`root_index`,
the place in `_get_all_output_dirs`) beside its label. The row's source is that index
in `/prompt_manager/gallery/subfolders?mode=workspace` and must still carry the row's
label, so a reordered or replaced source never opens another root's file; the
workspace listing's new `path` filter (one exact relative path, case-folded, at most
1,024 characters, no traversal) returns that file. A row without an index, a changed
source or a file the listing no longer has toasts "This file is no longer in Images.
Rescan from Import and try again."; other rows are plain text, with no new file
route. A start refused because another record-changing job runs toasts "Another job
is running: <title>." with the title linking that job's view (the shared toast now
accepts link parts). A toast with a link stays 20 s instead of 6.5 s, and any toast
stays while the pointer or focus is on it.
- **D58** (scan scope, Activity-2 Task 4): the concept's `scan-review` states a fixed
scope, "New and unresolved files". Production keeps that value as the default and adds
the concept's own System toggle after the `dl.meta-grid`: a `.setting-row` with the
label "All files", the hint "Also read the files already linked to their prompt
again." (the switch's `aria-describedby`) and the Toolkit switch (`label.ca-switch`, a
checkbox with `role="switch"`, as the concept's `hydrateSwitches` renders it),
unchecked. Checking it switches the Scope value to "All files"; Start scan sends
`POST /prompt_manager/jobs/scan {scope: "new" | "all"}` (no body, an empty body or no
`scope` is "new"; another value is a 400).
- In "new" the scan skips, before reading any metadata, the files the Library already
links with a resolved link and that are unchanged on disk: exactly one
`generated_images` row for the file, exactly one distinct pair link on it
(`image_prompt_pairs`, written only by resolved ingestion), and a stored
`file_size` equal to the file's size in this walk. Both paths go through
`normalize_image_path` (absolute, normalized, `os.path.normcase`); a row without
`file_path` matches by its `image_path`.
- Read again: files linked without a pair (the monitor's fallback and FIFO links, the
legacy positive-only import), linked to more than one prompt or pair, not linked, a
file whose size changed (one replaced at the same path, e.g. a reused ComfyUI
counter name, is read and its conflict lands under Needs review), and a row with no
stored size (unknown is not known). The scan now stores each file's size when it
ingests it, so a file read once is skipped by the next "new" pass; the first pass
after the upgrade fills the missing sizes, which reports those files as enriched
(Added) instead of unchanged.
- The known set is one streamed query that keeps only the scanned paths. If it cannot
be read, the scan logs "Known files could not be read · reading every file" and
reads every file.
- A skipped file counts as processed and unchanged in the legacy counts (not as
found) and under Already known, with the detail "Skipped · already linked" and the
log line "= <root>/<path> · Skipped · already linked"; the log's first line names
the scope ("Scan started · N output folders · new and unresolved files" or "· all
files"). A legacy-page scan that attaches to a running "new" job therefore shows
the skipped files in its `unchanged` count.
- "All files" reads every file, as the scan did in Activity-1, and the legacy
`/prompt_manager/scan` route always does when it starts the scan (its SSE payload
keys are unchanged).
- `activity-concept.spec.mjs` compares the switch with the concept's System → Sources
toggle ("Watch for new outputs"), without its place-dependent top margin;
`workspace-activity.spec.mjs` covers the Scope value, the description and the
request both ways; `tests/test_scan_scope.py` covers the skip, the size rule, the
replaced file, the re-read of unresolved files, "all", the legacy route, the
fallback, the empty body, the 400 and Windows case folding.
- **D59** (job report and log, Activity-2 Task 5): the job view's "Download report" is
enabled. It fetches `GET /prompt_manager/jobs/<id>/report` through the api-client URL
(so a proxy mount works) and saves a JSON attachment named
`prompt-manager-<kind>-<YYYYMMDD-HHMM>.json` (server local time), then toasts "Job
report downloaded." (or "The report could not be downloaded. Try again."). The report
holds the summary, every counter, each outcome group's exact `count`, its listed
`rows` (at most 200) and a `truncated` flag, the scope and the log; rows and log lines
carry root-relative paths only, never an absolute path (an unknown id is a 404).
A job lost with a restart has no report on the server, so its Download report is
disabled; a view left while the report downloads still saves it but shows no toast.
The scan log opens at its newest line every time it is opened and follows new lines
only while the reader is at the bottom; a reader who scrolled up keeps the position
while it stays open (checked on Chromium, Firefox and WebKit).
`test_report_is_attachment_without_absolute_paths` and the browser tests `download
report saves the job JSON` and `log keeps the reader's scroll position` cover it.
- **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.
library.", and the title has no " concept" suffix. Save view's dialog action reads
"Save", not "Apply in prototype", and its toast drops the demo's word "Sample".
- **D31**: at `max-width: 950px` the overlay caps the one-column shell at
`minmax(0, 1fr)`, so extreme zoom cannot widen it to the sidebar's min-content.
- **#245 D2**: no "Auto-tag images" on the Tags heading until the image-annotation
@@ -442,7 +770,7 @@ zoom, the 2nd card opens the viewer titled "<title> · <filename>", Escape retur
to it (production only: the concept re-renders its grid and drops focus), Info on the
3rd card moves `.selected`, searches for "alpine" and for one image, and Load more with
its toast. `mapImagesConcept` applies D3, D8 (the selection bar's real actions only),
D16 (follow-up PR: no Save view), D9, D10, D14, D15, D17, D29, D32, D35 (singular counts) and D36 (categories
D9, D10, D14, D15, D17, D29, D32, D35 (singular counts) and D36 (categories
sorted alphabetically). Removed toolbar and filter-row items flow again from the
concept's own boxes (`reflow`), checked against the concept page with those items
removed at runtime. Masonry tops and heights are compared unrounded, within one layout
@@ -498,7 +826,8 @@ Recorded deviations on this page (spec §8):
footer, masonry tops may differ by sub-pixel snapping (at most one layout unit per
card above in the column), and a List row's info takes its natural height, centred
beside the 160×95 art.
- **D5**, **D29**: no Activity button and no prototype copy (shell).
- **D29**: no prototype copy (shell). The Activity button and pill are the shell's
(D52, D53).
- **D7**: the star is a real persisted favorite with "Added to favorites." /
"Removed from favorites." and a rollback toast on failure.
- **D8**: every Images card has the concept's select checkbox before the star; the
@@ -548,7 +877,9 @@ Recorded deviations on this page (spec §8):
files in M output folders." (the scan's `total` and `roots`) or "Rescan could not
finish. Try again."; while it runs, a reopened Import shows an aria-disabled
"Rescanning…".
- **D16 (follow-up)**: no Save view in PR C; a follow-up PR adds real saved views.
- **D16 (retired)**: Save view is the concept's own toolbar button, between sort and
Filters, on Images and the Prompt library (see D49). It saves the visible toolbar value
with the Refine filters; the comparisons no longer map it.
- **D17**: the caption date is the newest result's day under Recently added (", YYYY"
outside the current year), hidden under every other sort.
- **D18**: the concept hover zoom, with no sticky zoom on hover-less (touch) devices.
@@ -592,6 +923,96 @@ Recorded deviations on this page (spec §8):
- **D22**: an empty positive reads "No positive prompt (explicitly empty)" (the concept
applies its negative wording to both roles), and a confirmed `p.prompt-text` is
focusable (`tabindex=0`, marked focus ring) so long prompts stay keyboard-readable.
- **D38 Show in folder** (maintainer decision 2026-10-20, beyond the mock): in the
viewer's File & provenance the Path value stays text, followed in its `dd` by a
"Show in folder" button (`Show <filename> in folder`), only for a listed file
(`root_index`, `root_scope`, `path`), so never in the side panel or a recorded run.
It POSTs that identity as JSON to `/prompt_manager/images/reveal` and toasts:
| Result | Toast |
|---|---|
| Success | "Opened the folder on this computer." |
| 403 `not-local` | "Show in folder works only in a browser on the computer running ComfyUI." |
| 501 `unavailable` | "No file manager is available on this computer." |
| 429 `busy` | "The folder is already opening." |
| Anything else (415, 404, 409, 500, offline) | "The folder could not be opened. Try again." |
The route is local-only. It answers 403 `not-local` unless all of these hold: no
forwarding-style header (`Forwarded`, `Via`, `X-Real-IP`, `CF-Connecting-IP`,
`True-Client-IP`, `X-Client-IP`, `X-Cluster-Client-IP`, `Fastly-Client-IP`, and any
`X-Forwarded-*`, `X-Original-*` or `*-Client-IP`); a loopback `request.remote`; a
loopback `Host` (`localhost`, `*.localhost` or a loopback address, against DNS
rebinding); an `Origin` equal to the request's own scheme, host and port (absent,
`null`, another port or another scheme is refused; browsers send `Origin` on every
fetch POST); and, when sent, `Sec-Fetch-Site: same-origin`. A body that is not
`application/json` is 415 `unsupported-media-type`, so any cross-origin request
needs a CORS preflight. The body is strict (an int root, a 64-hex scope, a bounded
relative path; absolute, drive and `..` paths are 400), the scope goes through
`_gallery_scope_error` (409 `source-changed`), and the path resolves only inside
that root to a regular media file (403/404; a resolution `OSError` such as
`ENAMETOOLONG` is the same JSON 404, and the client's path is never logged at error
level). One reveal runs at a time per process with a 1 s cooldown after it
(`RevealGate`, 429 `busy`), on its own single worker (`reveal_executor`), never
ComfyUI's default executor. Just before launching, `utils/file_reveal.py` re-checks
that the real path is still a regular file inside the root (a swapped parent is
404). Windows starts no process: `ILCreateFromPathW` and
`SHOpenFolderAndSelectItems` select the file through the shell API (Explorer's own
command line splits on commas, so a path like `output\a,\Windows\...` must never
reach it), falling back to `os.startfile(folder, "explore")`. macOS runs
`/usr/bin/open -R`; Linux asks FileManager1 `ShowItems` with `dbus-send
--print-reply --reply-timeout=2000` (a real reply or a fallback) and else runs
`xdg-open` on the folder. Those openers are argv lists resolved only on a fixed
system PATH, with no shell, DEVNULL streams, `close_fds`, their own session, a
minimal environment (no venv PATH, PYTHONPATH or API keys) and a daemon-thread
reaper. `tests/test_image_reveal.py` mocks `Popen`, `sys.platform`, `shutil.which`,
the shell API and `os.startfile`; the browser server's opener is a stub
(`POST`/`GET /__test__/reveal`) with no cooldown. The inspector comparison reads
closed disclosures as hidden, so the button needs no mapping there.
Residual risk, accepted: a reverse proxy or tunnel that adds no forwarding header
(a bare nginx `proxy_pass` to 127.0.0.1, `ssh -R`, `socat`) cannot be told apart
from a local client, and a non-browser client behind it can forge a matching
`Host` and `Origin`. Such a client can open a folder of a listed file on the host's
screen, at most once a second, and learn whether that file exists (which the
listing routes already tell it). Operators who expose ComfyUI that way accept this.
- **D39** (ruled 2026-10-21: fallback): a recipe-linked image shows its recipe's own
category when set, otherwise the linked positive prompt's, and its recipe's own tags
when it has any, otherwise the positive's; each field falls back on its own and the
two tag sets are never merged. Set category (#259) writes the positive's category and
the Images category and Tags filters match the positive's, so the panel now agrees
with them whenever the recipe adds nothing. The inspection response names each
field's source (`library.category_scope`, `library.tags_scope`: `pair`, `positive` or
null); the category chip's visually hidden prefix (D21) says "Recipe category: " or
"Positive prompt category: " from it, and tag chips are Tags filter buttons only when
they are the positive's (#260 D2). History's media inspector uses the same rule. The
concept shows the prompt's category and tags for every image and the concept
fixture's recipes have neither, so the inspector comparison needs no mapping for it.
- **D42**: the concept never shows a toast and a page alert together. Production keeps
the concept's fixed toast (centred, 22px from the bottom, 12px at 700px and below)
and, when an Import → Rescan toast leaves the Images discovery alert
(`.options-status`), scrolls that alert into view only as far as needed, with
`scroll-margin-block-end: 96px`, so it is never under the toast.
`Rescan toast does not cover the discovery alert it leaves` ends the window inside
the toast's band at 1600 and 390 pixels and measures both boxes.
`tests/browser/inspector-concept.spec.mjs` compares the side panel and the viewer's
full details (#249 D4) for alpine_00001.png, at 1600x1100 and 390x844 in both themes:
structure, cover, heading, filename, badges, prompt blocks (role labels, actions,
text), tag chips, facts, footer, the viewer's disclosures and pair actions, and every
offered control's name and minimum height. Each role's copy and send are named after
the prompt, as the concept's are ("Copy positive prompt: <title>"). The concept loads
all 24 fixture images first (D37). Behaviors: copy shows "Prompt copied." (or the
same "Copy prompt" dialog in both); the pair send previews both roles in the concept
and is applied and acknowledged by a test ComfyUI tab in production; the `cinematic`
chip sets the Images Tags filter (D21: the concept searches the word, which also
matches the untagged forest prompt's text); Prompt history goes to
`#/library/<positive id>` (the concept's `#prompt/<id>`). `mapInspectorConcept`
applies D3 (the `.file-inspector` class of the production-only rules), D20 (the
viewer's plain tag chips, laid out by the concept page itself with its chips made
plain at runtime), the D20/D21 side-panel scroll (`overflow: auto`,
`max-height: calc(100dvh - 40px)` above 700px), D21 (the category's hidden scope
prefix) and D22 (focusable prompt text). `inspector comparison detects mutations`
changes `.prompt-heading .role-label` letter spacing and removes `.detail-footer`.
Manual check (quick), after a browser refresh on Images: hover an image (it zooms);
click it (the viewer reads "<title> · <filename>"; Escape closes it); press a card's
@@ -601,7 +1022,7 @@ saved file.
```sh
npm run --prefix tools/vendor test:workflows -- images-concept.spec.mjs \
workspace-images.spec.mjs full-inspector.spec.mjs \
workspace-images.spec.mjs full-inspector.spec.mjs inspector-concept.spec.mjs \
--project chromium --project touch --project firefox --project webkit
```
@@ -663,8 +1084,7 @@ Behaviors, in both documents: the title and Explore history open the prompt's hi
(its `h1` is the title), the footer send carries the positive only (a bridge host page
records the roles production's preview asks for), copy writes the full positive with
"Prompt copied.", and the "neon" search and the Landscapes category leave the same cards
and caption. `mapLibraryConcept` applies D14 (Oldest first appended), D16 (no Save
view; the toolbar flows again, checked against the concept without it), D23 (the
and caption. `mapLibraryConcept` applies D14 (Oldest first appended), D23 (the
title button's `min-width: 0`; "− 1 negative variant", measured on the concept with the
singular at runtime; the spoken "Rated N out of 5"; the two-line clamp, which the
concept's samples exceed at 390px, checked against the concept with the clamp rule
@@ -689,6 +1109,114 @@ npm run --prefix tools/vendor test:workflows -- library-concept.spec.mjs \
workspace-library.spec.mjs --project chromium --project touch --project firefox --project webkit
```
### Prompt history page and recorded deviations (#249 PR F-a)
The history page (`#/library/<id>`) takes the concept's heading, summary, negative
variants panel and comparison (app.js `renderHistory` a591–616, the dialogs a1282–1304,
style.css 1059–1179). The summary is `article.surface` with the category chip, the
positive and selected negative prompt blocks and `.card-meta` "N tracked runs · N images
· N negative variants" (tracked runs from the recorded window's `recorded_run_count`).
The panel is `section.surface.timeline-panel` (region "Saved negative variants") with
Compare negatives, + Add negative and Show archived, a `.timeline` of `.variant-lane`
rows (`--lane-offset` 38 + 47i, the "−" `.variant-dot`, "<name> · N runs" and a
`.run-dot` for a pair counted more than once) and `.timeline-lower` with the legend and
All images for this positive. Recorded executions, frozen fields, send and archived
lanes keep their contracts. `tests/browser/workspace-history.spec.mjs` and
`workspace-library-editor.spec.mjs` cover them on Chromium and touch.
Spec decisions this page applies: **D24** (Send to ComfyUI is disabled only while the
selected pair's roles load), **D25** (Compare negatives compares a baseline with the
selected red dot, and lists the selected side's added comma terms) and **D26** (All
images for this positive is a dialog with its own bounded gallery; the run dot opens
Recorded run for the pair's newest recorded execution, else a Repeat run dialog with the
pair's newest linked image, reading the archived gallery for an archived pair).
Recorded deviations on this page:
- **D45**: a variant dot is named "Select negative <title>" when no other loaded lane
shares that title (compared trimmed), else "Select negative variant N"; a title that
itself reads "variant N" always uses the lane's own number, so no two names repeat.
The bounded "N of M saved negative variants" count stays a visually hidden status,
and production's "Saved combinations, not an execution timeline." is removed.
Archived lanes (#235) are concept lanes marked `.library-variant-archived` at the .6
marker emphasis (their text stays at full contrast) with their Restore button. Under
`pointer: coarse` the variant dot keeps a 47px target and the 12px run dot a 44px one
(`::after` overlays). While a run dot shows, the lane text wraps within 139px before
it. The panel heading, the legend row and All images wrap at 390px and 200% zoom.
- **D46**: lanes list newest first (`ORDER BY pp.id DESC`), so the comparison's
baseline is the oldest (lowest-id) active variant, not the first lane; while the
variant page has more, the oldest is not loaded and the section reads "Load more
negative variants to reach the baseline." instead of guessing. The two blocks are
named "Baseline" and "Selected" (regions and copy/send actions), so no name repeats.
Compare negatives is disabled while the positive has no active variant. The page
opens on the newest lane (existing behavior), while the concept opens on
`negatives[0]`, its baseline: the first comparison reads "Baseline compared with
<newest>" where the concept compares the baseline with itself. The history concept
comparison (PR F-b, F5) maps this.
- **D47** (#249 PR F-b): the shared gallery is the concept's `section.history-gallery`
(region "Shared gallery") with `.panel-heading` (h2 "Shared gallery", `span.small`
"<name> · same positive prompt") after the variants panel (and Compare), and
recorded executions (#209) follow it, as the concept has no recorded section. The
name is the lane's own name ("<title>", else "Negative variant N"), with " · Archived"
for an archived pair, and follows a rename. Its cards are the Images media cards: the
star wherever the read returns `favorite`, no checkbox or selection bar (Images only,
#259), the image opens the Viewer. A saved variant with no images shows the concept's
`.empty` "Saved variant · no runs yet." beside the grid (the concept puts it inside
`.image-grid`, which only holds positioned cards here). The concept's "Shared gallery"
heading is the gallery's only heading and names its region: the embedded collection's
"Linked images" heading and "N loaded · N linked images" count are removed, since they
repeated it (maintainer ruling, 2026-10-21). Two production pieces stay, which the
concept's gallery does not show: the layout row (Grid/List/Masonry and Thumbnail size),
because the legacy gallery let people change layout and density and nothing else on the
page offers it, and the selected-image side panel, the one shared inspector the Images
page uses (spec §3.3), so an image's details stay reachable without opening the viewer.
The same ruling covers the page's two media dialogs, All images for this positive and
Repeat run: as in the concept (app.js a1282-1304), each is named by its own dialog
title, with no "Linked images" heading or "N loaded · N linked images" count, and a
stranded focus returns to the dialog's close button (maintainer ruling, 2026-10-21).
The Images page keeps its caption. With no saved variant the page shows every image
of the positive under "All images for this positive, including unassigned images. …"
and no empty copy (a state the concept never reaches).
- **D48** (#249 PR F-b): production surfaces the concept never shows keep their own
overlay rules, drawn in the concept's tokens: recorded executions and the Recorded run
dialog (#209, D26), the Archive negative confirmation (#235) and the acknowledged
prompt sender (spec §2.2). With `library.css` retired, these rules live in
`workspace.css` (see "Concept shell and comparison method").
The run dot reads `GET /prompt_manager/library/{positive_id}/history/pairs/{pair_id}/newest`
(see "Chronological Library history"), and each lane's "N runs" is the pair's
`run_count` (see "Workspace data fields").
`tests/browser/history-concept.spec.mjs` (#249 PR F-b, F5) compares this page with the
immutable concept's `#prompt/alpine` on the concept fixture, at 1600×1100 and 390×844 in
both themes and motion settings, on Chromium and touch: the heading, the summary, the
variants panel, the comparison (Compare negatives pressed) and the shared gallery's
place and heading, all measured by `inventoryOf`'s `history` section with the concept's
selectors in both documents, with "Clean architecture" selected (the middle lane in
either order). The fixture links and records its images oldest first, so ids grow with
time as real runs do, and counts one pair use per image, which gives the concept's
"Quality baseline · 2 runs" and its run dot. `mapHistoryConcept` applies D24 (the four
recorded runs give the concept's own "4 tracked runs", asserted on both), D45 (the
visually hidden "3 of 3 saved negative variants" status, and its wrap rules applied to
the concept at runtime), D35 and D46 (the concept's lanes reordered newest first with
"1 run", laid out by the browser), D46's Baseline/Selected action names, #235's Show
archived button (added to the concept at runtime) and D47 (the gallery's body is the
Images collection, so its height is not compared). D25: each compared block mounts in a
`display: contents` child of the concept's `.compare-item`, so the item carries no
production class or margin. Behaviors are compared in both documents: a red dot changes
the negative block and the gallery, Compare toggles `aria-pressed`, All images opens a
dialog with the positive's four images, the run dot opens a dialog (D26: Recorded run
where the concept shows Repeat run), neither dialog has a collection caption (D47), the
heading send previews the selected pair, and
← Prompt library navigates. `history comparison detects mutations` injects
`.ca-tools .variant-dot { width: 20px }` and removes Compare negatives. The older
`history-canonical-comparison.mjs` stays for `workspace-visual.spec.mjs`'s recorded
executions (#209) checks; its production selectors are now the concept's.
```sh
npm run --prefix tools/vendor test:workflows -- history-concept.spec.mjs \
workspace-history.spec.mjs --project chromium --project touch --project firefox --project webkit
```
## Workspace data fields for the concept pages (#249)
These fields are additive. The legacy `/prompt_manager/images/output` listing (no
@@ -787,7 +1315,9 @@ null. The edit token ignores both. `variant_total` has two meanings. On the list
response it counts the active variants over all filtered parents (the same
snapshot as `total`, the same on every cursor page). On the detail response it
counts one positive's variants in the chosen archive scope. The Library caption
reads it from the list state.
reads it from the list state. Each detail variant (active and archived) also carries
its pair's `run_count` (`prompt_pairs.run_count`, 0 when null), which the history lanes
show as "N runs" (#249 F2).
Favorites store (D7 = A): `PUT` and `DELETE /prompt_manager/images/favorite` with
`{root_index, path}`. Both are idempotent and answer
@@ -1012,10 +1542,20 @@ The versioned public reads are:
| Route | Meaning |
| --- | --- |
| `GET /prompt_manager/library/{positive_id}/history` | Exact frozen raw-positive group; compact chronological windows |
| `GET /prompt_manager/library/{positive_id}/history/pairs/{pair_id}/newest` | The pair's newest recorded run for the history run dot (#249 D26): `{run: {run_id, anchor_id, context_id}}` or `{run: null}` |
| `GET /prompt_manager/history/runs/{run_id}` | Frozen fields, per-field provenance, capture and outcome for a known UUID |
| `GET /prompt_manager/history/runs/{run_id}/outputs` | Ordered host-reported occurrences, including repeated locations |
| `GET /prompt_manager/history/runs/{run_id}/output/current/{media,thumbnail,inspection}` | Authorized current file at that reported location, using the returned epoch/scope URL |
The newest-pair read takes no query. It answers 404 when the positive is not an
eligible saved prompt or the pair is not one of its pairs. It walks the positive's exact
raw-text scope newest first and follows only a row whose `reference_epoch` is the
current epoch and whose saved-pair hint still resolves to that pair (the
`get_history_run` hint rule). It inspects at most 16 candidates, so 16 newer stale hints
report `{run: null}` (the dot then opens Repeat run). Like the other reads it runs inside
the fenced history snapshot: a restore or epoch change is 409 `history-changed`, a
storage failure 503 `storage-unavailable`.
Run and output reads accept the returned `epoch`; current-file routes require
the returned epoch, current scope and occurrence coordinates. Run integers are
decimal strings, preserving SQLite's full signed 64-bit values. Windows and
@@ -1074,7 +1614,7 @@ bytes at the same path without changing the immutable receipt.
The focused acceptance command retains all four projects:
```sh
PM_TEST_PORT=8766 npm run --prefix tools/vendor test:workflows -- \
npm run --prefix tools/vendor test:workflows -- \
workspace-history.spec.mjs workspace-visual.spec.mjs \
--project chromium --project touch --project firefox --project webkit
npm run --prefix tools/vendor test:executions
@@ -1108,8 +1648,10 @@ Initial production acceptance requires maintainer inspection of paired PNGs and
geometry; never update snapshots automatically or mask content to bless a gap.
Data-dependent text/images/counts and the clearly labeled saved/frozen/current
distinctions are documented in the comparison helper. The Images card tag
button (#249 D9) and shared role-block
spacing/padding/glyph polish remain outside #209. The
button (#249 D9) remains outside #209; every prompt block now renders the concept's
`section.prompt-section` (D22), so the shared role-block spacing, padding and glyph
gap is closed, and only the production-only surfaces above keep their own rules
(D48). The
responsive production Viewer keeps its toolbar in the media panel and prompt details readable rather than reproducing the concept's
mobile toolbar collision. These are explicit remaining visual differences, not
a whole-application or full-gallery/Viewer pixel-parity claim.
@@ -1165,7 +1707,7 @@ acceptance; a baseline is added only after the maintainer accepts them.
Focused commands:
```sh
PM_TEST_PORT=8766 npm run --prefix tools/vendor test:workflows -- \
npm run --prefix tools/vendor test:workflows -- \
workspace-library-editor.spec.mjs library-editor-visual.spec.mjs \
--project chromium --project touch --project firefox --project webkit
npm run --prefix tools/vendor test:comfyui # library-sender.spec.mjs host regression
@@ -1265,7 +1807,7 @@ mobile, the concept geometry/token/control comparison and its mutation check),
```sh
node --test tests/js/workspace-vocabulary-state.test.js tests/js/workspace-state.test.js
PM_TEST_PORT=8766 npm run --prefix tools/vendor test:workflows -- \
npm run --prefix tools/vendor test:workflows -- \
workspace-vocabulary.spec.mjs vocabulary-visual.spec.mjs focused-node.spec.mjs \
--project chromium --project touch --project firefox --project webkit
```
+24 -11
View File
@@ -4,9 +4,17 @@ import os
import sqlite3
try:
from ..utils.inspection_response import MAX_LIBRARY_BYTES, _library_projection
from ..utils.inspection_response import (
MAX_LIBRARY_BYTES,
_library_projection,
library_metadata,
)
except ImportError:
from utils.inspection_response import MAX_LIBRARY_BYTES, _library_projection
from utils.inspection_response import (
MAX_LIBRARY_BYTES,
_library_projection,
library_metadata,
)
def _display_bytes(value: bytes | None) -> str | None:
@@ -52,15 +60,21 @@ def _saved_projection(conn: sqlite3.Connection, row: sqlite3.Row) -> tuple[dict,
return saved, _library_projection({"status": "limited"})
pair = pairs[0] if pairs else None
title = pair["title"] if pair is not None else None
category = pair["category"] if pair is not None else row["category"]
tags, limited = _tags(
conn,
pair["id"] if pair is not None else row["prompt_id"],
pair=pair is not None,
recipe_tags, limited = (
_tags(conn, pair["id"], pair=True) if pair is not None else ([], False)
)
positive_tags = []
if not recipe_tags and not limited:
# D39: only read the positive's tags when they are what the panel shows.
positive_tags, limited = _tags(conn, row["prompt_id"], pair=False)
metadata = library_metadata(
row["category"],
positive_tags,
(pair["category"], recipe_tags) if pair is not None else None,
)
limited = limited or any(
value is not None and len(value) > MAX_LIBRARY_BYTES
for value in (title, category)
for value in (title, metadata["category"])
)
if pair is not None:
saved["pair"] = {
@@ -73,9 +87,8 @@ def _saved_projection(conn: sqlite3.Connection, row: sqlite3.Row) -> tuple[dict,
"positive_id": row["prompt_id"],
"pair_id": pair["id"] if pair is not None else None,
"title": _display_bytes(title),
"category": _display_bytes(category),
"tags": tags,
"metadata_scope": "pair" if pair is not None else "positive",
**metadata,
"category": _display_bytes(metadata["category"]),
}
)
if limited:
+59 -1
View File
@@ -10,7 +10,7 @@ import re
import sqlite3
from typing import Iterator
from .execution_contexts import content_digest
from .execution_contexts import _hints, _values, content_digest
from .execution_history import HistoryEpochChanged
from .execution_outputs import _capture, _REFERENCE_COLUMNS
from .library import _ELIGIBLE
@@ -98,6 +98,8 @@ _ORDER = (
"r.observed_dequeue_at DESC,r.session_id COLLATE BINARY DESC,"
"r.queue_item_id DESC,r.run_id COLLATE BINARY DESC"
)
# Stale-hint rows a newest-pair lookup inspects before it reports none.
_PAIR_RUN_CANDIDATES = 16
_MATCHING = (
"SELECT DISTINCT rc.run_id FROM execution_prompt_snapshots s "
"JOIN execution_contexts c ON c.positive_snapshot_id=s.snapshot_id "
@@ -410,6 +412,62 @@ class HistoryReadOperations:
result["context_summaries"] = list(ordered.values())
return result
def get_newest_pair_run(self, positive_id: int, pair_id: int) -> dict | None:
"""The newest matching run whose current saved-pair hint names `pair_id`.
None when the positive is not an eligible saved prompt or the pair is
not one of its pairs; ``{"run": None}`` when no recorded run names it.
Only a hint captured in this epoch and still resolving to the same pair
counts, as in ``get_history_run``.
"""
conn = self._history_connection()
saved = conn.execute(
"SELECT CASE WHEN typeof(p.text)='text' AND "
"length(CAST(p.text AS BLOB))<=65536 THEN p.text ELSE NULL END AS text "
f"FROM prompts p WHERE p.id=? AND ({_ELIGIBLE})",
(positive_id,),
).fetchone()
if saved is None or (
conn.execute(
"SELECT 1 FROM prompt_pairs WHERE id=? AND positive_prompt_id=?",
(pair_id, positive_id),
).fetchone()
is None
):
return None
if saved["text"] is None:
return {"run": None}
epoch = self.get_execution_epoch()
candidates = conn.execute(
"SELECT r.run_id,rc.anchor_id,rc.context_id,"
"c.positive_snapshot_id,c.negative_snapshot_id "
"FROM execution_runs r JOIN execution_run_contexts rc "
"ON rc.run_id=r.run_id JOIN execution_contexts c "
"ON c.context_id=rc.context_id JOIN execution_prompt_snapshots s "
"ON s.snapshot_id=c.positive_snapshot_id "
"WHERE s.raw_text_id=? AND rc.pair_id=? AND rc.reference_epoch=? "
f"ORDER BY {_ORDER},rc.anchor_id COLLATE BINARY LIMIT ?",
(
content_digest("text", (saved["text"],)),
pair_id,
epoch,
_PAIR_RUN_CANDIDATES,
),
).fetchall()
for row in candidates:
positive = _values(conn, row["positive_snapshot_id"])
negative = _values(conn, row["negative_snapshot_id"])
if positive is None or positive["raw"] != saved["text"]:
continue
hints = _hints(conn, positive["raw"], negative["raw"] if negative else None)
if hints[2] == pair_id:
return {
"run": {
key: row[key] for key in ("run_id", "anchor_id", "context_id")
}
}
return {"run": None}
def get_history_run(self, run_id: str) -> dict | None:
conn = self._history_connection()
run = self.get_execution_run(run_id)
+13
View File
@@ -137,3 +137,16 @@ class ImageFavoriteOperations:
)
found.update(path for (path,) in rows)
return found
def get_root_favorites(self, root_key: str) -> set[str]:
"""Every relative key favorited under *root_key* (a primary-key range).
The Favorites listing (``favorites=1``) intersects these with the
root's scan, so its size follows the root's favorites, not its files.
"""
conn = self.model.get_connection()
rows = conn.execute(
"SELECT relative_path FROM image_favorites WHERE root_key=?",
(root_key,),
)
return {path for (path,) in rows}
+3 -1
View File
@@ -388,7 +388,7 @@ class LibraryOperations:
for row in conn.execute(
"SELECT pp.id,COALESCE(n.text,'') AS negative_text,"
"pp.category,pp.title,pp.archived,"
"pp.negative_prompt_id IS NULL AS explicit_empty,"
"pp.negative_prompt_id IS NULL AS explicit_empty,pp.run_count,"
"(SELECT COUNT(DISTINCT image_id) FROM image_prompt_pairs ip "
"WHERE ip.pair_id=pp.id) AS image_count "
"FROM prompt_pairs pp "
@@ -404,6 +404,8 @@ class LibraryOperations:
row["tags"] = []
row["archived"] = bool(row["archived"])
row["explicit_empty"] = bool(row["explicit_empty"])
# The history lane's "N runs" (#249): queue-hook pair uses, 0 when unset.
row["run_count"] = row["run_count"] if type(row["run_count"]) is int else 0
if by_id:
marks = ",".join("?" for _ in by_id)
for row in conn.execute(
+10 -2
View File
@@ -17,6 +17,7 @@ from typing import Optional, Set, Tuple
from .library_schema import library_schema_error, migrate_library_state
from .image_favorites import image_favorites_schema_error, migrate_image_favorites
from .saved_views import migrate_saved_views, saved_views_schema_error
from .execution_history_schema import (
history_schema_error,
migrate_execution_history,
@@ -300,6 +301,7 @@ class PromptModel:
migrate_execution_history(conn)
migrate_library_state(conn)
migrate_image_favorites(conn)
migrate_saved_views(conn)
def _migrate_prompt_pairs(self, conn: sqlite3.Connection) -> None:
conn.execute("SAVEPOINT prompt_pair_schema")
@@ -1019,8 +1021,9 @@ class PromptModel:
Args:
path: File to inspect
require_library_state: Also require the tables every current
database has (Library state and image favorites), as a
migrated restore stage must; old backups may lack them.
database has (Library state, image favorites and saved
views), as a migrated restore stage must; old backups may
lack them.
Returns:
(ok, reason): reason is "ok" when the file is acceptable, otherwise
@@ -1080,6 +1083,11 @@ class PromptModel:
)
if favorites_error:
return False, favorites_error
views_error = saved_views_schema_error(
conn, allow_missing=not require_library_state
)
if views_error:
return False, views_error
return True, "ok"
except sqlite3.Error as e:
return False, f"Invalid SQLite database: {e}"
+63
View File
@@ -12,6 +12,7 @@ from .models import PromptModel, normalize_image_path, utc_now_iso
from .pairs import PairDependencyError, PairOperations
from .library import LibraryOperations, titled
from .image_favorites import ImageFavoriteOperations
from .saved_views import SavedViewOperations
from .library_commands import LibraryCommandOperations
from .vocabulary import VocabularyOperations
from .execution_history import ExecutionHistoryOperations
@@ -180,6 +181,21 @@ AND p.id NOT IN (SELECT prompt_id FROM live_hint_prompts)
"""
# The scan's known set (#249 Activity-2): every image row, with its stored file
# size and whether its pair link is resolved: exactly one distinct pair, the rule
# _LINK_SUMMARY_SQL and get_image_prompt_info use. A file is known and resolved
# when it has exactly one image row, that row is resolved, and the row's stored
# size equals the file's size now; several rows (one file linked to more than
# one prompt) are ambiguous, and an unknown (NULL) stored size is not known. The
# rows are streamed in fetches of KNOWN_PATHS_FETCH_SIZE and only the scanned
# paths are kept, so memory follows the scan (at most MAX_SCAN_FILES paths), not
# the table.
_KNOWN_PATHS_SQL = """SELECT gi.file_path, gi.image_path, gi.file_size,
(SELECT COUNT(DISTINCT ip.pair_id) FROM image_prompt_pairs ip
WHERE ip.image_id = gi.id) = 1
FROM generated_images gi"""
KNOWN_PATHS_FETCH_SIZE = 1000
# One SELECT reads the link summaries of at most this many paths: three bound
# parameters per path stays under SQLite's historical 999-variable limit.
LINK_SUMMARY_BATCH = 200
@@ -306,6 +322,7 @@ class PromptDatabase(
ExecutionOutputOperations,
HistoryReadOperations,
ImageFavoriteOperations,
SavedViewOperations,
):
"""Database operations class for managing prompts."""
@@ -2278,6 +2295,52 @@ class PromptDatabase(
"parameters": row[8] if len(row) > 8 else None,
}
def get_known_resolved_paths(self, files: Dict[str, Optional[int]]) -> Set[str]:
"""The paths in ``files`` the Library already links with a resolved link.
``files`` maps each scanned path to its size on disk (None when it could
not be read). A path qualifies when exactly one ``generated_images`` row
claims the file, that row has exactly one pair link
(``image_prompt_pairs``, written only by resolved ingestion), and its
stored ``file_size`` is not NULL and equals the given size, so a file
replaced at the same path is read again. Files linked without a pair
(the monitor's fallback and FIFO links, the legacy positive-only
import), linked to several prompts or pairs, or not linked at all do
not qualify. Both sides go through ``normalize_image_path`` (absolute,
normalized, ``os.path.normcase``), so a Windows row stored in another
case or spelling still matches. One query, streamed; returns the
matching keys of ``files``.
"""
wanted: Dict[str, List[Tuple[str, Optional[int]]]] = {}
for path, size in files.items():
key = normalize_image_path(str(path))
wanted.setdefault(key, []).append((str(path), size))
if not wanted:
return set()
rows_seen: Dict[str, int] = {}
stored_sizes: Dict[str, int] = {}
with self.model.get_connection() as conn:
cursor = conn.execute(_KNOWN_PATHS_SQL)
while True:
rows = cursor.fetchmany(KNOWN_PATHS_FETCH_SIZE)
if not rows:
break
for file_path, image_path, file_size, is_resolved in rows:
# image_path is NOT NULL; file_path is NULL only on old rows.
key = normalize_image_path(file_path or image_path)
if key not in wanted:
continue
rows_seen[key] = rows_seen.get(key, 0) + 1
if is_resolved and file_size is not None:
stored_sizes[key] = file_size
return {
path
for key, stored_size in stored_sizes.items()
if rows_seen[key] == 1
for path, size in wanted[key]
if size is not None and size == stored_size
}
def get_image_link_summaries(
self, paths: List[str], allow_filename_fallback: bool = True
) -> Dict[str, Dict[str, Any]]:
+334
View File
@@ -0,0 +1,334 @@
"""Persisted saved views (D16): a named Images or Library toolbar state.
A view's state holds exactly its page's toolbar values and its Refine-this-view
refinements, whitelisted per page and stored as compact JSON of at most 4 KB.
Writes (the API's POST and PATCH) validate strictly. Startup and restore check
only the structure (the table, its columns and each row's types, sizes and JSON
object), never the value rules: a row saved under older rules must not stop the
database from opening. Startup is more tolerant still: a row whose state is not
a JSON object is logged by id, left in place and skipped by every read, while
restore rejects such a backup. Reads normalize instead, so a stale row opens with its
usable values and defaults for the rest. Optional in old backups, required once
a database is current (see ``library_schema.py``).
"""
import json
import logging
import re
import sqlite3
from typing import Optional
# The models logger's parent ("prompt_manager.database.models" logs there too).
_LOGGER = logging.getLogger("prompt_manager.database.saved_views")
TABLE = "saved_views"
PAGES = ("images", "library")
# The schema check's literal SQL repeats the name and state caps (names in
# bytes, at most 4 per character); keep them in step.
MAX_NAME_LENGTH = 100
MAX_STATE_BYTES = 4096
MAX_TAGS = 20
MAX_TAG_LENGTH = 50
_IMAGE_SORTS = ("newest", "oldest", "name", "name-desc", "rating")
_LIBRARY_SORTS = ("newest", "oldest", "name", "rating")
_MEDIA = ("all", "image", "video")
_CONTROL = re.compile(r"[\x00-\x1f\x7f]")
_SOURCE = re.compile(r"all|0|[1-9][0-9]{0,8}")
FALLBACK_NAME = "Saved view"
class SavedViewInvalid(ValueError):
"""A request's page, name or state breaks the write rules (a 400)."""
# (kind, limit-or-choices, default) per state key; "text" values are
# length-capped. The defaults match the client's (saved-views.mjs FIELDS).
_STATE_FIELDS = {
"images": {
"q": ("text", 500, ""),
"category": ("text", 100, ""),
"sort": ("choice", _IMAGE_SORTS, "newest"),
"source": ("source", None, "all"),
"folder": ("text", 1024, ""),
"media": ("choice", _MEDIA, "all"),
"tags": ("tags", None, ()),
# The Favorites page's filter (#249 D51): a view saved there reopens it.
"favorites": ("bool", None, False),
},
"library": {
"q": ("text", 500, ""),
"category": ("text", 100, ""),
"sort": ("choice", _LIBRARY_SORTS, "newest"),
"tags": ("tags", None, ()),
},
}
_DDL = (
f"CREATE TABLE {TABLE} (id INTEGER PRIMARY KEY AUTOINCREMENT, "
"page TEXT NOT NULL CHECK(page IN ('images','library')), "
"name TEXT NOT NULL, state TEXT NOT NULL, position INTEGER NOT NULL, "
"created_at TEXT NOT NULL, updated_at TEXT NOT NULL)"
)
_COLUMNS = (
("id", "INTEGER", 0, 1),
("page", "TEXT", 1, 0),
("name", "TEXT", 1, 0),
("state", "TEXT", 1, 0),
("position", "INTEGER", 1, 0),
("created_at", "TEXT", 1, 0),
("updated_at", "TEXT", 1, 0),
)
_SELECT = "SELECT id, page, name, state FROM saved_views"
def _compact(sql: str) -> str:
return re.sub(r"\s+", "", sql).lower()
def validate_name(name: object) -> str:
"""The trimmed name, 1 to 100 characters without control characters."""
if not isinstance(name, str):
raise SavedViewInvalid("Name must be a string")
trimmed = name.strip()
if not 1 <= len(trimmed) <= MAX_NAME_LENGTH:
raise SavedViewInvalid(f"Name must be 1 to {MAX_NAME_LENGTH} characters")
if _CONTROL.search(trimmed):
raise SavedViewInvalid("Name contains control characters")
return trimmed
def _validate_value(key: str, kind: str, limit, value: object) -> None:
"""Raise ``SavedViewInvalid`` when *value* breaks *key*'s write rule."""
if kind == "tags":
if not isinstance(value, list) or len(value) > MAX_TAGS:
raise SavedViewInvalid(
f"State '{key}' must be a list of at most {MAX_TAGS}"
)
for tag in value:
if (
not isinstance(tag, str)
or not tag.strip()
or len(tag) > MAX_TAG_LENGTH
or "," in tag
or _CONTROL.search(tag)
):
raise SavedViewInvalid(f"State '{key}' holds an invalid tag")
return
if kind == "bool":
if not isinstance(value, bool):
raise SavedViewInvalid(f"State '{key}' must be true or false")
return
if not isinstance(value, str):
raise SavedViewInvalid(f"State '{key}' must be a string")
if kind == "choice" and value not in limit:
raise SavedViewInvalid(f"State '{key}' is not a supported value")
if kind == "source" and not _SOURCE.fullmatch(value):
raise SavedViewInvalid(f"State '{key}' must be 'all' or a source index")
if kind == "text" and (len(value) > limit or _CONTROL.search(value)):
raise SavedViewInvalid(f"State '{key}' is too long or has control characters")
def encode_state(page: object, state: object) -> str:
"""Validate *state* for *page* and return its compact JSON (at most 4 KB)."""
if page not in PAGES:
raise SavedViewInvalid("Page must be 'images' or 'library'")
fields = _STATE_FIELDS[page]
if not isinstance(state, dict):
raise SavedViewInvalid("State must be an object")
if set(state) != set(fields):
raise SavedViewInvalid(
f"State for {page} must contain exactly " + ", ".join(sorted(fields))
)
for key, (kind, limit, _default) in fields.items():
_validate_value(key, kind, limit, state[key])
encoded = json.dumps(
{key: state[key] for key in fields}, ensure_ascii=False, separators=(",", ":")
)
if len(encoded.encode("utf-8")) > MAX_STATE_BYTES:
raise SavedViewInvalid("State must encode to at most 4 KB")
return encoded
def _decode_object(raw: object) -> Optional[dict]:
"""The JSON object stored in *raw*, or None when it holds no object."""
if isinstance(raw, dict):
return raw
if not isinstance(raw, str):
return None
try:
decoded = json.loads(raw)
except (ValueError, RecursionError):
return None
return decoded if isinstance(decoded, dict) else None
def normalize_state(page: str, raw: object) -> dict:
"""The page's full state from a stored row, never raising.
Every key of the page's current whitelist is present: a stored value that
passes today's write rule is kept, anything else (missing, invalid, or a
row that is not a JSON object) takes the key's default, and keys the
whitelist no longer has are dropped. An unknown page has no keys.
"""
fields = _STATE_FIELDS.get(page, {})
stored = _decode_object(raw) or {}
state = {}
for key, (kind, limit, default) in fields.items():
value = stored.get(key, default)
try:
_validate_value(key, kind, limit, value)
except SavedViewInvalid:
value = default
state[key] = list(value) if kind == "tags" else value
return state
def normalize_name(name: object) -> str:
"""A stored name made displayable: trimmed, capped, never empty."""
text = _CONTROL.sub("", name) if isinstance(name, str) else ""
trimmed = text.strip()[:MAX_NAME_LENGTH].strip()
return trimmed or FALLBACK_NAME
def saved_views_schema_error(
conn: sqlite3.Connection,
*,
allow_missing: bool = True,
check_states: bool = True,
) -> Optional[str]:
"""Reject unsupported saved-views schemas and structurally invalid rows.
Reads only. Checks the table, its columns, each row's types and sizes and
that each state is a JSON object; never the value rules, which change
between versions and are normalized on read instead. A missing table is
acceptable for backups that predate it unless ``allow_missing`` is false.
``check_states=False`` (live startup) skips the JSON-object check; such
rows are reported by ``unusable_view_ids`` and skipped on read.
"""
objects = conn.execute(
"SELECT type,name,sql FROM sqlite_master WHERE name=? "
"OR (type IN ('index','trigger') AND tbl_name=?)",
(TABLE, TABLE),
).fetchall()
if not objects:
return None if allow_missing else "Missing saved views schema"
tables = [sql for kind, _name, sql in objects if kind == "table"]
if len(tables) != 1 or len(objects) != 1:
return "Saved views name is reserved by an unsupported schema object"
if _compact(tables[0] or "") != _compact(_DDL):
return "Saved views has an invalid table definition"
columns = tuple(
(row[1], row[2].upper(), row[3], row[5])
for row in conn.execute(f"PRAGMA table_info({TABLE})")
)
if columns != _COLUMNS:
return "Saved views has invalid columns"
# Literal SQL: the table name and limits are constants of this module.
if conn.execute(
"SELECT 1 FROM saved_views WHERE typeof(page) != 'text' "
"OR page NOT IN ('images','library') "
"OR typeof(name) != 'text' OR typeof(state) != 'text' "
"OR typeof(position) != 'integer' OR typeof(created_at) != 'text' "
"OR typeof(updated_at) != 'text' "
"OR length(CAST(name AS BLOB)) > 400 "
"OR length(CAST(state AS BLOB)) > 4096 "
"OR length(CAST(created_at AS BLOB)) > 64 "
"OR length(CAST(updated_at AS BLOB)) > 64 LIMIT 1"
).fetchone():
return "Saved views contains invalid values"
if check_states and unusable_view_ids(conn):
return "Saved views contains invalid values"
return None
def unusable_view_ids(conn: sqlite3.Connection) -> list[int]:
"""Ids of rows whose state is not a JSON object (never listed)."""
return [
view_id
for view_id, state in conn.execute("SELECT id, state FROM saved_views")
if _decode_object(state) is None
]
def migrate_saved_views(conn: sqlite3.Connection) -> None:
"""Install the table atomically; validate before and after creating it."""
conn.execute("SAVEPOINT saved_views_schema")
try:
error = saved_views_schema_error(conn, check_states=False)
if error:
raise sqlite3.DatabaseError(error)
conn.execute(_DDL.replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS", 1))
error = saved_views_schema_error(conn, allow_missing=False, check_states=False)
if error:
raise sqlite3.DatabaseError(error)
# A corrupt state never stops the live database from opening: the row
# stays (no data is destroyed) and reads skip it. Only its id is logged.
for view_id in unusable_view_ids(conn):
_LOGGER.warning(
"Saved view id %d has an unreadable state and is hidden", view_id
)
conn.execute("RELEASE saved_views_schema")
except BaseException:
conn.execute("ROLLBACK TO saved_views_schema")
conn.execute("RELEASE saved_views_schema")
raise
def _view(row) -> dict:
view_id, page, name, state = row
return {
"id": view_id,
"page": page,
"name": normalize_name(name),
"state": normalize_state(page, state),
}
class SavedViewOperations:
"""PromptDatabase mixin: list, create, rename and delete saved views."""
def list_saved_views(self) -> list[dict]:
"""Every view, in list order (position, then creation)."""
rows = self.model.get_connection().execute(_SELECT + " ORDER BY position, id")
# A row whose state is no JSON object (logged at startup) is never listed.
return [_view(row) for row in rows if _decode_object(row[3]) is not None]
def create_saved_view(self, page: object, name: object, state: object) -> dict:
"""Validate and append one view at the end of the list."""
from .models import utc_now_iso # models imports this module's schema
encoded = encode_state(page, state)
trimmed = validate_name(name)
now = utc_now_iso()
with self.model.get_connection() as conn:
cursor = conn.execute(
"INSERT INTO saved_views (page, name, state, position, "
"created_at, updated_at) SELECT ?, ?, ?, "
"COALESCE(MAX(position), -1) + 1, ?, ? FROM saved_views",
(page, trimmed, encoded, now, now),
)
row = conn.execute(
_SELECT + " WHERE id = ?", (cursor.lastrowid,)
).fetchone()
return _view(row)
def rename_saved_view(self, view_id: int, name: object) -> Optional[dict]:
"""The renamed view, or None when no view has *view_id*."""
from .models import utc_now_iso
trimmed = validate_name(name)
with self.model.get_connection() as conn:
conn.execute(
"UPDATE saved_views SET name = ?, updated_at = ? WHERE id = ?",
(trimmed, utc_now_iso(), view_id),
)
row = conn.execute(_SELECT + " WHERE id = ?", (view_id,)).fetchone()
# A hidden (unreadable) view is not found, as in the listing.
return _view(row) if row and _decode_object(row[3]) is not None else None
def delete_saved_view(self, view_id: int) -> bool:
"""True when a view was removed."""
with self.model.get_connection() as conn:
cursor = conn.execute("DELETE FROM saved_views WHERE id = ?", (view_id,))
return cursor.rowcount > 0
+13 -2
View File
@@ -6,6 +6,7 @@ Split into domain-specific mixins for maintainability:
- AdminRoutesMixin: duplicates, stats, settings, diagnostics, maintenance, backup, scan
- LoggingRoutesMixin: log management endpoints
- AutotagRoutesMixin: auto-tagging model management and tagging
- JobRoutesMixin: the session job registry behind Activity (py/api/jobs.py)
"""
import asyncio
@@ -25,9 +26,12 @@ from .prompts import PromptRoutesMixin
from .pairs import PairRoutesMixin
from .library import LibraryRoutesMixin
from .vocabulary import VocabularyRoutesMixin
from .saved_views import SavedViewRoutesMixin
from .history import HistoryRoutesMixin
from .images import ImageRoutesMixin
from .admin import AdminRoutesMixin
from .job_routes import JobRoutesMixin
from .jobs import JobRegistry
from .logging_routes import LoggingRoutesMixin
from .autotag_routes import AutotagRoutesMixin
from .lora_integration import LoraIntegrationMixin
@@ -248,10 +252,12 @@ class PromptManagerAPI(
PairRoutesMixin,
LibraryRoutesMixin,
VocabularyRoutesMixin,
SavedViewRoutesMixin,
HistoryRoutesMixin,
PromptRoutesMixin,
ImageRoutesMixin,
AdminRoutesMixin,
JobRoutesMixin,
LoggingRoutesMixin,
AutotagRoutesMixin,
LoraIntegrationMixin,
@@ -297,9 +303,12 @@ class PromptManagerAPI(
# _acquire_long_job(). Created lazily so no lock binds to an event
# loop before the server's loop exists.
self._long_jobs: dict = {}
# The current output scan (py/api/scan_job.ScanJob), if one was started.
# This session's jobs (py/api/jobs.py): the metadata scan, and later
# every other long operation the Activity page tracks.
self._jobs = JobRegistry(self.logger)
# The latest metadata scan job (also in self._jobs), if one was started.
self._scan_job = None
# The current duplicate scan (also a ScanJob), if one was started.
# The current duplicate scan (a py/api/jobs.Job outside the registry).
self._duplicate_job = None
# Run cleanup on initialization to remove any existing duplicates
@@ -432,10 +441,12 @@ class PromptManagerAPI(
self._register_pair_routes(routes)
self._register_library_routes(routes)
self._register_vocabulary_routes(routes)
self._register_saved_view_routes(routes)
self._register_history_routes(routes)
self._register_prompt_routes(routes)
self._register_image_routes(routes)
self._register_admin_routes(routes)
self._register_job_routes(routes)
self._register_logging_routes(routes)
self._register_autotag_routes(routes)
self._register_lora_routes(routes)
+305 -53
View File
@@ -6,16 +6,25 @@ from contextlib import closing
import datetime
import hashlib
import json
import math
import os
import sqlite3
import tempfile
import threading
import time
from pathlib import Path
from aiohttp import web
from .prompts import thumbnail_url_for
from .scan_job import ScanJob
from .jobs import (
DEFAULT_SCAN_SCOPE,
SCAN_GROUP_NAMES,
SCAN_GROUPS,
SCAN_SCOPES,
Job,
JobBusy,
)
try:
from ...utils.parallel import map_parallel
@@ -47,8 +56,10 @@ except ImportError:
try:
from ...database.library import display_title
from ...utils.hashing import generate_prompt_hash
except ImportError:
from database.library import display_title
from utils.hashing import generate_prompt_hash
IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff")
@@ -138,6 +149,127 @@ def _sse(payload):
return f"data: {json.dumps(payload)}\n\n"
SCAN_JOB_KIND = "scan"
SCAN_JOB_TITLE = "Scan for metadata"
# The legacy final event type of a scan and the job state it ends in.
SCAN_FINAL_STATES = {
"complete": "finished",
"cancelled": "cancelled",
"error": "failed",
}
# The scope as the scan log's first line names it (the review dialog's words).
SCAN_SCOPE_LOG = {"new": "new and unresolved files", "all": "all files"}
MIN_BATCHES_FOR_ESTIMATE = 2 # "about X min left" waits for this many batches
# Log line prefix per outcome group (spec: + added, = known, ? review, ! error,
# - no usable metadata).
SCAN_LOG_PREFIXES = {
"added": "+",
"known": "=",
"review": "?",
"errors": "!",
"no_metadata": "-",
}
# What an outcome row says about a file: where it went (with the prompt's
# title) or why it was skipped. "error" is a file whose ingestion raised;
# None is a file without prompt metadata.
SCAN_TITLED_DETAILS = {
"added": "Added to the Library as “{title}”",
"linked": "Linked to “{title}”",
"enriched": "Metadata added to “{title}”",
"unchanged": "Already linked to “{title}”",
}
SCAN_DETAILS = {
"added": "Added to the Library",
"linked": "Linked to an existing prompt",
"enriched": "Metadata added to its prompt",
"unchanged": "Already linked to its prompt",
"skipped": "Skipped · already linked",
"ambiguous": "Needs review: more than one prompt could match",
"unresolved": "Needs review: the prompt could not be traced in the workflow",
"conflict": "Needs review: already linked to a different prompt",
"failed": "Error: the prompt could not be stored",
"error": "Error: the file could not be processed (see the server log)",
"found": "Metadata found, but no prompt text to store",
None: "No prompt metadata in the file",
}
def _plural(count, word):
return f"{count} {word}" if count == 1 else f"{count} {word}s"
def _scan_status_line(done, total, batches, elapsed):
""" "Processing file N of M", with an estimate once enough batches ran."""
line = f"Processing file {done:,} of {total:,}"
if batches < MIN_BATCHES_FOR_ESTIMATE or done <= 0 or done >= total:
return line
minutes = max(1, math.ceil(elapsed / done * (total - done) / 60))
return f"{line} · about {minutes} min left"
def _file_size(path):
"""``path``'s size on disk, or None when it cannot be read."""
try:
return os.path.getsize(path)
except OSError:
return None
def _with_file_size(canonical, media_file):
"""Record the file's size in ``file_info`` unless the metadata carries one.
Stored as ``generated_images.file_size``; the "new" scan scope skips a known
file only while its size is unchanged.
"""
info = canonical.get("file_info")
info = dict(info) if isinstance(info, dict) else {}
if info.get("size") is None:
size = _file_size(media_file)
if size is not None:
info["size"] = size
if info:
canonical["file_info"] = info
def _scan_roots(output_dirs):
"""(absolute root, public label, listing index), the most specific first.
The index is the root's place in ``output_dirs`` (``_get_all_output_dirs``),
the ``root_index`` the workspace listing names it by.
"""
public_path, _ = _public_helpers()
roots = [
(os.path.abspath(str(d)), public_path(d), index)
for index, d in enumerate(output_dirs)
]
return sorted(roots, key=lambda root: len(root[0]), reverse=True)
def _scan_location(media_file, roots):
"""``media_file`` relative to its output root, that root's label and index."""
full = os.path.abspath(str(media_file))
folded = os.path.normcase(full)
for root, label, index in roots:
base = os.path.normcase(root).rstrip("\\/") + os.sep
if folded.startswith(base):
return Path(os.path.relpath(full, root)).as_posix(), label, index
return Path(full).name, "", None
def _scan_outcome(media_file, roots, status, title=None):
"""The (group, row, log line) one scanned file records in its job."""
if status in SCAN_GROUPS:
group = SCAN_GROUPS[status]
else:
group = "errors" if status == "error" else "no_metadata"
template = SCAN_TITLED_DETAILS.get(status) if title else None
detail = template.format(title=title) if template else SCAN_DETAILS[status]
path, label, index = _scan_location(media_file, roots)
row = {"path": path, "root": label, "root_index": index, "detail": detail}
where = f"{label}/{path}" if label else path
return group, row, f"{SCAN_LOG_PREFIXES[group]} {where} · {detail}"
def _inspect_restore_upload(path):
"""Checks on a restore upload beyond PromptModel.verify_database_file.
@@ -437,7 +569,7 @@ class AdminRoutesMixin:
guard = self._acquire_long_job(self.LONG_JOB_DUPLICATES)
if guard is None:
return self._long_job_busy_response("Duplicate scan or delete")
job = ScanJob()
job = Job("duplicates", "Duplicate scan")
self._duplicate_job = job
job.task = asyncio.ensure_future(self._run_duplicate_job(job, guard))
return await self._stream_scan_job(request, job)
@@ -1319,14 +1451,18 @@ class AdminRoutesMixin:
async def scan_images(self, request):
"""Scan ComfyUI output images for prompt metadata and add them to the database.
The scan runs as a background task that outlives this request, so a
closed tab never leaves a half-finished scan. Progress streams as
server-sent events; while a scan is running, a new request attaches
to it instead of starting another one.
The scan runs as the registry's ``scan`` job, which outlives this
request, so a closed tab never leaves a half-finished scan. Progress
streams as the legacy server-sent events; while a scan is running
(started here or from the workspace), a new request attaches to it
instead of starting another one.
"""
job = self._scan_job
if job is None or job.finished:
job = self._start_scan_job()
job = self._jobs.active(SCAN_JOB_KIND)
if job is None:
try:
job = self._start_scan_job("all")
except JobBusy as busy:
return self._long_job_busy_response(busy.running.title)
return await self._stream_scan_job(request, job)
async def cancel_scan(self, request):
@@ -1348,19 +1484,37 @@ class AdminRoutesMixin:
}
)
def _start_scan_job(self):
"""Create the job record synchronously, then run the scan as a task."""
job = ScanJob()
def _start_scan_job(self, files=DEFAULT_SCAN_SCOPE):
"""Register the scan job synchronously; its runner starts on the loop.
``files`` is the scope (``SCAN_SCOPES``). Raises JobBusy while another
record-changing job runs.
"""
if files not in SCAN_SCOPES:
raise ValueError(f"Unknown scan scope: {files!r}")
job = self._jobs.start(
SCAN_JOB_KIND,
SCAN_JOB_TITLE,
record_changing=True,
scope={"files": files},
runner=self._run_scan_job,
groups=SCAN_GROUP_NAMES,
)
self._scan_job = job
job.task = asyncio.ensure_future(self._run_scan_job(job))
return job
async def _run_scan_job(self, job):
try:
async for payload in self._scan_images_events():
job.publish(payload)
finally:
job.finish()
final = None
async for payload in self._scan_images_events(job):
job.publish(payload)
final = payload.get("type")
state = SCAN_FINAL_STATES.get(final, "finished")
if state == "failed":
job.log_line(f"Scan failed · {job.status_line}")
else:
verb = "cancelled" if state == "cancelled" else "finished"
job.log_line(f"Scan {verb} · {_plural(job.processed, 'file')} processed")
job.finish(state)
async def _stream_scan_job(self, request, job):
"""Stream ``job`` as SSE; stop quietly if the client goes away."""
@@ -1386,21 +1540,31 @@ class AdminRoutesMixin:
)
return response
async def _scan_images_events(self):
"""Yield progress payloads while scanning every configured output directory."""
async def _scan_images_events(self, job):
"""Yield progress payloads while scanning every configured output directory.
The payloads are the legacy SSE events, unchanged; ``job`` also gets
its status line, totals and one outcome per processed file.
"""
try:
self.logger.info("Starting image scan operation")
output_dirs = self._get_all_output_dirs()
if not output_dirs:
self.logger.error("No output directories found")
yield {
"type": "error",
"message": "No output directories found. "
"Configure scan directories in Settings.",
}
message = (
"No output directories found. "
"Configure scan directories in Settings."
)
job.update(status_line=message)
yield {"type": "error", "message": message}
return
dir_names = [self._public_path(d) for d in output_dirs]
folders = _plural(len(output_dirs), "output folder")
job.scope["output_folders"] = len(output_dirs)
scope = SCAN_SCOPE_LOG[job.scope.get("files", "all")]
job.log_line(f"Scan started · {folders} · {scope}")
job.update(status_line=f"Looking for media files in {folders}")
yield self._scan_progress(
0, f"Scanning {len(output_dirs)} directory(ies) for media files..."
)
@@ -1409,11 +1573,21 @@ class AdminRoutesMixin:
self._collect_output_media_sync, output_dirs
)
counts = dict.fromkeys(SCAN_COUNT_KEYS, 0)
job.update(
total=len(media_files),
status_line=_scan_status_line(0, len(media_files), 0, 0),
)
if media_files:
yield self._scan_progress(
5, f"Found {len(media_files)} media files to process...", counts
)
async for event in self._scan_batches(media_files, counts):
roots = _scan_roots(output_dirs)
known = set()
if media_files and job.scope.get("files") != "all":
known = await self._known_scan_files(job, media_files)
async for event in self._scan_batches(
media_files, counts, job, roots, known
):
yield event
self.logger.info(
@@ -1421,37 +1595,78 @@ class AdminRoutesMixin:
f"found={counts['found']}, new_prompts_added={counts['added']}, "
f"images_linked_to_existing={counts['linked']}"
)
job = self._scan_job
final_type = "cancelled" if job and job.cancelled else "complete"
final_type = "cancelled" if job.cancelled else "complete"
if final_type == "complete":
job.update(progress=100)
yield {"type": final_type, **counts, "directories": dir_names}
except Exception:
self.logger.exception("Scan error")
job.update(status_line="The scan stopped on an internal error.")
yield {
"type": "error",
"message": "An internal error occurred. Check server logs for details.",
}
async def _scan_batches(self, media_files, counts):
async def _known_scan_files(self, job, media_files):
"""The "new" scope's skip set; a failed read falls back to reading all."""
try:
return await self._run_in_executor(self._known_scan_files_sync, media_files)
except Exception:
self.logger.warning(
"Known files could not be read; the scan reads every file",
exc_info=True,
)
job.log_line("Known files could not be read · reading every file")
return set()
def _known_scan_files_sync(self, media_files):
"""Stat each scanned file and ask the database which are known (blocking)."""
sizes = {str(media_file): _file_size(media_file) for media_file in media_files}
return self.db.get_known_resolved_paths(sizes)
async def _scan_batches(self, media_files, counts, job, roots=(), known=()):
"""Process ``media_files`` in executor batches, updating ``counts`` in place.
Yields one progress event per batch.
Records each file's outcome in ``job`` and yields one progress event
per batch. Files in ``known`` (path strings) are not read: they count
as processed and unchanged and are recorded as skipped.
"""
total = len(media_files)
started = time.monotonic()
batches = 0
for batch_start in range(0, total, SCAN_BATCH_SIZE):
if self._scan_job and self._scan_job.cancelled:
if job.cancelled:
return
batch = media_files[batch_start : batch_start + SCAN_BATCH_SIZE]
batch_results = await self._run_in_executor(
self._extract_batch_metadata_sync, batch
)
batch_counts = await self._run_in_executor(
self._ingest_scan_batch_sync, batch_results
)
for key in counts:
counts[key] += batch_counts[key]
skipped = [f for f in batch if str(f) in known]
to_read = [f for f in batch if str(f) not in known]
outcomes = [_scan_outcome(f, roots, "skipped") for f in skipped]
counts["processed"] += len(skipped)
counts["unchanged"] += len(skipped)
if to_read:
batch_results = await self._run_in_executor(
self._extract_batch_metadata_sync, to_read
)
batch_counts = await self._run_in_executor(
self._ingest_scan_batch_sync,
batch_results,
roots=roots,
outcomes=outcomes,
)
for key in counts:
counts[key] += batch_counts[key]
for group, row, log_line in outcomes:
job.record(group, row, log_line)
batches += 1
done = min(batch_start + len(batch), total)
job.update(
processed=counts["processed"],
status_line=_scan_status_line(
done, total, batches, time.monotonic() - started
),
)
yield self._scan_progress(
int(done / total * 100), f"Processing file {done}/{total}...", counts
)
@@ -1490,9 +1705,19 @@ class AdminRoutesMixin:
return map_parallel(extract, file_batch, PromptManagerConfig.WORKER_THREADS)
def _ingest_scan_batch_sync(self, batch_results):
"""Store the prompts found in a metadata batch (blocking DB work)."""
def _ingest_scan_batch_sync(self, batch_results, roots=(), outcomes=None):
"""Store the prompts found in a metadata batch (blocking DB work).
Returns the legacy counts. When ``outcomes`` is a list, every processed
file appends its ``(group, row, log_line)`` for the scan job, with the
path relative to its root in ``roots`` (see ``_scan_roots``).
"""
counts = dict.fromkeys(SCAN_COUNT_KEYS, 0)
def note(media_file, status, title=None):
if outcomes is not None:
outcomes.append(_scan_outcome(media_file, roots, status, title))
for media_file, metadata in batch_results:
if self._scan_job and self._scan_job.cancelled:
break
@@ -1502,25 +1727,21 @@ class AdminRoutesMixin:
except Exception as e:
self.logger.error(f"Error processing {media_file.name}: {e}")
counts["failed"] += 1
note(media_file, "error")
continue
if outcome is None:
note(media_file, None)
continue
counts["found"] += 1
status = outcome["status"] if isinstance(outcome, dict) else outcome
if status in (
"added",
"linked",
"enriched",
"unchanged",
"ambiguous",
"unresolved",
"conflict",
"failed",
):
if status in SCAN_GROUPS:
counts[status] += 1
if isinstance(outcome, dict):
counts["pairs_added"] += outcome.get("pairs_added", 0)
counts["pair_links_added"] += outcome.get("links_added", 0)
note(media_file, status, outcome.get("title"))
else:
note(media_file, status)
return counts
@staticmethod
@@ -1539,7 +1760,13 @@ class AdminRoutesMixin:
return positive.strip() or None
def _ingest_scanned_file(self, media_file, metadata, detailed=False):
"""Enrich exact pairs; retain legacy positive-only import for absent pairs."""
"""Enrich exact pairs; retain legacy positive-only import for absent pairs.
Returns the file's scan status, or None without prompt metadata. With
``detailed``, a stored or linked file returns a dict instead: its
``status``, the prompt's display ``title`` and, for a resolved pair,
the pair counts.
"""
if not metadata:
return None
canonical = dict(metadata)
@@ -1552,12 +1779,18 @@ class AdminRoutesMixin:
pass
if not canonical.get("prompt") and not canonical.get("parameters"):
return None
_with_file_size(canonical, media_file)
resolution = resolve_prompt_pair(canonical)
if resolution["state"] == "resolved":
result = ingest_resolved_pair(
self.db, str(media_file), canonical, resolution
)
return result if detailed else result["status"]
if not detailed:
return result["status"]
return {
**result,
"title": self._scan_title(media_file, resolution["positive"]["text"]),
}
if canonical.get("prompt") and resolution["state"] in (
"ambiguous",
"unresolved",
@@ -1585,6 +1818,25 @@ class AdminRoutesMixin:
return "found"
prompt_text = prompt_text.strip()
status = self._store_scanned_prompt(media_file, canonical, prompt_text)
if not detailed or status not in SCAN_TITLED_DETAILS:
return status
return {"status": status, "title": self._scan_title(media_file, prompt_text)}
def _scan_title(self, media_file, text):
"""The linked prompt's Library title (its stored title, else ``text``'s
first line), as the Library and the inspector name it."""
try:
path = str(media_file)
summary = self.db.get_image_link_summaries([path], False).get(path)
if summary and summary.get("title"):
return summary["title"]
except Exception:
self.logger.warning("Scan row title could not be read; using its text")
return display_title((), text)
def _store_scanned_prompt(self, media_file, canonical, prompt_text):
"""Link ``media_file`` to ``prompt_text``'s prompt, saving it when new."""
prompt_hash = generate_prompt_hash(prompt_text)
existing = self.db.get_prompt_by_hash(prompt_hash)
if existing:
+37
View File
@@ -388,6 +388,9 @@ class HistoryRoutesMixin:
routes.get("/prompt_manager/library/{positive_id}/history")(
self.get_library_history
)
routes.get(
"/prompt_manager/library/{positive_id}/history/pairs/{pair_id}/newest"
)(self.get_newest_pair_run)
routes.get("/prompt_manager/history/runs/{run_id}")(self.get_history_run)
routes.get("/prompt_manager/history/runs/{run_id}/outputs")(
self.get_history_outputs
@@ -473,6 +476,40 @@ class HistoryRoutesMixin:
return _success_response(await self._run_in_executor(project))
@_history_errors
async def get_newest_pair_run(self, request: web.Request) -> web.Response:
"""The pair's newest recorded run for the history run dot (#249 D26)."""
_query(request)
if request.query:
raise ValueError("Invalid newest pair run query")
positive_id = _decimal(request.match_info["positive_id"], minimum=1)
pair_id = _decimal(request.match_info["pair_id"], minimum=1)
fence = self._history_fence()
db = self.db
def project() -> bytes:
generation = fence.begin()
try:
with db.recorded_history_snapshot() as epoch:
result = db.get_newest_pair_run(positive_id, pair_id)
finally:
db.model.close()
if result is None:
fence.validate(epoch, generation)
raise HistoryReadError(404, "not-found")
encoded = _serialize_history(
{
"success": True,
"history_version": 1,
"history_epoch": epoch,
**result,
}
)
fence.validate(epoch, generation)
return encoded
return _success_response(await self._run_in_executor(project))
@_history_errors
async def get_history_run(self, request: web.Request) -> web.Response:
_query(request)
+240 -24
View File
@@ -36,16 +36,38 @@ 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.inspection_response import encode_inspection_response
from ...utils.inspection_response import (
encode_inspection_response,
library_metadata,
)
from ...utils.prompt_inspector import MetadataReadError, read_inspection_file
from ...utils.file_reveal import (
OpenerUnavailable,
RevealGate,
RevealTargetChanged,
is_local_request,
reveal_executor,
reveal_in_file_manager,
)
except ImportError:
from database.image_favorites import MAX_KEY_LENGTH
from database.library import display_title
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.inspection_response import encode_inspection_response
from utils.inspection_response import (
encode_inspection_response,
library_metadata,
)
from utils.prompt_inspector import MetadataReadError, read_inspection_file
from utils.file_reveal import (
OpenerUnavailable,
RevealGate,
RevealTargetChanged,
is_local_request,
reveal_executor,
reveal_in_file_manager,
)
IMAGE_EXTENSIONS = _IMAGE_EXTENSIONS
@@ -522,6 +544,51 @@ def _favorite_target(data: Mapping, roots: list[Path]):
return (index, relative), None
def _reveal_target(data: Mapping):
"""``((index, scope, relative), None)`` for a reveal body, else an error.
Only the listing identity is accepted: an int root index, the root's
64-hex scope and a bounded root-relative path, never an absolute,
drive-qualified or ``..`` path.
"""
index, scope, relative = (
data.get("root_index"),
data.get("root_scope"),
data.get("path"),
)
if type(index) is not int or index < 0:
return None, bad_request("Invalid gallery root")
if not isinstance(scope, str) or re.fullmatch(r"[0-9a-f]{64}", scope) is None:
return None, bad_request("Invalid gallery root scope")
if (
not isinstance(relative, str)
or not relative
or len(relative) > MAX_FAVORITE_PATH
or "\0" in relative
or _has_traversal(relative)
or re.match(r"^[a-zA-Z]:", relative)
):
return None, bad_request("path must be a relative media path")
return (index, scope, relative), None
def _reveal_not_found():
return web.json_response(
{"success": False, "error": "Image file not found"}, status=404
)
def _not_local():
return web.json_response(
{
"success": False,
"code": "not-local",
"error": "Show in folder works only on the computer running ComfyUI.",
},
status=403,
)
# The path module whose case rules key favorites (tests swap in ntpath).
_path_flavor = os.path
@@ -629,19 +696,25 @@ def _workspace_filters(query: Mapping) -> dict:
``q`` (at most 500 characters) matches the relative path, case-folded, and
the linked positive's text, title and tag names. ``category`` is exact,
``tags`` is a comma list that must all be present, ``sort=rating`` ranks by
the linked positive. Raises ValueError for any unknown or oversized value.
the linked positive, and ``favorites=1`` keeps only favorites (the
Favorites page, #249 D51). Raises ValueError for any unknown or oversized
value.
"""
text = query.get("q", "").strip()
folded = text.casefold()
media = query.get("media", "all")
order = query.get("sort", "newest")
folder = query.get("subfolder", "").strip().replace("\\", "/")
# ``path``: one exact relative path (Activity opens a scan row by it, D57).
exact = query.get("path", "").strip().replace("\\", "/")
category = query.get("category", "").strip() or None
tags = sorted(
{tag.strip() for tag in query.get("tags", "").split(",") if tag.strip()}
)
favorites = query.get("favorites", "0")
if (
len(text) > 500
favorites not in ("0", "1")
or len(text) > 500
or len(folded) > 500
or media not in ("all", "image", "video")
or order not in WORKSPACE_ORDERS
@@ -652,14 +725,18 @@ def _workspace_filters(query: Mapping) -> dict:
raise ValueError("Invalid gallery filters")
if folder and (_has_traversal(folder) or len(folder) > 1024):
raise ValueError("Invalid gallery folder")
if exact and (_has_traversal(exact) or len(exact) > 1024):
raise ValueError("Invalid gallery path")
return {
"text": text,
"query": folded,
"media": media,
"order": order,
"folder": folder,
"path": exact.casefold(),
"category": category,
"tags": tags,
"favorites": favorites == "1",
}
@@ -669,7 +746,12 @@ _COUNT_OMITTED = object()
def _filtered_page(
entries: list, start: int, size: int, filters: dict, links: dict | None
entries: list,
start: int,
size: int,
filters: dict,
links: dict | None,
favorites: frozenset | None = None,
) -> tuple[int, list, int | None]:
"""Count the *entries* the filters admit and take *size* from *start* on.
@@ -678,19 +760,25 @@ def _filtered_page(
category or tag filter admits only those, and ``q`` admits a filename match
or a linked-positive hit. Returns the total, the page and the number of
distinct linked positives among all admitted entries (None without links).
*favorites*, when given, admits only those listed paths (``favorites=1``).
"""
query, media, folder = filters["query"], filters["media"], filters["folder"]
members_only = filters["category"] is not None or bool(filters["tags"])
native_folder = folder.replace("/", os.sep)
nested = native_folder + os.sep
total, page, positives = 0, [], set()
exact = filters.get("path", "")
for position, entry in enumerate(entries):
if exact and entry.folded != exact:
continue
if folder and not (
entry.folder == native_folder or entry.folder.startswith(nested)
):
continue
if media != "all" and (media == "video") != entry.video:
continue
if favorites is not None and entry.path not in favorites:
continue
match = None if links is None else links.get(entry.normalized)
if members_only and match is None:
continue
@@ -828,6 +916,11 @@ class ImageRoutesMixin:
async def clear_image_favorite_route(request):
return await self.update_image_favorite(request, favorite=False)
# POST, local-only: opens this computer's file manager (D38).
@routes.post("/prompt_manager/images/reveal")
async def reveal_image_route(request):
return await self.reveal_image(request)
# POST: drops the server-side listing cache and rewalks the output roots.
@routes.post("/prompt_manager/gallery/rescan")
async def rescan_gallery_route(request):
@@ -1070,10 +1163,12 @@ class ImageRoutesMixin:
filters["media"],
filters["order"],
filters["folder"],
filters["path"],
root_index,
[str(root) for root in output_dirs],
filters["category"],
filters["tags"],
filters["favorites"],
]
).encode()
).hexdigest()
@@ -1261,8 +1356,11 @@ class ImageRoutesMixin:
start = len(entries) - bisect_left(ascending, after, key=key)
else:
start = bisect_right(entries, after, key=key)
favorites = (
self._workspace_favorite_paths(listings) if filters["favorites"] else None
)
total, page, positive_count = _filtered_page(
entries, start, limit + 1, filters, links
entries, start, limit + 1, filters, links, favorites
)
if not needed and after is not None:
positive_count = _COUNT_OMITTED
@@ -2156,6 +2254,108 @@ class ImageRoutesMixin:
}
)
# Swapped by tests and the disposable browser server; never a shell.
_reveal_opener = staticmethod(reveal_in_file_manager)
# One per process (a class attribute): single flight plus a cooldown.
_reveal_gate = RevealGate()
@staticmethod
def _reveal_path(relative: str, root: Path) -> Path | None:
"""The real path of a regular media file under *root*, else None."""
resolved = ImageRoutesMixin._resolve_client_media_path(relative, [root])
if resolved is None or _media_access_error(resolved, [root]) is not None:
return None
real = Path(os.path.realpath(resolved))
return real if real.is_file() and _is_within(real, root) else None
async def reveal_image(self, request):
"""POST ``{root_index, root_scope, path}``: show the file in its folder.
Local-only (a same-origin browser request from a loopback client, no
forwarding header) and JSON-only: the browser must run on the
computer whose file manager opens. One reveal at a time (429 busy).
"""
if not is_local_request(request.remote, request.headers, request.scheme):
return _not_local()
if request.content_type != "application/json":
# A non-JSON POST is a CORS "simple" request with no preflight.
return web.json_response(
{
"success": False,
"code": "unsupported-media-type",
"error": "Send the request as application/json.",
},
status=415,
)
data, error = await _json_body_or_error(request)
if error is not None:
return error
target, error = _reveal_target(data)
if error is not None:
return error
index, scope, relative = target
roots = list(self._get_all_output_dirs())
error = self._gallery_scope_error(
{"root_scope": scope, "root": str(index)}, roots
)
if error is not None:
return error
root = roots[index]
if Path(relative).suffix.lower() not in SERVABLE_EXTENSIONS:
return _forbidden("Only media files can be shown")
gate = self._reveal_gate
if not gate.try_enter():
return web.json_response(
{
"success": False,
"code": "busy",
"error": "The folder is already opening.",
},
status=429,
)
try:
return await self._reveal(relative, root)
finally:
gate.leave()
async def _reveal(self, relative: str, root: Path):
"""Resolve and open, inside the reveal gate."""
try:
path = await self._run_in_executor(self._reveal_path, relative, root)
except (OSError, ValueError) as exc:
# ENAMETOOLONG and the like: the client's path is not logged.
self.logger.debug(
"Show in folder could not resolve: %s", type(exc).__name__
)
return _reveal_not_found()
if path is None:
return _reveal_not_found()
try:
await asyncio.get_running_loop().run_in_executor(
reveal_executor(), self._reveal_opener, path, root
)
except RevealTargetChanged:
return _reveal_not_found()
except OpenerUnavailable:
return web.json_response(
{
"success": False,
"code": "unavailable",
"error": "No file manager is available on this computer.",
},
status=501,
)
except Exception as exc:
# The exception text can hold the path: only its type at error
# level, the traceback at debug.
self.logger.error("Show in folder failed: %s", type(exc).__name__)
self.logger.debug("Show in folder failure", exc_info=True)
return web.json_response(
{"success": False, "error": "The folder could not be opened."},
status=500,
)
return web.json_response({"success": True})
def _favorite_listing(self, root: Path, files: list) -> dict[str, str]:
"""``{relative key: listing POSIX spelling}`` for one root's scan.
@@ -2173,6 +2373,25 @@ class ImageRoutesMixin:
self._favorite_listing_cache[cache_key] = (files, listing)
return listing
def _workspace_favorite_paths(self, listings: list[tuple]) -> frozenset:
"""The listed paths of every favorite in the selected roots (blocking).
One primary-key read per root (get_root_favorites), matched against the
root's cached ``{relative key: listing spelling}``, so the work follows
the favorites rather than the files, and a key folds case exactly as
the toggle stored it (Windows). A favorite whose file is gone matches
nothing. A failed read raises: an unfiltered page must never pass for
the favorites.
"""
paths = set()
for _index, root, files in listings:
listing = self._favorite_listing(root, files)
for key in self.db.get_root_favorites(_favorite_root_key(root)):
listed = listing.get(key)
if listed is not None:
paths.add(root / listed)
return frozenset(paths)
def _workspace_favorites(self, page: list[tuple]) -> set[tuple[int, str]]:
"""``(root index, listing path)`` of the favorites among *page* entries."""
by_root: dict[tuple[int, Path], dict[str, str]] = {}
@@ -2296,7 +2515,8 @@ class ImageRoutesMixin:
"title": None,
"category": None,
"tags": [],
"metadata_scope": None,
"category_scope": None,
"tags_scope": None,
}
if not isinstance(saved, dict) or not saved:
return result
@@ -2304,23 +2524,19 @@ class ImageRoutesMixin:
result["status"] = "ambiguous"
return result
pair = saved.get("pair")
if isinstance(pair, dict):
result.update(
status="linked",
positive_id=pair.get("positive_prompt_id"),
pair_id=pair.get("id"),
category=pair.get("category"),
tags=pair.get("tags"),
metadata_scope="pair",
)
else:
result.update(
status="linked",
positive_id=saved.get("prompt_id"),
category=saved.get("category"),
tags=saved.get("tags"),
metadata_scope="positive",
)
recipe = isinstance(pair, dict)
result.update(
status="linked",
positive_id=(
pair.get("positive_prompt_id") if recipe else saved.get("prompt_id")
),
pair_id=pair.get("id") if recipe else None,
**library_metadata(
saved.get("category"),
saved.get("tags"),
(pair.get("category"), pair.get("tags")) if recipe else None,
),
)
return result
async def get_image_prompt(self, request):
+161
View File
@@ -0,0 +1,161 @@
"""Session job routes (Activity): list, inspect, follow and cancel jobs.
The registry itself is ``py/api/jobs.py``; the scan job's runner is in
``py/api/admin.py``. Every response leaves out absolute server paths: job
rows already carry root-relative paths and public root labels.
"""
import contextlib
import json
from aiohttp import web
from .jobs import DEFAULT_SCAN_SCOPE, SCAN_SCOPES, JobBusy
def _not_found():
return web.json_response({"success": False, "error": "Job not found"}, status=404)
def _bad_request(message):
return web.json_response({"success": False, "error": message}, status=400)
async def _scan_scope(request):
"""The requested scan scope ("new" or "all"), or None when the body is invalid.
An empty body (also a chunked one with no bytes), or one without
``scope``, asks for the default.
"""
if not request.can_read_body:
return DEFAULT_SCAN_SCOPE
text = await request.text()
if not text.strip(): # e.g. a chunked POST that sent no bytes
return DEFAULT_SCAN_SCOPE
try:
body = json.loads(text)
except ValueError:
return None
if not isinstance(body, dict):
return None
scope = body.get("scope", DEFAULT_SCAN_SCOPE)
return scope if scope in SCAN_SCOPES else None
def _summary_event(job):
return f"data: {json.dumps(job.summary())}\n\n".encode("utf-8")
class JobRoutesMixin:
"""Mixin providing the /prompt_manager/jobs endpoints."""
def _register_job_routes(self, routes):
@routes.get("/prompt_manager/jobs")
async def list_jobs_route(request):
return await self.list_jobs(request)
@routes.post("/prompt_manager/jobs/scan")
async def start_scan_job_route(request):
return await self.start_scan_job(request)
@routes.get("/prompt_manager/jobs/{job_id}")
async def job_detail_route(request):
return await self.job_detail(request)
@routes.get("/prompt_manager/jobs/{job_id}/report")
async def job_report_route(request):
return await self.job_report(request)
@routes.get("/prompt_manager/jobs/{job_id}/events")
async def job_events_route(request):
return await self.job_events(request)
@routes.post("/prompt_manager/jobs/{job_id}/cancel")
async def cancel_job_route(request):
return await self.cancel_job(request)
async def list_jobs(self, request):
"""This session's jobs: running first, then finished, newest first."""
jobs = [job.summary() for job in self._jobs.list()]
return web.json_response({"success": True, "jobs": jobs})
async def job_detail(self, request):
job = self._jobs.get(request.match_info["job_id"])
if job is None:
return _not_found()
return web.json_response({"success": True, "job": job.detail()})
async def job_report(self, request):
"""The job's whole record as a JSON attachment (no absolute paths)."""
job = self._jobs.get(request.match_info["job_id"])
if job is None:
return _not_found()
body = json.dumps(job.report(), indent=2)
disposition = f'attachment; filename="{job.report_filename()}"'
return web.Response(
text=body,
content_type="application/json",
headers={
"Content-Disposition": disposition,
"Cache-Control": "no-store",
},
)
async def cancel_job(self, request):
"""Stop a job at its next file boundary; a finished job is a no-op."""
job_id = request.match_info["job_id"]
if self._jobs.get(job_id) is None:
return _not_found()
requested = self._jobs.cancel(job_id)
return web.json_response({"success": True, "cancellation_requested": requested})
async def start_scan_job(self, request):
"""Start the metadata scan as a job (202), or 409 naming the busy job.
The body's ``scope`` is "new" (the default: new and unresolved files)
or "all"; anything else is a 400.
"""
files = await _scan_scope(request)
if files is None:
return _bad_request('The scan scope must be "new" or "all"')
try:
job = self._start_scan_job(files)
except JobBusy as busy:
running = busy.running
return web.json_response(
{
"success": False,
"code": "busy",
"error": str(busy),
"running": {"id": running.id, "title": running.title},
},
status=409,
)
return web.json_response({"success": True, "job": job.summary()}, status=202)
async def job_events(self, request):
"""Stream the job's summary as SSE: now, after each event, and at the end."""
job = self._jobs.get(request.match_info["job_id"])
if job is None:
return _not_found()
response = web.StreamResponse(
status=200,
reason="OK",
headers={
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
)
await response.prepare(request)
try:
await response.write(_summary_event(job))
async with contextlib.aclosing(job.events()) as events:
async for _payload in events:
await response.write(_summary_event(job))
# The last event is published before the job takes its final state.
await response.write(_summary_event(job))
await response.write_eof()
except Exception as e: # the socket closed under us; the job goes on
self.logger.info(f"Job progress client disconnected ({e})")
return response
+359
View File
@@ -0,0 +1,359 @@
"""The session job registry: every long operation as one tracked job.
A job runs as an asyncio task that outlives the HTTP request that started it,
so an accidental tab close never abandons half-finished work. Each job keeps
its counters, a capped list of file outcomes per outcome group and a capped
log, and fans its progress out to SSE subscribers: the request that started
it, a reconnecting page, a second tab. A subscriber receives the latest event
as a snapshot and then every event published after it, until the job ends.
Jobs live only in server memory and are gone after a restart. The registry
keeps the running jobs and this session's last ``MAX_FINISHED_JOBS`` finished
ones. Record-changing jobs (the metadata scan, and later maintenance, cleanup
and restore) never run together, and one job of a kind runs at a time.
"""
import asyncio
import itertools
import logging
import re
import time
import uuid
from pathlib import PurePosixPath, PureWindowsPath
logger = logging.getLogger(__name__)
# Start and finish order: wall-clock times can tie (coarse Windows clocks).
_sequence = itertools.count()
JOB_STATES = ("running", "finished", "cancelled", "failed")
FINAL_STATES = JOB_STATES[1:]
MAX_OUTCOME_ROWS = 200 # rows listed per outcome group; counters stay exact
MAX_LOG_LINES = 2000
LOG_CAP_LINE = "[Log capped at 2,000 lines]"
MAX_FINISHED_JOBS = 20 # finished jobs this session keeps
# The metadata scan's outcome groups, in counter-tile order of the spec's
# mapping, and the per-file scan status each group collects. Files without
# usable metadata have no status and go to "no_metadata".
# Which files a scan reads (#249 Activity-2): "new" skips the files the Library
# already links with a resolved link (get_known_resolved_paths) before reading
# them, so new, unlinked, unresolved and ambiguous files are read; "all" reads
# every file again. The workspace job defaults to "new"; legacy /scan is "all".
SCAN_SCOPES = ("new", "all")
DEFAULT_SCAN_SCOPE = "new"
SCAN_GROUP_NAMES = ("added", "known", "review", "no_metadata", "errors")
SCAN_GROUPS = {
"added": "added",
"linked": "added",
"enriched": "added",
"unchanged": "known",
"skipped": "known", # "new" scope: known and resolved, not read again
"ambiguous": "review",
"conflict": "review",
"unresolved": "review",
"failed": "errors",
}
def _relative_path(path) -> str:
"""``path`` as a root-relative POSIX path; an absolute one keeps its name."""
text = str(path or "")
if (
PurePosixPath(text).is_absolute()
or PureWindowsPath(text).is_absolute()
or PureWindowsPath(text).drive
):
return re.split(r"[\\/]", text)[-1]
return text.replace("\\", "/")
class JobBusy(Exception):
"""A job could not start because ``running`` holds its slot."""
def __init__(self, running):
super().__init__(f"{running.title} is already running")
self.running = running
class Job:
"""One tracked operation: state, counters, outcomes, log and subscribers."""
def __init__(
self, kind, title, *, record_changing=False, scope=None, groups=(), clock=None
):
self._clock = clock or time.time
self.id = uuid.uuid4().hex
self.kind = kind
self.title = title
self.record_changing = record_changing
self.scope = dict(scope or {})
self.state = "running"
self.started_at = self._clock()
self._started_seq = next(_sequence)
self._finished_seq = None
self.finished_at = None
self.progress = 0
self.status_line = ""
self.processed = 0
self.total = 0
self.counters = dict.fromkeys(groups, 0)
self.outcomes = {group: [] for group in groups}
self.log = []
self.cancelled = False
self.last_event = None
self.task = None
self._subscribers = set()
@property
def running(self):
return self.state == "running"
@property
def finished(self):
return not self.running
@property
def subscriber_count(self):
return len(self._subscribers)
# ── progress ──────────────────────────────────────────────────────
def update(self, *, progress=None, status_line=None, processed=None, total=None):
"""Set the summary's progress fields; ``None`` leaves one unchanged."""
if progress is not None:
self.progress = max(0, min(100, int(progress)))
if status_line is not None:
self.status_line = status_line
if processed is not None:
self.processed = processed
if total is not None:
self.total = total
def publish(self, payload):
"""Record ``payload`` as the latest event and hand it to every subscriber."""
if isinstance(payload.get("progress"), (int, float)):
self.update(progress=payload["progress"])
self.last_event = payload
for queue in list(self._subscribers):
queue.put_nowait(payload)
def log_line(self, text):
"""Append a timestamped log line, up to MAX_LOG_LINES and one cap line."""
if len(self.log) > MAX_LOG_LINES:
return
if len(self.log) == MAX_LOG_LINES:
self.log.append(LOG_CAP_LINE)
return
stamp = time.strftime("%H:%M:%S", time.localtime(self._clock()))
self.log.append(f"{stamp} {text}")
def record(self, group, row, log_line):
"""Count one file outcome in ``group``, list its row and log it.
``row`` is ``{path, root, root_index, detail}``: the path relative to
its output root, the root's public label and its listing index (the
identity Images opens it by; None when unknown). An absolute path is
cut to its file name, so a row never reveals the server's layout.
"""
if group not in self.counters:
raise ValueError(f"Unknown outcome group {group!r} for {self.kind}")
self.counters[group] += 1
raw = str(row.get("path") or "")
path = _relative_path(raw)
if raw and raw != path:
log_line = log_line.replace(raw, path)
rows = self.outcomes[group]
index = row.get("root_index")
if isinstance(index, bool) or not isinstance(index, int) or index < 0:
index = None
if len(rows) < MAX_OUTCOME_ROWS:
rows.append(
{
"path": path,
"root": str(row.get("root") or ""),
"root_index": index,
"detail": str(row.get("detail") or ""),
}
)
self.log_line(log_line)
def finish(self, state="finished"):
"""End the job in ``state`` and release every subscriber.
Each subscriber still gets the events queued before the release. A
second finish is a no-op, so the first final state stands.
"""
if state not in FINAL_STATES:
raise ValueError(f"{state!r} is not a final job state")
if self.finished:
return
self.state = state
self.finished_at = self._clock()
self._finished_seq = next(_sequence)
for queue in list(self._subscribers):
queue.put_nowait(None)
async def events(self):
"""Yield the latest event, then each later one, until the job finishes.
Consume it under ``contextlib.aclosing`` so a consumer that stops early
(a closed socket) drops its queue at once instead of at garbage collection.
"""
queue = asyncio.Queue()
self._subscribers.add(queue)
try:
if self.last_event is not None:
yield self.last_event
if self.finished:
return
while True:
payload = await queue.get()
if payload is None:
return
yield payload
finally:
self._subscribers.discard(queue)
# ── views ─────────────────────────────────────────────────────────
def summary(self):
"""The job as the Activity list and pill show it."""
return {
"id": self.id,
"kind": self.kind,
"title": self.title,
"state": self.state,
"started_at": self.started_at,
"finished_at": self.finished_at,
"progress": self.progress,
"status_line": self.status_line,
"processed": self.processed,
"total": self.total,
"counters": dict(self.counters),
"scope": dict(self.scope),
# What detail() lists, so a view re-reads it only when it changed.
"listed": {group: len(rows) for group, rows in self.outcomes.items()},
"log_length": len(self.log),
}
def detail(self):
"""The summary plus the listed outcome rows and the log."""
return {
**self.summary(),
"outcomes": {
group: [dict(row) for row in rows]
for group, rows in self.outcomes.items()
},
"log": list(self.log),
}
def report(self):
"""The downloadable report: the summary, every outcome group and the log.
Each group carries its exact count, its listed rows (at most
MAX_OUTCOME_ROWS) and whether the list is cut short. Rows and log lines
already hold root-relative paths only.
"""
summary = self.summary()
summary.pop("listed", None)
summary.pop("log_length", None)
return {
"report_version": 1,
"generated_at": self._clock(),
"job": summary,
"outcomes": {
group: {
"count": self.counters.get(group, len(rows)),
"rows": [dict(row) for row in rows],
"truncated": self.counters.get(group, len(rows)) > len(rows),
}
for group, rows in self.outcomes.items()
},
"log": list(self.log),
}
def report_filename(self):
"""The download name: prompt-manager-KIND-YYYYMMDD-HHMM.json (local time)."""
stamp = time.strftime("%Y%m%d-%H%M", time.localtime(self._clock()))
return f"prompt-manager-{self.kind}-{stamp}.json"
class JobRegistry:
"""Every job of this server session, running and recently finished."""
def __init__(self, logger_=None):
self._logger = logger_ or logger
self._jobs = {}
def start(self, kind, title, *, record_changing, scope, runner, groups=()) -> Job:
"""Create a job and run ``runner(job)`` as a task.
Raises JobBusy, naming the job in the way, when a job of ``kind`` is
running or when ``record_changing`` and another record-changing job
runs. Must be called from the event loop.
"""
same_kind = self.active(kind)
if same_kind is not None:
raise JobBusy(same_kind)
if record_changing:
for job in self._jobs.values():
if job.running and job.record_changing:
raise JobBusy(job)
self._prune()
job = Job(
kind, title, record_changing=record_changing, scope=scope, groups=groups
)
self._jobs[job.id] = job
job.task = asyncio.ensure_future(self._run(job, runner))
return job
async def _run(self, job, runner):
try:
await runner(job)
except asyncio.CancelledError:
job.finish("cancelled")
raise
except Exception:
self._logger.exception(f"Job {job.kind} failed")
job.finish("failed")
finally:
if job.running:
job.finish("cancelled" if job.cancelled else "finished")
def _prune(self):
"""Drop the oldest finished jobs so a new one keeps the cap."""
finished = [job for job in self._jobs.values() if job.finished]
finished.sort(key=lambda job: job._finished_seq)
for job in finished[: max(0, len(finished) - MAX_FINISHED_JOBS + 1)]:
del self._jobs[job.id]
def get(self, job_id):
return self._jobs.get(job_id)
def active(self, kind):
"""The running job of ``kind``, or None."""
for job in self._jobs.values():
if job.kind == kind and job.running:
return job
return None
def list(self):
"""Running jobs first, then the last finished ones, newest first."""
jobs = list(self._jobs.values())
running = [job for job in jobs if job.running]
finished = [job for job in jobs if job.finished]
running.sort(key=lambda job: job._started_seq, reverse=True)
finished.sort(key=lambda job: job._finished_seq, reverse=True)
return running + finished[:MAX_FINISHED_JOBS]
def cancel(self, job_id) -> bool:
"""Ask a running job to stop at its next safe boundary.
Returns whether a cancellation was requested: False for a finished or
unknown job, so a cancel after the end is a no-op.
"""
job = self._jobs.get(job_id)
if job is None or job.finished:
return False
job.cancelled = True
return True
+115
View File
@@ -0,0 +1,115 @@
"""Saved views (D16): list, create, rename and delete named toolbar states.
The page, the name and the whitelisted per-page state are validated here
(through ``database/saved_views.py``) before anything is written. Only that
request validation (``SavedViewInvalid``) is a 400 carrying its message; any
other failure, including a ``ValueError`` raised by the database layer, is a
logged 500 with a generic message.
"""
from functools import wraps
from typing import Callable
from aiohttp import web
try:
from ...database.saved_views import SavedViewInvalid, encode_state, validate_name
except ImportError:
from database.saved_views import SavedViewInvalid, encode_state, validate_name
_CREATE_FIELDS = frozenset({"page", "name", "state"})
_RENAME_FIELDS = frozenset({"name"})
_MAX_ID = 2**63 - 1
class SavedViewNotFound(LookupError):
pass
def saved_view_response(handler: Callable) -> Callable:
"""``{success:false,error}`` with 400, 404 or 500 on every failure."""
@wraps(handler)
async def wrapped(self, request: web.Request) -> web.Response:
try:
return await handler(self, request)
except SavedViewNotFound:
return web.json_response(
{"success": False, "error": "Saved view not found"}, status=404
)
except SavedViewInvalid as exc:
return web.json_response({"success": False, "error": str(exc)}, status=400)
except Exception:
self.logger.exception("Saved view operation failed")
return web.json_response(
{"success": False, "error": "Saved view operation failed"}, status=500
)
return wrapped
async def _body(request: web.Request, fields: frozenset) -> dict:
"""A JSON object whose keys are exactly ``fields``."""
try:
body = await request.json()
except (ValueError, UnicodeDecodeError): # JSONDecodeError is a ValueError
raise SavedViewInvalid("Expected a JSON object") from None
if not isinstance(body, dict):
raise SavedViewInvalid("Expected a JSON object")
if set(body) != fields:
raise SavedViewInvalid(
"Request must contain exactly " + ", ".join(sorted(fields))
)
return body
def _view_id(request: web.Request) -> int:
raw = request.match_info["view_id"]
if not raw.isascii() or not raw.isdigit():
raise SavedViewInvalid("Invalid saved view ID")
value = int(raw)
if not 1 <= value <= _MAX_ID:
raise SavedViewInvalid("Invalid saved view ID")
return value
class SavedViewRoutesMixin:
def _register_saved_view_routes(self, routes: web.RouteTableDef) -> None:
routes.get("/prompt_manager/saved-views")(self.list_saved_views)
routes.post("/prompt_manager/saved-views")(self.create_saved_view)
routes.patch("/prompt_manager/saved-views/{view_id}")(self.rename_saved_view)
routes.delete("/prompt_manager/saved-views/{view_id}")(self.delete_saved_view)
@saved_view_response
async def list_saved_views(self, request: web.Request) -> web.Response:
views = await self._run_in_executor(self.db.list_saved_views)
return web.json_response({"success": True, "views": views})
@saved_view_response
async def create_saved_view(self, request: web.Request) -> web.Response:
body = await _body(request, _CREATE_FIELDS)
encode_state(body["page"], body["state"])
validate_name(body["name"])
view = await self._run_in_executor(
self.db.create_saved_view, body["page"], body["name"], body["state"]
)
return web.json_response({"success": True, "view": view}, status=201)
@saved_view_response
async def rename_saved_view(self, request: web.Request) -> web.Response:
view_id = _view_id(request)
body = await _body(request, _RENAME_FIELDS)
validate_name(body["name"])
view = await self._run_in_executor(
self.db.rename_saved_view, view_id, body["name"]
)
if view is None:
raise SavedViewNotFound
return web.json_response({"success": True, "view": view})
@saved_view_response
async def delete_saved_view(self, request: web.Request) -> web.Response:
view_id = _view_id(request)
if not await self._run_in_executor(self.db.delete_saved_view, view_id):
raise SavedViewNotFound
return web.json_response({"success": True, "id": view_id})
-62
View File
@@ -1,62 +0,0 @@
"""Progress fan-out for the background output scan.
The scan runs as an asyncio task that outlives the HTTP request that started
it, so an accidental tab close does not abandon a half-finished scan. Every
SSE subscriber (the request that started the scan, a reconnecting page, a
second tab) receives the latest event as a snapshot and then every event
published after it, until the job finishes.
"""
import asyncio
class ScanJob:
"""One scan run: its latest event, completion flag and live subscribers."""
def __init__(self):
self.last_event = None
self.finished = False
self.cancelled = False
self.task = None
self._subscribers = set()
@property
def running(self):
return not self.finished
@property
def subscriber_count(self):
return len(self._subscribers)
def publish(self, payload):
"""Record ``payload`` as the latest event and hand it to every subscriber."""
self.last_event = payload
for queue in list(self._subscribers):
queue.put_nowait(payload)
def finish(self):
"""Mark the job done and release every subscriber after its queued events."""
self.finished = True
for queue in list(self._subscribers):
queue.put_nowait(None)
async def events(self):
"""Yield the latest event, then each later one, until the job finishes.
Consume it under ``contextlib.aclosing`` so a consumer that stops early
(a closed socket) drops its queue at once instead of at garbage collection.
"""
queue = asyncio.Queue()
self._subscribers.add(queue)
try:
if self.last_event is not None:
yield self.last_event
if self.finished:
return
while True:
payload = await queue.get()
if payload is None:
return
yield payload
finally:
self._subscribers.discard(queue)
+296
View File
@@ -0,0 +1,296 @@
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
import { THEMES, seedConcept, openConcept, openProduction, diffInventory, mutate } from './concept-inventory.mjs';
// Activity-1 Task 3. The review dialog is compared with the concept's own `scan-review` ("Review metadata scan",
// app.js 1428–1432) and its backup box with the concept's System `.notice`; the differences are D55. The job view
// has no concept counterpart: it is compared with the approved mockup `activity-scan.html` (a local, ignored file),
// whose structure and CSS values are restated below (D56). Each comparison is shown to notice a mutation.
const workspace = '/prompt_manager/workspace';
test.beforeEach(async ({ page, request, browserName }) => {
await startCoverage(page, browserName);
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);
expect(page.conceptErrors).toEqual([]);
await request.post('/__test__/jobs/restart');
});
/** The open review dialog: its body's element sequence, the meta grid, the sentence, the notice and the actions. */
function measureReview() {
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 grid = dialog.querySelector('#modal-body > .meta-grid');
const sentence = dialog.querySelector('#modal-body > p.small');
const actions = dialog.querySelector('#modal-body > .dialog-actions');
return {
title: dialog.querySelector('#modal-title').textContent,
body: [...dialog.querySelector('#modal-body').children].map(name),
grid: {
tag: grid.localName,
...css(grid, 'display', 'gridTemplateColumns', 'columnGap', 'rowGap', 'marginTop', 'marginBottom', 'fontSize'),
entries: [...grid.children].map(entry => ({
tag: entry.localName, dt: entry.querySelector('dt').textContent, dd: entry.querySelector('dd').textContent,
dtStyle: css(entry.querySelector('dt'), 'color', 'fontSize'),
ddStyle: css(entry.querySelector('dd'), 'marginTop', 'marginLeft', 'fontSize', 'color'),
})),
},
sentence: { text: sentence.textContent, ...css(sentence, 'fontSize', 'color', 'lineHeight', 'marginTop') },
actions: {
...css(actions, 'display', 'justifyContent', 'columnGap', 'marginTop'),
buttons: [...actions.children].map(button => ({ text: button.textContent, className: button.className,
...css(button, 'backgroundColor', 'color', 'fontSize', 'paddingTop', 'paddingLeft', 'borderTopLeftRadius') })),
},
};
}
/** A `.notice` box: its frame and its text and actions columns, without the copy. */
function measureNotice(selector) {
const notice = document.querySelector(selector);
const css = (node, ...keys) => node ? Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]])) : null;
return {
frame: css(notice, 'display', 'alignItems', 'justifyContent', 'columnGap', 'borderTopWidth', 'borderTopStyle',
'borderTopColor', 'backgroundColor', 'borderTopLeftRadius', 'paddingTop', 'paddingLeft'),
columns: [...notice.children].map(node => node.localName + (node.className ? `.${node.className}` : '')),
text: css(notice.querySelector(':scope > div > p'), 'fontSize', 'color', 'marginTop'),
actions: css(notice.querySelector(':scope > .row-actions'), 'display', 'flexShrink'),
};
}
/** A concept `.setting-row` switch: its frame, its label and hint, and its checkbox (not its place-dependent margin). */
function measureToggle(selector) {
const row = document.querySelector(selector);
const css = (node, ...keys) => node ? Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]])) : null;
return {
children: [...row.children].map(node => [node.localName, ...node.classList].join('.')),
frame: css(row, 'display', 'alignItems', 'justifyContent', 'columnGap', 'paddingTop', 'paddingBottom',
'borderTopWidth', 'borderTopStyle', 'borderTopColor'),
label: css(row.querySelector('label'), 'fontSize', 'fontWeight', 'color'),
hint: css(row.querySelector('p'), 'fontSize', 'color', 'marginTop'),
box: css(row.querySelector('input[type="checkbox"]'), 'accentColor', 'width', 'height', 'appearance',
'backgroundColor', 'borderTopLeftRadius'),
role: row.querySelector('input').getAttribute('role'),
labelled: row.querySelector('label').htmlFor === row.querySelector('input').id,
};
}
/** D55 and D58: the concept's dialog with the production differences applied. */
function mapReviewConcept(reference) {
return {
...reference,
// No sample-plan note (prototype copy); the D58 "All files" toggle follows the grid; the backup box sits
// before the actions.
body: [...reference.body.filter(item => item !== 'p.demo-note').slice(0, -1), 'div.notice', reference.body.at(-1)]
.flatMap(item => item === 'div.meta-grid' ? ['dl.meta-grid', 'div.setting-row'] : [item]),
// A `dl`, as in the concept's maintenance plan; real sources. The scope keeps the concept's
// "New and unresolved files" until "All files" is checked.
grid: { ...reference.grid, tag: 'dl', entries: reference.grid.entries.map(entry => ({ ...entry,
dd: { Sources: '2 output folders', Scope: 'New and unresolved files' }[entry.dt] })) },
actions: { ...reference.actions, buttons: reference.actions.buttons.map(button => button.text === 'Simulate scan'
? { ...button, text: 'Start scan' } : button) },
};
}
async function conceptReview(page, theme) {
await openConcept(page, 'images', theme);
await page.locator('#main .heading-actions').getByRole('button', { name: 'Import', exact: true }).click();
await page.locator('dialog#modal[open] .operation').filter({ hasText: 'Scan an output folder' })
.getByRole('button', { name: 'Review scan', exact: true }).click();
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Review metadata scan');
await page.mouse.move(0, 0);
const review = await page.evaluate(measureReview);
await page.keyboard.press('Escape');
// The concept's System → Sources notice ("Bring older images into your prompt history").
await page.goto(`/__test__/canonical/workspace#system`);
await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption(theme);
await page.locator('#main').getByRole('button', { name: 'Manage sources', exact: true }).click();
await expect(page.locator('#main .notice')).toBeVisible();
return { review, notice: await page.evaluate(measureNotice, '#main .notice'),
toggle: await page.evaluate(measureToggle, '#main .setting-row:has(input[type="checkbox"])') };
}
async function productionReview(page, theme) {
await openProduction(page, 'images', theme);
await page.locator('#workspace-main .heading-actions').getByRole('button', { name: 'Import', exact: true }).click();
await page.locator('dialog#modal[open] .operation').filter({ hasText: 'Scan for metadata' })
.getByRole('button', { name: 'Review scan', exact: true }).click();
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Review metadata scan');
// The swapped dialog can open under the pointer that pressed Review scan; measure it at rest.
await page.mouse.move(0, 0);
const review = await page.evaluate(measureReview);
const notice = await page.evaluate(measureNotice, 'dialog#modal[open] .notice');
// The concept's switch is on; compare ours on too, then turn it off again (the default).
const allFiles = page.getByRole('switch', { name: 'All files', exact: true });
await allFiles.check();
await page.mouse.move(0, 0);
const toggle = await page.evaluate(measureToggle, 'dialog#modal[open] .setting-row');
await allFiles.uncheck();
await page.mouse.move(0, 0);
return { review, notice, toggle };
}
for (const theme of THEMES) test(`review dialog matches the concept scan-review ${theme}`, async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
await seedConcept(request);
const concept = await conceptReview(page, theme);
expect(concept.review.body).toEqual(['p.demo-note', 'div.meta-grid', 'p.small', 'div.dialog-actions']);
const production = await productionReview(page, theme);
expect(diffInventory(mapReviewConcept(concept.review), production.review)).toEqual([]);
expect(diffInventory(concept.notice, production.notice)).toEqual([]);
expect(diffInventory(concept.toggle, production.toggle)).toEqual([]);
await expect(page.locator('dialog#modal[open] .setting-row > div > label')).toHaveText('All files');
await expect(page.locator('dialog#modal[open] .setting-row input')).not.toBeChecked();
await expect(page.getByRole('switch', { name: 'All files', exact: true })).toBeVisible();
await expect(page.locator('dialog#modal[open] .notice h3')).toHaveText('Back up the database first (recommended)');
await expect(page.locator('dialog#modal[open] .notice .row-actions button')).toHaveText(['Back up now']);
// Mutation check: one CSS change and one removed element are both noticed, then undone to an exact match again.
const undoCss = await mutate(page, { css: '#modal .meta-grid { gap: 3px; } #modal .notice { padding: 2px; } #modal .setting-row { gap: 3px; }' });
const cssMutated = await page.evaluate(measureReview);
const noticeMutated = await page.evaluate(measureNotice, 'dialog#modal[open] .notice');
await page.getByRole('switch', { name: 'All files', exact: true }).check();
await page.mouse.move(0, 0);
const toggleMutated = await page.evaluate(measureToggle, 'dialog#modal[open] .setting-row');
await page.getByRole('switch', { name: 'All files', exact: true }).uncheck();
await page.mouse.move(0, 0);
expect(diffInventory(concept.toggle, toggleMutated).map(item => item.path)).toEqual(['frame.columnGap']);
expect(diffInventory(mapReviewConcept(concept.review), cssMutated).map(item => item.path)).toEqual(expect.arrayContaining(['grid.columnGap', 'grid.rowGap']));
expect(diffInventory(concept.notice, noticeMutated).map(item => item.path)).toEqual(['frame.paddingTop', 'frame.paddingLeft']);
await undoCss();
const undoDom = await mutate(page, { remove: ['#modal-body > p.small'] });
const domMutated = await page.evaluate(() => [...document.querySelectorAll('#modal-body > *')].map(node => node.localName));
expect(domMutated).toEqual(['dl', 'div', 'div', 'div']);
await undoDom();
expect(diffInventory(mapReviewConcept(concept.review), await page.evaluate(measureReview))).toEqual([]);
});
// The approved mockup's job view, restated: structure first, then the values of its CSS rules.
const MOCKUP_TREE = ['div.heading', 'section.surface', 'section.surface', 'details.term'];
const MOCKUP_PROGRESS = ['div.panel-heading(h2,span.small)', 'progress', 'dl.stat-row(div.added.stat,div.stat,div.stat.warn,div.stat,div.bad.stat)'];
const MOCKUP_RESULTS = ['div.panel-heading(h2,label.search-field)', 'div.outcomes(div.outcome-list,div.outcome-files)'];
const MOCKUP_STYLES = {
statRow: { display: 'grid', columnGap: '10px', columns: 5, marginTop: '16px', marginBottom: '16px' },
stat: { borderTopWidth: '1px', borderTopLeftRadius: '7px', paddingTop: '12px', paddingLeft: '14px' },
dt: { fontSize: '11px' },
dd: { fontSize: '20px', fontWeight: '600', marginTop: '6px', marginLeft: '0px' },
outcomes: { display: 'grid', columnGap: '16px', first: '220px', marginTop: '6px' },
outcomeButton: { display: 'flex', justifyContent: 'space-between', marginBottom: '6px', textAlign: 'left' },
fileRow: { display: 'grid', columnGap: '12px', paddingTop: '9px', paddingLeft: '4px', borderBottomWidth: '1px', fontSize: '12px' },
path: { monospace: true, overflowWrap: 'anywhere' },
search: { maxWidth: '320px' },
term: { marginTop: '16px', borderTopWidth: '1px', borderTopLeftRadius: '7px', overflow: 'hidden' },
summary: { paddingTop: '11px', paddingLeft: '14px', fontSize: '12px', cursor: 'pointer' },
pre: { marginTop: '0px', paddingTop: '12px', paddingLeft: '14px', maxHeight: '180px', fontSize: '11px', lineHeight: '18.7px', overflow: 'auto' },
};
function measureJobView() {
const main = document.querySelector('#workspace-main');
const css = (node, ...keys) => Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]]));
const name = node => [node.localName, ...[...node.classList].sort()].join('.');
// The rows and sections the mockup nests are listed with their children.
const shape = node => node.matches('.panel-heading, .outcomes, dl.stat-row')
? `${name(node)}(${[...node.children].map(name).join(',')})` : name(node);
const [progress, results] = main.querySelectorAll(':scope > section.surface');
const statRow = progress.querySelector('dl.stat-row');
const stat = statRow.querySelector('.stat');
const outcomes = results.querySelector('.outcomes');
const button = outcomes.querySelector('.outcome-list > button');
const row = outcomes.querySelector('.file-row');
const term = main.querySelector(':scope > details.term');
const pre = term.querySelector('pre');
const columns = getComputedStyle(statRow).gridTemplateColumns.split(' ');
return {
tree: [...main.children].map(name),
progress: [...progress.children].map(shape),
results: [...results.children].map(shape),
h2: [...main.querySelectorAll(':scope > section.surface > .panel-heading > h2')].map(node => node.textContent),
summary: term.querySelector('summary').textContent,
styles: {
statRow: { ...css(statRow, 'display', 'columnGap', 'marginTop', 'marginBottom'), columns: columns.length },
stat: css(stat, 'borderTopWidth', 'borderTopLeftRadius', 'paddingTop', 'paddingLeft'),
dt: css(stat.querySelector('dt'), 'fontSize'),
dd: css(stat.querySelector('dd'), 'fontSize', 'fontWeight', 'marginTop', 'marginLeft'),
outcomes: { ...css(outcomes, 'display', 'columnGap', 'marginTop'), first: getComputedStyle(outcomes).gridTemplateColumns.split(' ')[0] },
outcomeButton: css(button, 'display', 'justifyContent', 'marginBottom', 'textAlign'),
fileRow: css(row, 'display', 'columnGap', 'paddingTop', 'paddingLeft', 'borderBottomWidth', 'fontSize'),
path: { monospace: /monospace/.test(getComputedStyle(row.querySelector('.path')).fontFamily),
overflowWrap: getComputedStyle(row.querySelector('.path')).overflowWrap },
search: css(results.querySelector('.search-field'), 'maxWidth'),
term: css(term, 'marginTop', 'borderTopWidth', 'borderTopLeftRadius', 'overflow'),
summary: css(term.querySelector('summary'), 'paddingTop', 'paddingLeft', 'fontSize', 'cursor'),
// WebKit reports the 1.7 line height of 11px as 18.700001px; compare it to the hundredth of a pixel.
pre: { ...css(pre, 'marginTop', 'paddingTop', 'paddingLeft', 'maxHeight', 'fontSize', 'overflow'),
lineHeight: `${Math.round(parseFloat(getComputedStyle(pre).lineHeight) * 100) / 100}px` },
},
// Mockup colours: the pressed outcome takes the accent border on the soft blue, the tiles their tone.
pressed: (() => {
const probe = document.createElement('span');
main.append(probe);
const tone = name => { probe.style.color = `var(${name})`; return getComputedStyle(probe).color; };
const values = { accent: tone('--ca-accent'), soft: tone('--soft-blue'), green: tone('--green'), red: tone('--red'), warning: tone('--warning') };
probe.remove();
const on = outcomes.querySelector('.outcome-list > button[aria-pressed="true"]');
const dd = selector => getComputedStyle(statRow.querySelector(`${selector} dd`)).color;
return { border: getComputedStyle(on).borderTopColor === values.accent, background: getComputedStyle(on).backgroundColor === values.soft,
added: dd('.stat.added') === values.green, bad: dd('.stat.bad') === values.red, warn: dd('.stat.warn') === values.warning };
})(),
};
}
const EXPECTED_JOB_VIEW = {
tree: MOCKUP_TREE, progress: MOCKUP_PROGRESS, results: MOCKUP_RESULTS, h2: ['Progress', 'Results'],
summary: '● Scan log — raw detail, click to show', styles: MOCKUP_STYLES,
pressed: { border: true, background: true, added: true, bad: true, warn: true },
};
async function openRunningJob(page, request, theme, viewport = { width: 1600, height: 1100 }) {
await page.setViewportSize(viewport);
const started = await request.post('/__test__/jobs/start', { data: { total: 19622 } });
await expect(started).toBeOK();
const { job } = await started.json();
await expect(await request.post(`/__test__/jobs/${job.id}/step`, { data: { processed: 8240,
counters: { known: 6511, review: 37, no_metadata: 254, errors: 18 },
records: [{ group: 'added', path: 'CCC/mabes9sans-dataset-v1/IMG_GEN_00369_.png', detail: 'Added to the Library as “Mabes portrait”' }] } })).toBeOK();
await openProduction(page, `activity/${job.id}`, theme);
await expect(page.locator('#workspace-main .file-row')).toHaveCount(1);
return job;
}
for (const theme of THEMES) test(`job view follows the approved mockup ${theme}`, async ({ page, request }) => {
await seedConcept(request);
await openRunningJob(page, request, theme);
expect(await page.evaluate(measureJobView)).toEqual(EXPECTED_JOB_VIEW);
// Mutation check: a CSS change (tile gap) and a removed element (the outcome list) are both noticed.
const undoCss = await mutate(page, { css: '#workspace-main .stat-row { gap: 3px; }' });
expect(diffInventory(EXPECTED_JOB_VIEW, await page.evaluate(measureJobView)).map(item => item.path)).toEqual(['styles.statRow.columnGap']);
await undoCss();
const undoDom = await mutate(page, { remove: ['#workspace-main details.term'] });
expect(await page.evaluate(() => [...document.querySelector('#workspace-main').children].map(node => node.localName)))
.toEqual(['div', 'section', 'section']);
await undoDom();
expect(await page.evaluate(measureJobView)).toEqual(EXPECTED_JOB_VIEW);
});
test('job view fits a phone without page scroll', async ({ page, request }) => {
await seedConcept(request);
await openRunningJob(page, request, 'dark', { width: 390, height: 844 });
const layout = await page.evaluate(() => {
const main = document.querySelector('#workspace-main');
const columns = node => getComputedStyle(node).gridTemplateColumns.split(' ').length;
return {
scroll: document.documentElement.scrollWidth <= document.documentElement.clientWidth,
tiles: columns(main.querySelector('dl.stat-row')),
outcomes: columns(main.querySelector('.outcomes')),
inside: [...main.querySelectorAll('.stat, .outcome-list button, .file-row, .search-field')]
.every(node => node.getBoundingClientRect().right <= main.getBoundingClientRect().right + 0.5),
};
});
expect(layout).toEqual({ scroll: true, tiles: 2, outcomes: 1, inside: true });
});
+3 -2
View File
@@ -1,5 +1,5 @@
import { readFileSync } from 'node:fs';
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import { test, expect } from './fixtures.mjs';
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
test.beforeEach(async ({page, browserName}) => {
@@ -57,7 +57,8 @@ test('Classic keeps immediate selection, search query and metadata serialization
});
test('Classic detaches native text synchronization on removal',async({page})=>{
await page.waitForTimeout(60);
// The page installs the text sync (serializeValue and its listeners) in a 50 ms timer; wait for it, not for time.
await expect.poll(()=>page.evaluate(()=>typeof node.widgets.find(item=>item.name==='text').serializeValue)).toBe('function');
const values=await page.evaluate(()=>{
const widget=node.widgets.find(item=>item.name==='text');
widget.inputEl.value='Before removal';widget.inputEl.dispatchEvent(new Event('input'));
+189 -3
View File
@@ -2,7 +2,7 @@
// same concept selectors in the immutable concept (/__test__/canonical/workspace) and in production, which now renders
// the concept's classes. The concept is never rewritten: each page's map<Page>Concept(reference) applies only the
// recorded spec-§8 deviations, each commented with its D-number, and diffInventory must then report nothing.
import {expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import {expect} from './fixtures.mjs';
export {diffInventory} from './library-editor-canonical-comparison.mjs';
export const CONCEPT_URL='/__test__/canonical/workspace';
@@ -97,6 +97,8 @@ export async function openProduction(page,route,theme,motion='no-preference') {
await selectTheme(page,theme);
await expect(page.locator('#workspace-main #page-title')).toBeVisible();
await expect(page.locator('#primary-nav .nav-count')).toHaveCount(3);
// SAVED VIEWS' Favorites count (D51), the concept's starred images in the concept fixture.
await expect(page.locator('#saved-nav a[data-route="favorites"] .nav-count')).toHaveCount(1);
}
/** Computed outline of the focused element, after keyboard focus so `:focus-visible` applies. */
@@ -152,7 +154,8 @@ export async function mutate(page,{css=null,remove=[]}={}) {
* history, noPageScroll}. A section is null when its element is absent. The Images sections (toolbar … panel, #249 C7)
* measure the concept's Images markup; `cards` holds the first six; `bulkbar` is the selection bar's place and, while
* anything is checked, its `.bulkbar` row. `library` (#249 E3) holds the Prompt library's toolbar,
* caption, grid, first six cards and whatever follows the grid; history stays null until PR F.
* caption, grid, first six cards and whatever follows the grid. `history` (#249 F5) holds the Prompt history's summary,
* negative variants panel, comparison (while Compare negatives is pressed) and the shared gallery's heading.
*/
export function inventoryOf(page,scope='main') {
return page.evaluate(measureInventory,scope);
@@ -441,6 +444,85 @@ export function measureInventory(scope) {
grid:{columns:style.gridTemplateColumns.split(/\s+/).length,...css(grid,'columnGap','rowGap'),...relative(grid,main),cardCount:cards.length},
cards:cards.slice(0,6).map(card=>libraryCardOf(card,grid)),trailing};
}
// ---- Prompt history (#249 F5): renderHistory a587–622, style.css 1059–1179 and 1546–1567. The concept's main holds
// the summary, the variants panel, the comparison and the gallery; production's sit in `section.library-history`, so
// each is found by descendant and placed relative to `main` (vertical rhythm) or to its own section.
const spoken=node=>node.classList.contains('ca-visually-hidden');
const outline=node=>css(node,'outlineStyle','outlineWidth','outlineColor','outlineOffset');
const pseudo=(node,which)=>{const style=getComputedStyle(node,which);return Object.fromEntries(['content','position','top','left','width','height',
'borderLeftWidth','borderBottomWidth','borderLeftColor','borderBottomColor','borderBottomLeftRadius','backgroundColor','color'].map(key=>[key,style[key]]));};
// What a section shows, in order: each shown child as `tag.classes`, visually hidden statuses apart (`spoken`).
const structureOf=node=>({children:boxedChildren(node).filter(child=>shown(child)&&!spoken(child)).map(child=>`${child.tagName.toLowerCase()}.${classes(child)}`),
spoken:[...node.querySelectorAll('.ca-visually-hidden')].filter(shown).map(child=>({role:child.getAttribute('role'),text:child.textContent}))});
function promptSectionOf(section,origin) {
const heading=section.querySelector(':scope > .prompt-heading'),label=heading?.querySelector('.role-label');
const actions=heading?.querySelector('.prompt-actions'),text=section.querySelector(':scope > .prompt-text');
return {className:classes(section),...relative(section,origin),...edges(section),
label:when(label,()=>({text:label.textContent,...font(label)})),
actions:when(actions,()=>({...layout(actions),...relative(actions,section),buttons:[...actions.children].map(node=>controlOf(node,actions))})),
text:when(text,()=>({text:text.textContent,...font(text),...edges(text),...relative(text,section),
lines:Math.round(rect(text).height/parseFloat(getComputedStyle(text).lineHeight))}))};
}
function panelHeadingOf(heading,origin) {
const h2=heading.querySelector('h2'),note=heading.querySelector('.small'),actions=heading.querySelector(':scope > .row-actions');
return {...layout(heading),...edges(heading),...relative(heading,origin),
h2:when(h2,()=>({text:h2.textContent,...font(h2),...edges(h2),...relative(h2,heading)})),
note:when(note,()=>({tag:note.tagName.toLowerCase(),text:note.textContent,...font(note),...edges(note),...relative(note,heading)})),
actions:when(actions,()=>({...layout(actions),...relative(actions,heading),buttons:[...actions.children].filter(shown).map(node=>controlOf(node,actions))}))};
}
function laneOf(lane,timeline) {
const dot=lane.querySelector(':scope > .variant-dot'),label=lane.querySelector(':scope > span'),run=lane.querySelector(':scope > .run-dot');
return {className:classes(lane),offset:lane.style.getPropertyValue('--lane-offset'),...relative(lane,timeline),...edges(lane),...css(lane,'minWidth','position'),
before:pseudo(lane,'::before'),
dot:when(dot,()=>({...controlOf(dot,lane),...outline(dot),...css(dot,'position','top','left','width','height','color')})),
label:when(label,()=>({text:label.textContent,...font(label),...relative(label,lane),...css(label,'position','top','left')})),
run:shown(run)?{...controlOf(run,lane),...css(run,'position','top','left','width','height')}:null};
}
function timelineOf(timeline,origin) {
const positive=timeline.querySelector(':scope > .positive-lane'),span=positive?.querySelector('span');
return {...edges(timeline),...css(timeline,'overflowX','position'),...relative(timeline,origin),
positive:when(positive,()=>({...edges(positive),...css(positive,'height','minWidth'),...relative(positive,timeline),before:pseudo(positive,'::before'),
span:when(span,()=>({text:span.textContent,...font(span),...relative(span,positive)}))})),
lanes:[...timeline.querySelectorAll(':scope > .variant-lane')].map(lane=>laneOf(lane,timeline)),
structure:structureOf(timeline)};
}
function variantsOf(panel) {
const heading=panel.querySelector(':scope > .panel-heading'),timeline=panel.querySelector(':scope > .timeline'),lower=panel.querySelector(':scope > .timeline-lower');
return {className:classes(panel),...edges(panel),...relative(panel,main),structure:structureOf(panel),
heading:when(heading,()=>panelHeadingOf(heading,panel)),timeline:when(timeline,()=>timelineOf(timeline,panel)),
lower:when(lower,()=>({...layout(lower),...edges(lower),...relative(lower,panel),
items:[...lower.children].filter(shown).map(node=>({...controlOf(node,lower),...font(node)}))}))};
}
function compareOf(section) {
const h2=section.querySelector(':scope > h2'),intro=section.querySelector(':scope > p:not(.small)'),grid=section.querySelector(':scope > .compare-grid');
return {...edges(section),...relative(section,main),structure:structureOf(section),
h2:when(h2,()=>({text:h2.textContent,...font(h2),...edges(h2)})),
intro:when(intro,()=>({text:intro.textContent,...font(intro),...edges(intro),...relative(intro,section)})),
grid:when(grid,()=>({columns:getComputedStyle(grid).gridTemplateColumns.split(/\s+/).length,...css(grid,'columnGap','rowGap'),...relative(grid,section),
items:[...grid.children].map(item=>({className:classes(item),...edges(item),...relative(item,grid),
sections:[...item.querySelectorAll('.prompt-section')].map(node=>promptSectionOf(node,item))}))})),
notes:[...section.querySelectorAll(':scope > p.small')].map(note=>({text:note.textContent,...font(note),...edges(note),...relative(note,section)})),
added:[...section.querySelectorAll(':scope > .diff-added')].map(chip=>({text:chip.textContent,...font(chip),...edges(chip),...css(chip,'display'),...relative(chip,section)}))};
}
function historyOf() {
const panel=main?.querySelector('section.timeline-panel');
if(!panel)return null;
const summary=main.querySelector('article.surface'),gallery=main.querySelector('section.history-gallery');
const compare=[...main.querySelectorAll('section.surface:not(.timeline-panel)')].find(shown);
const chip=summary?.querySelector(':scope > .chip'),meta=summary?.querySelector(':scope > .card-meta');
const galleryHeading=gallery?.querySelector(':scope > .panel-heading');
return {
summary:when(summary,()=>({...edges(summary),...relative(summary,main),structure:structureOf(summary),
chip:when(chip,()=>({text:chip.textContent,...font(chip),...edges(chip),...relative(chip,summary)})),
sections:[...summary.querySelectorAll('.prompt-section')].map(node=>promptSectionOf(node,summary)),
meta:when(meta,()=>({...layout(meta),...edges(meta),...font(meta),...relative(meta,summary),
items:[...meta.children].filter(shown).map(node=>({text:node.textContent,...font(node),...relative(node,meta)}))}))})),
variants:variantsOf(panel),
compare:when(compare,compareOf),
gallery:when(gallery,()=>({...edges(gallery),...relative(gallery,main),
heading:when(galleryHeading,()=>panelHeadingOf(galleryHeading,gallery))})),
};
}
return {chrome:chromeOf(),heading:headingOf(),
toolbar:when(main?.querySelector(':scope > search.toolbar'),rowOf),
filterRow:when(main?.querySelector(':scope > .filter-row'),rowOf),
@@ -450,5 +532,109 @@ export function measureInventory(scope) {
cards:grid?[...grid.querySelectorAll(':scope > .media-card')].slice(0,6).map(cardOf):null,
continuation:when(continuation,continuationOf),
panel:when(gallery,panelOf),
library:libraryOf(),history:null,noPageScroll:document.documentElement.scrollWidth<=innerWidth+1};
library:libraryOf(),history:historyOf(),noPageScroll:document.documentElement.scrollWidth<=innerWidth+1};
}
/**
* The inspector (#249 D4): `panel` is the Images side panel's content (`aside#image-inspector`, app.js imageDetails
* a400–414) and `viewerDetails` the viewer's full details (`aside#viewer-details`, imageDetails(i, true)), each null
* when absent. Both are measured by one in-page function with the concept's selectors in either document.
*/
export async function inspectorOf(page) {
return {panel:await page.evaluate(measureInspector,'#image-inspector'),
viewerDetails:await page.evaluate(measureInspector,'#viewer-details')};
}
/** Runs in the page; self-contained. Every box is relative to the measured root. */
export function measureInspector(selector) {
const root=document.querySelector(selector);
if(!root)return null;
const px=value=>Math.round(value);
const css=(node,...keys)=>{const style=getComputedStyle(node);return Object.fromEntries(keys.map(key=>[key,style[key]]));};
const shown=node=>Boolean(node)&&node.getClientRects().length>0&&getComputedStyle(node).visibility!=='hidden';
const rect=node=>node.getBoundingClientRect();
const font=node=>css(node,'fontFamily','fontSize','fontWeight','lineHeight','letterSpacing','color','textTransform');
const edges=node=>css(node,'paddingTop','paddingRight','paddingBottom','paddingLeft','marginTop','marginRight','marginBottom','marginLeft',
'borderTopWidth','borderTopStyle','borderTopColor','borderRightWidth','borderBottomWidth','borderTopLeftRadius','backgroundColor');
const layout=node=>css(node,'display','flexDirection','flexWrap','justifyContent','alignItems','columnGap','rowGap');
const box=node=>{const a=rect(node),b=rect(root);return {left:px(a.left-b.left),top:px(a.top-b.top),width:px(a.width),height:px(a.height)};};
const classes=node=>[...node.classList].sort().join(' ');
const when=(node,measure)=>node?measure(node):null;
// Visible text: without visually hidden prefixes and aria-hidden glyphs, whitespace collapsed.
const textOf=(node,skip='.ca-visually-hidden, [aria-hidden="true"]')=>{const copy=node.cloneNode(true);copy.querySelectorAll(skip).forEach(item=>item.remove());
return copy.textContent.replace(/\s+/g,' ').trim();};
const hiddenOf=node=>[...node.querySelectorAll('.ca-visually-hidden')].map(item=>item.textContent).join('')||null;
const nameOf=node=>node.getAttribute('aria-label')??textOf(node,'[aria-hidden="true"]');
function iconOf(node) {
const svg=node.querySelector(':scope > svg.icon');
if(!svg)return null;
return {width:px(rect(svg).width),height:px(rect(svg).height),viewBox:svg.getAttribute('viewBox'),hidden:svg.getAttribute('aria-hidden'),
d:svg.querySelector('path')?.getAttribute('d')??null,...css(svg,'strokeWidth','color')};
}
function controlOf(node) {
const style=getComputedStyle(node);
return {tag:node.tagName.toLowerCase(),className:classes(node),text:textOf(node),name:nameOf(node),pressed:node.getAttribute('aria-pressed'),
primary:node.classList.contains('ca-primary'),...box(node),...font(node),...edges(node),...layout(node),
minHeight:style.minHeight,minWidth:style.minWidth,icon:iconOf(node)};
}
function chipOf(node) {
return {tag:node.tagName.toLowerCase(),className:classes(node),text:textOf(node),hidden:hiddenOf(node),...box(node),...font(node),...edges(node),
minHeight:getComputedStyle(node).minHeight};
}
function rowOf(row) {
return {...layout(row),...edges(row),...box(row),chips:[...row.children].map(chipOf)};
}
function sectionOf(section) {
const heading=section.querySelector(':scope > .prompt-heading'),label=heading?.querySelector('.role-label');
const actions=heading?.querySelector('.prompt-actions'),text=section.querySelector(':scope > .prompt-text');
return {className:classes(section),...edges(section),...box(section),
heading:when(heading,()=>({...layout(heading),...edges(heading),...box(heading)})),
label:when(label,()=>({tag:label.tagName.toLowerCase(),text:label.textContent,...font(label),...box(label)})),
actions:when(actions,()=>({...layout(actions),...box(actions),buttons:[...actions.children].map(controlOf)})),
text:when(text,()=>({tag:text.tagName.toLowerCase(),className:classes(text),text:text.textContent,tabIndex:text.tabIndex,...font(text),...edges(text),
...css(text,'whiteSpace','overflowWrap'),...box(text),lines:px(rect(text).height/parseFloat(getComputedStyle(text).lineHeight))}))};
}
function factsOf(list) {
const style=getComputedStyle(list);
return {...css(list,'display','columnGap','rowGap','fontSize'),...edges(list),columns:style.gridTemplateColumns.split(/\s+/).length,...box(list),
items:[...list.children].map(item=>{const dt=item.querySelector('dt'),dd=item.querySelector('dd');
return {...box(item),dt:when(dt,()=>({text:dt.textContent,...font(dt),...edges(dt)})),dd:when(dd,()=>({text:dd.textContent,...font(dd),...edges(dd)}))};})};
}
const describe=item=>`${item.localName}${item.classList.length?'.'+[...item.classList].join('.'):''}`;
const panel=root.classList.contains('detail-panel');
// The panel's `.detail-inner`, the viewer's class-less wrapper (D20). A prompt-block mount point that is
// `display: contents` is transparent: its sections count as the wrapper's own children.
const inner=root.querySelector(panel?':scope > .detail-inner':':scope > div');
const parts=inner?[...inner.children].flatMap(item=>getComputedStyle(item).display==='contents'?[...item.children]:[item]):[];
const sections=parts.filter(item=>item.classList.contains('prompt-section'));
const firstSection=parts.indexOf(sections[0]);
const rows=parts.filter(item=>item.classList.contains('tags'));
const heading=parts.find(item=>item.classList.contains('panel-heading')),h2=heading?.querySelector('h2'),format=heading?.querySelector('.chip');
const filename=parts.find(item=>item.matches('p.small'));
const cover=root.querySelector(':scope > .detail-cover'),footer=root.querySelector(':scope > .detail-footer');
// What a closed disclosure holds is not offered (only its summary is), whatever boxes the engine keeps for it.
const offered=node=>{const closed=node.closest('details:not([open])');return !closed||node.parentElement===closed&&node.localName==='summary';};
const controls=[...root.querySelectorAll('button, a[href], [tabindex]:not([tabindex="-1"]), summary')].filter(node=>shown(node)&&offered(node));
return {
className:classes(root),label:root.getAttribute('aria-label'),
...css(root,'position','top','overflowX','overflowY','maxHeight'),...edges(root),width:px(rect(root).width),height:px(rect(root).height),
structure:{outer:[...root.children].map(describe),inner:parts.map(describe)},
cover:when(cover,()=>({tag:cover.tagName.toLowerCase(),alt:cover.getAttribute('alt'),...box(cover),...css(cover,'objectFit','display')})),
inner:when(inner,()=>({...edges(inner),...box(inner)})),
heading:when(heading,()=>({...layout(heading),...edges(heading),...box(heading),
h2:when(h2,()=>({text:h2.textContent,...font(h2),...edges(h2),...box(h2)})),format:when(format,chipOf)})),
filename:when(filename,()=>({text:filename.textContent,...font(filename),...edges(filename),...box(filename)})),
badges:when(rows.find(row=>parts.indexOf(row)<firstSection),rowOf),
sections:sections.map(sectionOf),
tags:when(rows.find(row=>parts.indexOf(row)>firstSection),rowOf),
facts:when(parts.find(item=>item.matches('dl.meta-grid')),factsOf),
// Disclosures (viewer only) by their summary and frame; their content is the D20 production facts.
details:parts.filter(item=>item.localName==='details').map(item=>{const summary=item.querySelector(':scope > summary');
return {open:item.open,...edges(item),...box(item),summary:when(summary,()=>({text:summary.textContent,...font(summary),...box(summary)}))};}),
footer:when(footer,()=>({...layout(footer),...edges(footer),...box(footer),buttons:[...footer.children].map(controlOf)})),
// The viewer's pair send and history follow the wrapper directly (imageDetails(i, true) has no footer).
actions:panel?null:[...root.children].filter(item=>item.localName==='button').map(controlOf),
controls:controls.map(node=>({tag:node.tagName.toLowerCase(),text:textOf(node),name:nameOf(node),pressed:node.getAttribute('aria-pressed'),
primary:node.classList.contains('ca-primary'),minHeight:getComputedStyle(node).minHeight})),
};
}
+33
View File
@@ -0,0 +1,33 @@
// Shared `test` for the page workflows: every Playwright worker owns one
// tests/browser/server.py process on a port the OS chose for it, with its own temp root
// and database. `baseURL` (and so `page`, `request` and `browser.newContext`)
// points at that worker's server. A server that dies fails the running test
// with its stderr tail, and the next test in the worker gets a fresh server.
// A test that fails gets the stderr its server wrote during it attached as
// `server-stderr`.
import { test as base, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import { attachServerStderr, createServerSupervisor, startBrowserServer } from '../../tools/vendor/start-browser-server.mjs';
const SERVER_FIXTURE_TIMEOUT_MS = 90_000;
export const test = base.extend({
testServer: [async ({}, use) => {
const supervisor = createServerSupervisor({ start: () => startBrowserServer() });
try {
await use(supervisor);
} finally {
await supervisor.stop();
}
}, { scope: 'worker', timeout: SERVER_FIXTURE_TIMEOUT_MS }],
workerServer: [async ({ testServer }, use, testInfo) => {
const server = await testServer.ensure();
const mark = server.stderrMark();
await use(server);
await attachServerStderr(testInfo, server, mark);
const crash = testServer.crash(server);
if (crash) throw crash;
}, { auto: true, timeout: SERVER_FIXTURE_TIMEOUT_MS }],
baseURL: async ({ workerServer }, use) => use(workerServer.url),
});
export { expect };
+1 -1
View File
@@ -1,5 +1,5 @@
import {readFileSync} from 'node:fs';
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import {test,expect} from './fixtures.mjs';
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
for (const variant of ['Focused', 'Split']) {
+60 -10
View File
@@ -1,5 +1,5 @@
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import { test, expect } from './fixtures.mjs';
import { seedConcept, openConcept, openProduction, SIZES } from './concept-inventory.mjs';
// D10/D20: the heading is the linked positive's title (its confirmed first line here), never the pair's
// "Inspector recipe", which names the negative variant.
@@ -75,7 +75,7 @@ test('inspection wire preserves exact rooted envelope', async ({ request }) => {
expect(result.metadata.prompt_graph.value['3'].inputs.cfg).toBe('6.5');
expect(result.metadata.record_formats).toEqual({ prompt: 'original-text', workflow: 'original-text' });
expect(result.file).toMatchObject({ observation: 'current-file', format: 'PNG', width: 720, height: 480 });
expect(result.library).toMatchObject({ status: 'linked', metadata_scope: 'pair', title: POSITIVE_TITLE, category: 'Inspection', tags: ['fixture', 'geometric'] });
expect(result.library).toMatchObject({ status: 'linked', category_scope: 'pair', tags_scope: 'pair', title: POSITIVE_TITLE, category: 'Inspection', tags: ['fixture', 'geometric'] });
expect((await inspect(collision)).prompts.positive.text).toBe('Input collision');
const gallery = await (await request.get(`/prompt_manager/library/${result.library.positive_id}/images?pair_id=${result.library.pair_id}`)).json();
expect(gallery.images[0].root_scope).toBe(complete.root_scope);
@@ -163,10 +163,20 @@ test('panel matches the concept order and copy',async({page,request})=>{
inner:['div.panel-heading','p.small','section.prompt-section.positive','section.prompt-section.negative','dl.meta-grid']});
await expect(panel.locator('.meta-grid dd')).toHaveText(Array(6).fill('Not recorded'));
// Ambiguous saved association: the same honest absence.
reads.patch=data=>{data.library={...data.library,status:'ambiguous',positive_id:null,pair_id:null,title:null,category:null,tags:[],metadata_scope:null};};
reads.patch=data=>{data.library={...data.library,status:'ambiguous',positive_id:null,pair_id:null,title:null,category:null,tags:[],category_scope:null,tags_scope:null};};
await selectFile(page,'workflow-only.png');await expect(panel.locator('.panel-heading h2')).toHaveText('workflow-only.png');
await expect(panel.locator('.chip:not(.blue)')).toHaveCount(1);await expect(panel.locator('.detail-inner > .tags button')).toHaveCount(0);
await expect(panel.getByRole('button',{name:'Prompt history',exact:true})).toHaveCount(0);
// D39 mixed scopes: each field follows its own source, so the prefix and the tag chips can disagree.
const tagRow=panel.locator('.detail-inner > .tags').last();
reads.patch=data=>{data.library={...data.library,status:'linked',category:'Subject',category_scope:'pair',tags_scope:'positive',tags:['alpha','beta']};};
await selectFile(page,'complete.png');
await expect(panel.locator('span.chip:not(.blue) .ca-visually-hidden')).toHaveText('Recipe category: ');
await expect(tagRow.getByRole('button')).toHaveText(['alpha','beta']);
reads.patch=data=>{data.library={...data.library,status:'linked',category:'Subject',category_scope:'positive',tags_scope:'pair',tags:['alpha','beta']};};
await selectFile(page,'workflow-only.png');
await expect(panel.locator('span.chip:not(.blue) .ca-visually-hidden')).toHaveText('Positive prompt category: ');
await expect(tagRow.locator('span.chip')).toHaveText(['alpha','beta']);await expect(tagRow.getByRole('button')).toHaveCount(0);
// Loading and error states render in `.detail-inner` as `.small` text with the existing buttons.
reads.patch=null;await page.unroute('**/images/inspect/**');
await page.route('**/images/inspect/**',route=>route.fulfill({status:500,json:{success:false,error:'broken'}}));
@@ -243,7 +253,8 @@ test('viewer details hold provenance, raw records and downloads',async({page,req
const provenance=details.locator('details').first();await provenance.locator('summary').click();
await expect(provenance.locator('p.small').first()).toHaveText('Current file at this source; historical bytes are not verified.');
await expect(provenance.locator('dl.meta-grid dt')).toHaveText(['Source','Path','Size','Dimensions','Format','Media type','Modified']);
await expect(provenance.locator('dl.meta-grid dd').nth(1)).toHaveText('inspector/complete.png');
// D38: the Path value stays text, with "Show in folder" right after it.
await expect(provenance.locator('dl.meta-grid dd').nth(1)).toHaveText('inspector/complete.png Show in folder');
await expect(provenance.getByText('Prompt evidence: workflow inferred',{exact:true})).toBeVisible();
await expect(provenance.getByText('Saved association; not verified against current file',{exact:true})).toBeVisible();
await expect(provenance.getByRole('link',{name:'Download original',exact:true})).toBeVisible();
@@ -271,6 +282,41 @@ test('viewer details hold provenance, raw records and downloads',async({page,req
await expect(page.locator('#viewer-dialog')).toHaveCount(0);
});
// D38 Show in folder: a listed file's Path offers it; the stubbed opener records what the route resolved.
test('Show in folder asks the server to open the listed file and announces each outcome',async({page,request})=>{
await fullFixture(page,request);
await expect(inspector(page).getByRole('button',{name:/in folder$/})).toHaveCount(0);
const details=await viewerDetails(page,'complete.png');
const provenance=details.locator('details').first();await provenance.locator('summary').click();
const path=provenance.locator('dl.meta-grid > div').nth(1);await expect(path.locator('dt')).toHaveText('Path');
const show=path.locator('dd > button');
await expect(show).toHaveText('Show in folder');await expect(show).toHaveAccessibleName('Show complete.png in folder');
const toast=page.locator('#workspace-toast');
const [sent]=await Promise.all([page.waitForRequest(entry=>entry.url().endsWith('/prompt_manager/images/reveal')),show.click()]);
expect(sent.method()).toBe('POST');
// The route takes only a same-origin JSON request, as a real browser sends it.
const sentHeaders=await sent.allHeaders();
expect(sentHeaders.origin).toBe(new URL(page.url()).origin);expect(sentHeaders['content-type']).toBe('application/json');
const body=sent.postDataJSON();
expect(Object.keys(body).sort()).toEqual(['path','root_index','root_scope']);
expect(body).toMatchObject({root_index:0,path:'inspector/complete.png'});expect(body.root_scope).toMatch(/^[0-9a-f]{64}$/);
await expect(toast).toHaveText('Opened the folder on this computer.');
const opened=async()=>(await (await request.get('/__test__/reveal')).json()).opened;
expect(await opened()).toEqual(['output/inspector/complete.png']);
for (const [outcome,message] of [['unavailable','No file manager is available on this computer.'],['fail','The folder could not be opened. Try again.']]) {
await expect(await request.post('/__test__/reveal',{data:{outcome}})).toBeOK();
await show.click();await expect(toast).toHaveText(message);
}
// A reveal already in progress answers 429 busy.
await page.route('**/prompt_manager/images/reveal',route=>route.fulfill({status:429,contentType:'application/json',body:JSON.stringify({success:false,code:'busy',error:'The folder is already opening.'})}));
await show.click();await expect(toast).toHaveText('The folder is already opening.');
await page.unroute('**/prompt_manager/images/reveal');
// A proxied request counts as remote, so the real route answers 403 not-local.
await expect(await request.post('/__test__/reveal',{data:{outcome:'opened'}})).toBeOK();
await page.route('**/prompt_manager/images/reveal',route=>route.continue({headers:{...route.request().headers(),'x-forwarded-for':'203.0.113.7'}}));
await show.click();await expect(toast).toHaveText('Show in folder works only in a browser on the computer running ComfyUI.');
expect(await opened()).toEqual(['output/inspector/complete.png']);
});
test('full inspector shows six facts and scoped provenance',async({page,request},testInfo)=>{
await page.setViewportSize({width:1600,height:1100});await fullFixture(page,request);const panel=inspector(page);
await expect(panel.getByRole('heading',{name:POSITIVE_TITLE,exact:true})).toBeVisible();await expect(panel.locator('.panel-heading .chip')).toHaveText('PNG');await expect(panel.locator('.detail-inner > p.small')).toHaveText('complete.png');
@@ -278,12 +324,12 @@ test('full inspector shows six facts and scoped provenance',async({page,request}
expect(await panel.locator('.detail-inner').evaluate(node=>getComputedStyle(node).padding)).toBe('17px');
if (!await page.evaluate(()=>matchMedia('(pointer: coarse)').matches)) {
// The concept's prompt actions (`.prompt-actions button.icon-btn`) measure 31×31 in the mock.
const copy=await panel.getByRole('button',{name:'Copy positive prompt',exact:true}).boundingBox();expect(copy.width).toBe(31);expect(copy.height).toBe(31);
const copy=await panel.getByRole('button',{name:/^Copy positive prompt: /}).boundingBox();expect(copy.width).toBe(31);expect(copy.height).toBe(31);
}
await expect(panel.locator('.meta-grid dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);
await expect(panel.locator('.meta-grid dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']);
await selectFile(page,'empty.png');await expect(panel.locator('.panel-heading .chip')).toHaveText('PNG');await expect(panel.getByRole('heading',{name:'empty.png',exact:true})).toBeVisible();
await expect(panel.getByRole('button',{name:'Send negative prompt to ComfyUI',exact:true})).toHaveCount(0);await expect(panel).toContainText('Not recorded in this file.');await expect(panel.getByRole('button',{name:'Prompt history',exact:true})).toHaveCount(0);
await expect(panel.getByRole('button',{name:/^Send negative prompt to ComfyUI: /})).toHaveCount(0);await expect(panel).toContainText('Not recorded in this file.');await expect(panel.getByRole('button',{name:'Prompt history',exact:true})).toHaveCount(0);
await selectFile(page,'explicit-empty.png');await expect(panel.locator('.prompt-section.negative p.prompt-text')).toHaveText('No negative prompt (explicitly empty)');await expect(panel.getByRole('button',{name:/^Send prompt pair/})).toHaveCount(1);
// Provenance lives in the viewer's full details (D20): one "Prompt evidence" line replaces the per-fact source lines.
const details=await viewerDetails(page,'complete.png');
@@ -356,7 +402,7 @@ test('full inspector keeps long literal content reachable on mobile',async({page
const literal='<svg onload="window.executed=true"><image href="https://invalid.test/never"/></svg></pre>'+'verylongmodelpath/'.repeat(30);
let external=0;await page.route('https://invalid.test/**',route=>{external++;return route.abort();});
await page.route('**/images/inspect/**',async route=>{const response=await route.fetch();const data=await response.json();data.metadata.raw.parameters=literal;data.metadata.record_formats.parameters='original-text';data.metadata.parameters=literal;data.metadata.prompt_graph.value['4'].inputs.ckpt_name=literal;await route.fulfill({response,json:data});});
await page.setViewportSize({width:390,height:844});await fullFixture(page,request);const panel=inspector(page);await expect(panel.locator('.prompt-section.positive p.prompt-text')).toContainText('fine geometry and light.');await panel.getByRole('button',{name:'Copy positive prompt',exact:true}).click();const fallback=page.getByRole('dialog',{name:'Copy prompt',exact:true});await expect(fallback.getByRole('textbox')).toHaveValue(/^Inspector portrait 猫 😀\nFull prompt context, fine geometry and light\./);await page.keyboard.press('Escape');await expect(fallback).toBeHidden();
await page.setViewportSize({width:390,height:844});await fullFixture(page,request);const panel=inspector(page);await expect(panel.locator('.prompt-section.positive p.prompt-text')).toContainText('fine geometry and light.');await panel.getByRole('button',{name:/^Copy positive prompt: /}).click();const fallback=page.getByRole('dialog',{name:'Copy prompt',exact:true});await expect(fallback.getByRole('textbox')).toHaveValue(/^Inspector portrait 猫 😀\nFull prompt context, fine geometry and light\./);await page.keyboard.press('Escape');await expect(fallback).toBeHidden();
await expect(panel.locator('.meta-grid dd').last()).toHaveText(literal);
for(const zoom of [1,2]) {await page.evaluate(zoom=>{document.documentElement.style.zoom=String(zoom);},zoom);await expect.poll(()=>page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);const footer=panel.locator('.detail-footer button.send');await footer.focus();await expect(footer).toBeFocused();}
await page.evaluate(()=>{document.documentElement.style.zoom='1';});
@@ -585,11 +631,15 @@ test('collection viewer follows filtered loaded order beyond mounted cards',asyn
await expect(onePlay).toHaveAccessibleDescription('Slideshow requires at least two loaded media entries.');await onePlay.dispatchEvent('click');await expect(onePlay).toHaveText('Play');
await mediaDialog(page).getByRole('button',{name:'Close image viewer',exact:true}).click();
const library=await(await request.get('/prompt_manager/library')).json();const id=library.items.find(item=>item.text.startsWith('Inspector portrait')).id;
await page.goto(`/prompt_manager/workspace#/library/${id}`);await page.getByRole('button',{name:'Select negative variant 1',exact:true}).click();await selectFile(page,'complete.png');await openCollection(page);
await page.goto(`/prompt_manager/workspace#/library/${id}`);await page.getByRole('button',{name:'Select negative Inspector recipe',exact:true}).click();await selectFile(page,'complete.png');await openCollection(page);
await expect(position(page)).toContainText('of 1 loaded: complete.png');
await mediaDialog(page).getByRole('button',{name:'Close image viewer',exact:true}).click();
// #249 D26: the whole positive's images are in the All images dialog's own embedded gallery.
await page.getByRole('button',{name:'All images for this positive',exact:true}).click();
await selectFile(page,'complete.png');await openCollection(page);await expect(position(page)).toContainText('of 2 loaded:');
const listing=page.getByRole('dialog',{name:'All images for this positive',exact:true});
await listing.locator('article[data-image-key="0:inspector/complete.png"] .image-card-actions button').click();
await listing.locator('.media-card.selected .image-open').click();await expect(mediaDialog(page).locator('.viewer-canvas img')).toBeVisible();
await expect(position(page)).toContainText('of 2 loaded:');
});
test('collection viewer uses real public transforms and resets on navigation',async({page,request})=>{
@@ -719,7 +769,7 @@ test('slideshow interaction visibility sender and disposal clear intent',async({
for(const action of [
()=>modal.locator('.prompt-section.positive p.prompt-text').click(),
()=>modal.locator('#viewer-details').dispatchEvent('wheel',{deltaY:100}),
()=>modal.getByRole('button',{name:'Copy positive prompt',exact:true}).click(),
()=>modal.getByRole('button',{name:/^Copy positive prompt: /}).click(),
async()=>{await modal.getByText('Workflow & raw metadata',{exact:true}).click();await downloadText(page,modal.getByRole('button',{name:'Download metadata JSON',exact:true}));},
async()=>{await page.evaluate(()=>{Object.defineProperty(document,'hidden',{configurable:true,value:true});document.dispatchEvent(new Event('visibilitychange'));Object.defineProperty(document,'hidden',{configurable:true,value:false});document.dispatchEvent(new Event('visibilitychange'));});},
async()=>{const trigger=modal.getByRole('button',{name:/^Send prompt pair to ComfyUI: /});await trigger.click();await expect(page.getByRole('dialog',{name:'Send to ComfyUI',exact:true})).toBeVisible();await page.keyboard.press('Escape');await expect(trigger).toBeFocused();},
+25 -26
View File
@@ -1,5 +1,5 @@
import { writeFile } from 'node:fs/promises';
import { expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import { expect } from './fixtures.mjs';
// Content, row counts, and image bytes come from independent synthetic datasets.
// Saved choices and actual executions are separate production sections. The concept's
@@ -10,13 +10,14 @@ import { expect } from '../../tools/vendor/node_modules/@playwright/test/index.m
export const canonicalDifferences = {
data: ['Independent synthetic text, image bytes, IDs, counts and text wrapping'],
semantics: [
'Saved combinations are distinct from the additional chronological executions',
'Negative variants are saved combinations; Recorded runs list the chronological executions',
'Recorded run exposes exact frozen evidence; Repeat run is illustrative',
'Current-file candidates disclose unknown identity and context membership',
],
// Closed by #249: the gallery's checkbox is Images-only (#259) and the role blocks are the concept's (PR D, F); the
// exact history comparison is tests/browser/history-concept.spec.mjs.
remainingApplicationPolish: [
'Gallery card select checkbox (#249 D8 follow-up) and tag button (#249 D9, image-annotations slice) remain outside #209',
'Shared role-block spacing, padding and glyph styling remain outside #209; exact role order/actions and evidence disclosure are covered',
'Gallery card tag button (#249 D9, image-annotations slice) remains outside #209',
'Production keeps mobile toolbar inside media panel and readable scrolling details; the concept toolbar collision is not reproduced',
],
};
@@ -33,29 +34,27 @@ export async function captureCanonicalState(page, testInfo, name, selector) {
await testInfo.attach(name, { path, contentType: 'image/png' });
}
const CONCEPT_HISTORY_SELECTORS = Object.freeze({
surface: 'article.surface', timeline: '.timeline', positive: '.positive-lane', branch: '.variant-lane',
label: '.variant-lane > span', dot: '.variant-dot[aria-pressed="true"]', role: 'article.surface .prompt-section',
positiveLabel: '.positive-lane span',
prompt: 'article.surface .prompt-text', gallery: '.history-gallery',
heading: '.timeline-panel h2', intro: '.timeline-panel .panel-heading .small',
footer: '.timeline-lower', all: '.timeline-lower button',
repeat: '.timeline .run-dot:not([hidden])',
});
const RECORDED_HISTORY_SELECTORS = Object.freeze({
recordedPositiveLabel: '.history-positive-lane span', recordedBranch: '.history-context-branch',
recordedHeading: '.recorded-history h2', recordedTimeline: '.history-timeline', recordedCanvas: '.history-canvas',
recordedPositive: '.history-positive-lane', recordedLabel: '.history-context-label',
recordedAnchor: '.history-context-anchor', recordedRepeat: '.history-run-marker:not(.history-context-anchor)',
recordedMarker: '.history-run-marker', recordedColumn: '.history-run-column',
});
export async function historyGeometry(page, production) {
const selectors = production ? {
surface: '.library-surface', timeline: '.library-branches', positive: '.library-positive-lane',
branch: '.library-variant-lane', label: '.library-variant-lane span', dot: '.library-variant-dot[aria-pressed="true"]',
positiveLabel: '.library-positive-lane span',
role: '[aria-label="Saved prompt texts"] .prompt-section',
prompt: '[aria-label="Saved prompt texts"] .prompt-text', gallery: '.library-shared-gallery',
heading: '.library-variants h2', intro: '.library-variants > p.muted',
footer: '.library-variant-footer', all: '.library-variants button:last-child',
recordedPositiveLabel: '.history-positive-lane span', recordedBranch: '.history-context-branch',
recordedHeading: '.recorded-history h2', recordedTimeline: '.history-timeline', recordedCanvas: '.history-canvas',
recordedPositive: '.history-positive-lane', recordedLabel: '.history-context-label',
recordedAnchor: '.history-context-anchor', recordedRepeat: '.history-run-marker:not(.history-context-anchor)',
recordedMarker: '.history-run-marker', recordedColumn: '.history-run-column',
} : {
surface: '.surface', timeline: '.timeline', positive: '.positive-lane', branch: '.variant-lane',
label: '.variant-lane span', dot: '.variant-dot[aria-pressed="true"]', role: '.surface .prompt-section',
positiveLabel: '.positive-lane span',
prompt: '.surface .prompt-text', gallery: '.history-gallery',
heading: '.timeline-panel h2', intro: '.timeline-panel .panel-heading .small',
footer: '.timeline-lower', all: '.timeline-lower button',
repeat: '.timeline .run-dot',
};
// Production renders the concept's history classes (#249 PR F), so both documents use the concept's selectors;
// production adds the recorded executions (#209), which the concept does not have.
const selectors = production ? { ...CONCEPT_HISTORY_SELECTORS, ...RECORDED_HISTORY_SELECTORS } : CONCEPT_HISTORY_SELECTORS;
return page.evaluate(selectors => ({ pointerCoarse: matchMedia('(pointer: coarse)').matches,
...Object.fromEntries(Object.entries(selectors).map(([key, selector]) => {
const nodes = [...document.querySelectorAll(selector)];
+294
View File
@@ -0,0 +1,294 @@
import {writeFile} from 'node:fs/promises';
import {test,expect} from './fixtures.mjs';
import {captureCanonicalState} from './history-canonical-comparison.mjs';
import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact} from './concept-inventory.mjs';
// Task F5 of #249: the production Prompt history (#/library/<id>) against the immutable concept's (#prompt/alpine,
// app.js renderHistory a587–622, the dialogs a1282–1304, style.css 1059–1179 and 1546–1567), on the concept-shaped real
// fixture: Alpine observatory with its three saved negatives, four images linked to their pairs, four recorded runs and
// real pair run counts (2, 1, 1). Heading, summary, the negative variants panel, the comparison and the shared gallery's
// heading are compared at 1600x1100 and 390x844 in both themes and motion settings, with the page's behaviors in both
// documents. Both documents are measured with "Clean architecture" selected: it is the middle lane in either order
// (D46). The topbar and sidebar are the shell spec's, the gallery's cards the Images spec's (D47). Paired captures are
// artifacts only; there are no PNG baselines.
test.use({locale:'en-US',timezoneId:'UTC'});
const TITLE='Alpine observatory';
const SELECTED='Clean architecture';
const LANES=3;
const SHOW_ARCHIVED='Show archived (0)';
const SECTIONS=['heading','history','noPageScroll'];
/**
* Recorded history deviations only (spec §8, TESTING.md D24–D26, D35, D45–D47); everything else must equal production.
* `reference.oracle` is the concept page itself, measured once its lanes list newest first with the singular for one
* (D46, D35) and once the approved Show archived button (#235) follows + Add negative: the browser lays out what moves.
*/
export function mapHistoryConcept(reference) {
const {history}=reference.oracle;
return {...reference,history:{...history,
// D24: "N tracked runs" is the recorded window's `recorded_run_count`; the fixture records one run per image, so
// the concept's "4 tracked runs" holds by construction (asserted on both documents below).
summary:history.summary,
variants:{...history.variants,
// D45: the bounded "N of M saved negative variants" count stays a visually hidden status.
structure:{...history.variants.structure,spoken:[{role:'status',text:`${LANES} of ${LANES} saved negative variants`}]}},
compare:history.compare&&{...history.compare,grid:{...history.compare.grid,items:history.compare.grid.items.map((item,index)=>({...item,
// D46: the two compared blocks are named Baseline and Selected, so no action name repeats on the page.
sections:item.sections.map(section=>({...section,actions:{...section.actions,buttons:section.actions.buttons.map(button=>({...button,
label:button.label.replace(/^(Copy|Send) negative/,`$1 ${COMPARED[index]} negative`)}))}}))}))}},
// D47: the gallery's body is the Images collection (layout row, cards, side panel), so only its place, frame and
// heading are compared here; the cards are the Images comparison's.
gallery:history.gallery&&withoutHeight(history.gallery),
}};
}
const COMPARED=['baseline','selected'];
const withoutHeight=({height,...gallery})=>gallery;
function compare(reference,actual) {
const only=inventory=>Object.fromEntries(SECTIONS.map(key=>[key,key==='history'&&inventory.history?.gallery
?{...inventory.history,gallery:withoutHeight(inventory.history.gallery)}:inventory[key]]));
return diffInventory(stripExact(only(mapHistoryConcept(reference))),stripExact(only(actual)));
}
/** The comparison as a gate: throws, naming every differing path, unless production equals the mapped concept. */
function assertHistoryMatch(reference,actual) {
const diff=compare(reference,actual);
if(diff.length)throw new Error(`the Prompt history differs from the concept at ${diff.map(item=>item.path).join(', ')}`);
}
// Two frames, then every running transition finished.
const settle=page=>page.evaluate(async()=>{
await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));
await Promise.race([Promise.all(document.getAnimations().map(animation=>animation.finished.catch(()=>{}))),
new Promise(resolve=>setTimeout(resolve,2000))]);
});
const decoded=page=>page.evaluate(async()=>{
await document.fonts.ready;
await Promise.all([...document.querySelectorAll('.history-gallery img')].map(img=>{img.loading='eager';return img.decode().catch(()=>{});}));
});
const dot=(page,name)=>page.getByRole('button',{name:`Select negative ${name}`,exact:true});
const compareButton=page=>page.getByRole('button',{name:'Compare negatives',exact:true});
const galleryCards=page=>page.locator('.history-gallery .media-card');
/** Press a variant dot and wait until the summary and the gallery follow it. */
async function selectVariant(page,name) {
await dot(page,name).click();
await expect(dot(page,name)).toHaveAttribute('aria-pressed','true');
await expect(page.locator('.history-gallery .panel-heading .small')).toHaveText(`${name} · same positive prompt`);
await expect(galleryCards(page).first()).toBeVisible();
await page.mouse.move(0,0);await decoded(page);await settle(page);
}
async function setCompare(page,pressed) {
const button=compareButton(page);
if(await button.getAttribute('aria-pressed')!==String(pressed))await button.click();
await expect(compareButton(page)).toHaveAttribute('aria-pressed',String(pressed));
await page.mouse.move(0,0);await settle(page);
}
async function openConceptHistory(page,theme,motion) {
await openConcept(page,'prompt/alpine',theme,motion);
await expect(page.locator('.timeline .variant-lane')).toHaveCount(LANES);
await selectVariant(page,SELECTED);
}
async function openProductionHistory(page,theme,motion,positiveId) {
await openProduction(page,`library/${positiveId}`,theme,motion);
await expect(page.locator('.timeline .variant-lane')).toHaveCount(LANES);
await expect(page.locator('article.surface .card-meta > span').first()).toBeVisible();
await selectVariant(page,SELECTED);
}
// D45: the recorded production rules that lay the panel out (workspace.css): the panel heading and the legend row wrap,
// All images wraps its label, and a lane's text wraps before a shown repeat-run dot. Applied to the concept so the browser
// lays out what follows; at 1600px nothing wraps.
const D45_LAYOUT=`.timeline-panel > .panel-heading, .timeline-lower { flex-wrap: wrap; }
.variant-lane:has(> .run-dot:not([hidden])) > span { max-inline-size: 139px; overflow-wrap: anywhere; }
.ca-tools .timeline-lower > button { max-inline-size: 100%; white-space: normal; }`;
/**
* The concept laid out as production lists it: lanes newest first with their offsets (D46), "1 run" in the singular
* (D35), the approved Show archived button after + Add negative (#235) and D45's layout rules. Returns the undo.
*/
async function conceptAsRecorded(page) {
const restoreLayout=await mutate(page,{css:D45_LAYOUT});
await page.evaluate(label=>{
const timeline=document.querySelector('.timeline'),lanes=[...timeline.querySelectorAll(':scope > .variant-lane')];
const texts=lanes.map(lane=>[lane.querySelector(':scope > span'),lane.querySelector(':scope > span').textContent]);
lanes.reverse().forEach((lane,index)=>{lane.style.setProperty('--lane-offset',`${38+index*47}px`);timeline.append(lane);});
for(const [span,text] of texts)span.textContent=text.replace(/(^|\D)1 runs$/,(match,before)=>`${before}1 run`);
const add=[...document.querySelectorAll('.timeline-panel .row-actions > button')].find(node=>node.textContent==='+ Add negative');
const archived=add.cloneNode(false);archived.removeAttribute('data-action');archived.removeAttribute('data-prompt');
archived.setAttribute('aria-pressed','false');archived.textContent=label;add.after(archived);
window.__historyOracle={timeline,order:[...lanes].reverse(),texts,archived};
},SHOW_ARCHIVED);
await settle(page);
return ()=>page.evaluate(()=>{
const {timeline,order,texts,archived}=window.__historyOracle;
order.forEach((lane,index)=>{lane.style.setProperty('--lane-offset',`${38+index*47}px`);timeline.append(lane);});
for(const [span,text] of texts)span.textContent=text;
archived.remove();delete window.__historyOracle;
}).then(restoreLayout);
}
/** The concept inventory, with `oracle`: the same page laid out as recorded (`conceptAsRecorded`). */
async function conceptInventory(page) {
const reference=await inventoryOf(page,'#main');
const restore=await conceptAsRecorded(page);
const oracle=await inventoryOf(page,'#main');
await restore();await settle(page);
return {...reference,oracle};
}
const negativeText=page=>page.locator('article.surface .prompt-section.negative .prompt-text').textContent();
const galleryOf=async page=>({context:await page.locator('.history-gallery .panel-heading .small').textContent(),
cards:await galleryCards(page).locator('.image-open').evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')))});
/** A dialog the page opens: its accessible name, how many media cards it shows once loaded and its captions; then closed. */
async function dialogOf(page,trigger,{name,cards}) {
await trigger.click();
const dialog=page.getByRole('dialog',{name,exact:true});
await expect(dialog).toBeVisible();
if(cards)await expect(dialog.locator('.media-card')).toHaveCount(cards);
// D47: neither document's media dialog carries a collection caption ("Linked images", "N loaded · …").
const captions=await dialog.locator('.section-caption').count();
const result=cards?{name,cards:await dialog.locator('.media-card').count(),captions}:{name,captions};
await page.keyboard.press('Escape');await expect(dialog).toHaveCount(0);
return result;
}
/** The behaviors Task F5 lists, with identical selectors in both documents. */
async function behaviorsOf(page,{sent,runDialog}) {
await selectVariant(page,'Quality baseline');
const before={negative:await negativeText(page),gallery:await galleryOf(page)};
await selectVariant(page,SELECTED);
const after={negative:await negativeText(page),gallery:await galleryOf(page)};
const pressed=[await compareButton(page).getAttribute('aria-pressed')];
await setCompare(page,true);pressed.push(await compareButton(page).getAttribute('aria-pressed'));
const compared=await page.locator('section.surface:not(.timeline-panel) > h2').textContent();
await setCompare(page,false);pressed.push(await compareButton(page).getAttribute('aria-pressed'));
const all=await dialogOf(page,page.getByRole('button',{name:'All images for this positive',exact:true}),{name:'All images for this positive',cards:4});
const run=await dialogOf(page,page.getByRole('button',{name:'Show repeat run for Quality baseline',exact:true}),{name:runDialog});
await page.locator('.heading-actions').getByRole('button',{name:`Send prompt pair to ComfyUI: ${TITLE}`,exact:true}).click();
const send=await sent(page);
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
await expect(page.locator('h1#page-title')).toHaveText('Prompt library');
return {variant:{before,after},pressed,compared,all,run,send,back:await page.locator('h1#page-title').textContent()};
}
/** The concept's demo sender: the roles its preview lists, with their text. */
async function conceptSent(page) {
const modal=page.locator('dialog#modal[open]');
await expect(modal.locator('#modal-title')).toHaveText('Send to ComfyUI');
const roles=await modal.locator('.send-preview').evaluateAll(nodes=>Object.fromEntries(nodes.map(node=>
[node.querySelector('h3').textContent.startsWith('+')?'positive':'negative',node.querySelector('p').textContent])));
await page.keyboard.press('Escape');await expect(modal).toHaveCount(0);
return roles;
}
/** Production's acknowledged sender against a bridge host page: the roles its preview asked the host to change. */
function productionSent(host) {
return async page=>{
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'Concept ComfyUI'});
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'Concept destination'});
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
await expect.poll(()=>host.evaluate(()=>window.previewed.length)).toBe(1);
const roles=await host.evaluate(()=>window.previewed.pop());
await page.keyboard.press('Escape');await expect(sender).toHaveCount(0);
return roles;
};
}
async function openHost(context) {
const host=await context.newPage();await host.goto('/prompt_manager/workspace#/overview');
await host.evaluate(async()=>{
const {createBridgeHost}=await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');
const target={id:'pair-1',label:'Concept destination',type:'PromptManagerPair',roles:['positive','negative']};
window.previewed=[];
window.conceptHost=createBridgeHost({origin:location.origin,namespace:'/',label:'Concept ComfyUI',adapter:{
listTargets:()=>[target],
preview:(_id,roles)=>{window.previewed.push(roles);return {preview:{target,fields:Object.entries(roles).map(([role,after])=>({role,before:`Previous ${role}`,after})),
composition:{prepend:'',append:'',prependConnected:false,appendConnected:false,before:'Previous positive',after:roles.positive??'Previous positive'}},snapshot:roles};},
apply:roles=>({targetId:target.id,values:roles}),
}});
});
return host;
}
test.beforeEach(async({page,request},testInfo)=>{
testInfo.manifest=await seedConcept(request);
await page.addInitScript(()=>{window.copied=[];Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>{window.copied.push(text);}}});});
});
for(const [width,height] of SIZES)for(const theme of THEMES)test(`history geometry, tokens, controls and behaviors match the concept ${width}x${height} ${theme}`,async({page,context},testInfo)=>{
test.setTimeout(240_000);
await page.setViewportSize({width,height});
const positiveId=testInfo.manifest.prompts[0].positive_id;
const host=await openHost(context);
try {
const result={};
for(const motion of MOTIONS) {
const capture=motion==='no-preference';
await openConceptHistory(page,theme,motion);
const reference=await conceptInventory(page);
await setCompare(page,true);
const referenceCompared=await conceptInventory(page);
if(capture)await captureCanonicalState(page,testInfo,`concept-history-${width}-${theme}`);
const referenceBehaviors=capture?await behaviorsOf(page,{sent:conceptSent,runDialog:'Repeat run'}):null;
await openProductionHistory(page,theme,motion,positiveId);
const actual=await inventoryOf(page,'#workspace-main');
await setCompare(page,true);
const actualCompared=await inventoryOf(page,'#workspace-main');
if(capture)await captureCanonicalState(page,testInfo,`production-history-${width}-${theme}`);
const actualBehaviors=capture?await behaviorsOf(page,{sent:productionSent(host),runDialog:'Recorded run'}):null;
result[motion]={reference,actual,diff:compare(reference,actual),referenceCompared,actualCompared,
compareDiff:compare(referenceCompared,actualCompared),referenceBehaviors,actualBehaviors};
}
const path=testInfo.outputPath('history-geometry.json');await writeFile(path,JSON.stringify(result,null,2));
await testInfo.attach('history-geometry',{path,contentType:'application/json'});
for(const [motion,{diff,compareDiff,actual,referenceCompared}] of Object.entries(result)) {
// The concept itself is what this comparison assumes (style.css 709–722, 1059–1179, 1426, 1546).
const {summary,variants,compare:compared,gallery}=referenceCompared.history;
expect.soft(summary,`${motion} concept surface`).toMatchObject({paddingTop:'22px',paddingLeft:'22px',borderTopLeftRadius:'7px',borderTopWidth:'1px'});
// D24: the fixture's four recorded runs give the concept's own "4 tracked runs"; production reads them.
expect.soft(summary.meta.items.map(item=>item.text),`${motion} concept counts`).toEqual(['4 tracked runs','4 images','3 negative variants']);
expect.soft(actual.history.summary.meta.items.map(item=>item.text),`${motion} tracked runs`).toEqual(['4 tracked runs','4 images','3 negative variants']);
expect.soft(variants.timeline,`${motion} concept timeline`).toMatchObject({paddingTop:'18px',paddingLeft:'20px',borderTopLeftRadius:'6px'});
variants.timeline.lanes.forEach((lane,index)=>{
expect.soft(lane,`${motion} concept lane ${index}`).toMatchObject({offset:`${38+index*47}px`,marginLeft:`${38+index*47}px`,height:57});
expect.soft(lane.dot,`${motion} concept dot ${index}`).toMatchObject({width:'29px',height:'29px',text:'−'});
});
expect.soft(variants.timeline.lanes.map(lane=>lane.run&&{width:lane.run.width,left:lane.run.left}),`${motion} concept run dots`).toEqual([{width:'12px',left:'190px'},null,null]);
expect.soft(compared.grid,`${motion} concept compare grid`).toMatchObject({columns:width>700?2:1,columnGap:'15px'});
expect.soft(compared.added.map(chip=>chip.text),`${motion} concept added terms`).toEqual(['+ distorted architecture','+ watermark','+ text']);
expect.soft(compared.added[0],`${motion} concept diff chip`).toMatchObject({fontSize:'11px',paddingTop:'3px',paddingLeft:'7px',borderTopLeftRadius:'4px',display:'inline-block'});
expect.soft(gallery,`${motion} concept gallery`).toMatchObject({marginTop:'24px',heading:{h2:{text:'Shared gallery',fontSize:'14px'},note:{text:`${SELECTED} · same positive prompt`}}});
expect.soft(diff,`${motion} differences after the recorded deviations`).toEqual([]);
expect.soft(compareDiff,`${motion} differences with Compare negatives pressed`).toEqual([]);
expect.soft(actual.noPageScroll,`${motion} no page-level horizontal scroll`).toBe(true);
}
const {referenceBehaviors:concept,actualBehaviors:production}=result['no-preference'];
expect.soft(production,'behaviors').toEqual({...concept,run:{...concept.run,name:'Recorded run'}});
} finally {await host.evaluate(()=>window.conceptHost.dispose());await host.close();}
});
// The comparison is only worth something if it notices a change (spec §7 mutation check): one CSS mutation (the variant
// dot's width) and one DOM mutation (Compare negatives removed), each applied after an exact match.
test('history comparison detects mutations',async({page},testInfo)=>{
await page.setViewportSize({width:1600,height:1100});
await openConceptHistory(page,'dark','no-preference');
const reference=await conceptInventory(page);
await openProductionHistory(page,'dark','no-preference',testInfo.manifest.prompts[0].positive_id);
const measure=()=>inventoryOf(page,'#workspace-main');
assertHistoryMatch(reference,await measure());
const restoreCss=await mutate(page,{css:'.ca-tools .variant-dot { width: 20px }'});
const cssMutated=await measure();
expect(()=>assertHistoryMatch(reference,cssMutated)).toThrow(/history\.variants\.timeline\.lanes\.0\.dot\.width/);
await restoreCss();await settle(page);
assertHistoryMatch(reference,await measure());
await compareButton(page).evaluate(node=>node.remove());
const domMutated=await measure();
expect(()=>assertHistoryMatch(reference,domMutated)).toThrow(/history\.variants\.heading\.actions\.buttons\.0\.text/);
});
+14 -13
View File
@@ -1,5 +1,5 @@
import {writeFile} from 'node:fs/promises';
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import {test,expect} from './fixtures.mjs';
import {captureCanonicalState} from './history-canonical-comparison.mjs';
import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact,countCopy,reflow,withoutItems} from './concept-inventory.mjs';
@@ -7,7 +7,7 @@ import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,
// images of the concept's six prompts, its seeds, sizes and favorites). Heading, toolbar, filter row, caption, grid,
// the first six cards, the continuation and the panel box are compared at 1600x1100 and 390x844 in both themes, and the
// page's behaviors under normal and reduced motion; grid, cards and panel also at 1200x900 and 1800x1100 (2 and 4
// columns, the 275px and 310px panel). The topbar and sidebar (D5, D28, D29) are the shell spec's.
// columns, the 275px and 310px panel). The topbar and sidebar (D28, D29, D52, D53) are the shell spec's.
// Paired captures are artifacts only; there are no PNG baselines.
test.use({locale:'en-US',timezoneId:'UTC'});
@@ -85,8 +85,8 @@ const nocase=(a,b)=>{const x=a.toLowerCase(),y=b.toLowerCase();return x<y?-1:x>y
export function mapImagesConcept(reference,{manifest,now=new Date()}) {
const paths=new Map(manifest.images.map(image=>[image.filename,image.path]));
const toolbar=withoutItems(reference.toolbar,item=>item.text==='Save view');
// D16 (follow-up PR): no Save view until the saved-views store ships. D14: the sort appends production's two sorts.
// D14: the sort appends production's two sorts. Save view is the concept's own (saved views, #249 D16 retired).
const toolbar={...reference.toolbar};
toolbar.items=toolbar.items.map(item=>item.id==='sort'?{...item,options:[...item.options,'Oldest first','Name Z–A']}:item);
// D36: the category list is the Library's real list, sorted alphabetically (database/library.py `ORDER BY category
// COLLATE NOCASE, category`); the concept lists its samples in prompt order (app.js 348). Only the order is mapped.
@@ -149,12 +149,15 @@ function compareLoadingRow(reference,actual,count) {
return diffInventory(unplaced(mapContinuation(reference,{loaded,total})),unplaced(actual));
}
/** D15: the Import dialog's two real operations, with the ruled intro and the Rescan wording. */
/**
* D15: the Import dialog's real operations, with the ruled intro and the Rescan wording. D55: the concept's own "Scan an
* output folder → Review scan" row follows Rescan as "Scan for metadata", opening the concept's "Review metadata scan".
*/
function mapImportConcept(reference) {
return {...reference,
intro:{...reference.intro,text:'Image inspection and output scanning have different effects. Choose the workflow you want.'},
rows:reference.rows.filter(row=>row.title!=='Import portable recipes').map(row=>row.title==='Scan an output folder'
?{...row,description:'Find files added or removed in the output folders.',buttons:['Rescan']}:row)};
rows:reference.rows.filter(row=>row.title!=='Import portable recipes').flatMap(row=>row.title==='Scan an output folder'
?[{...row,description:'Find files added or removed in the output folders.',buttons:['Rescan']},{...row,title:'Scan for metadata'}]:[row])};
}
const SECTIONS=['heading','toolbar','filterRow','bulkbar','caption','grid','cards','continuation','panel','noPageScroll'];
@@ -329,9 +332,9 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`images page ge
const conceptLoad=await openConceptLoaded(page,theme,motion);
const reference=await inventoryOf(page,'#main');
if(capture)await captureCanonicalState(page,testInfo,`concept-images-${width}-${theme}`);
// The browser as the re-layout's oracle: the concept page itself without Save view, then without the sample tag.
// The browser as the re-layout's oracle: the concept page itself without the sample tag.
const removed={};
for(const [key,selector] of [['toolbar','.toolbar > [data-action="save-view"]'],['filterRow','.filter-row > .sample-tag']]) {
for(const [key,selector] of [['filterRow','.filter-row > .sample-tag']]) {
const restore=await mutate(page,{remove:[selector]});
removed[key]=(await inventoryOf(page,'#main'))[key];
await restore();
@@ -385,12 +388,10 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`images page ge
expect.soft(reference.cards[0].info.p.text,`${motion} concept subtitle`).toBe(`${first.width} × ${first.height} · PNG · Seed ${first.seed}`);
expect.soft(reference.cards[0].info.h3.fontSize,`${motion} concept h3 size`).toBe('11px');
expect.soft(referenceImport.rows.map(row=>row.title),`${motion} concept Import rows`).toEqual(['Inspect a local image','Scan an output folder','Import portable recipes']);
// The row model reproduces the concept's own rows, and the concept page itself once Save view (D16) or the
// sample tag (D29) is removed from it at runtime: the browser is the oracle for the remainders the mapping uses.
// The row model reproduces the concept's own rows, and the concept page itself once the sample tag (D29) is
// removed from it at runtime: the browser is the oracle for the remainders the mapping uses.
expect.soft(diffInventory(stripExact(reflow(reference.toolbar,reference.toolbar.items)),stripExact(reference.toolbar)),`${motion} toolbar model`).toEqual([]);
expect.soft(diffInventory(stripExact(reflow(reference.filterRow,reference.filterRow.items)),stripExact(reference.filterRow)),`${motion} filter row model`).toEqual([]);
expect.soft(diffInventory(stripExact(withoutItems(reference.toolbar,item=>item.text==='Save view')),stripExact(removed.toolbar)),
`${motion} toolbar without Save view`).toEqual([]);
expect.soft(diffInventory(stripExact(withoutItems(reference.filterRow,item=>String(item.className).includes('sample-tag'))),stripExact(removed.filterRow)),
`${motion} filter row without the sample tag`).toEqual([]);
expect.soft(reference.toolbar.items.find(item=>item.id==='category-filter').options,`${motion} concept categories`).toEqual(CONCEPT_CATEGORIES);
+353
View File
@@ -0,0 +1,353 @@
import {writeFile} from 'node:fs/promises';
import {test,expect} from './fixtures.mjs';
import {captureCanonicalState} from './history-canonical-comparison.mjs';
import {SIZES,THEMES,seedConcept,openConcept,openProduction,inspectorOf,diffInventory,mutate,countCopy} from './concept-inventory.mjs';
// Task D4 of #249: the Images side panel (app.js imageDetails a400–414) and the viewer's full details (imageDetails(i,
// true)) against the immutable concept, on the concept-shaped real fixture, at 1600x1100 and 390x844 in both themes.
// Both documents select the first listed image, alpine_00001.png (D19: the first load selects it). The fixture links it
// to a recipe without a category or tags, so (D39, ruled 2026-10-21) its panel falls back to the positive's own category
// and tags, as the concept's does. The panel's box in the gallery layout is the Images spec's. Paired captures are
// artifacts only; there are no PNG baselines.
test.use({locale:'en-US',timezoneId:'UTC'});
const CARDS='.gallery-body > .image-grid > .media-card';
const TITLE='Alpine observatory';
const FIXTURE_IMAGES=24;
// The concept's sticky panel never scrolls; production's scrolls inside the window above 700px (D20/D21 side panel).
const PANEL_INSET=40;
// D34: coarse pointers keep 40px targets for the viewer details' disclosure summaries and "Download original" (the
// concept's coarse rule covers buttons only). The recorded rule, applied to the concept under the same emulation so the
// browser lays out what follows the taller summaries; a fine pointer never matches it.
const D34_COARSE_TARGETS=`@media (pointer: coarse) {
#viewer-details summary { box-sizing: border-box; min-block-size: 40px; padding-block: 10px; }
#viewer-details a { display: inline-flex; align-items: center; min-block-size: 40px; }
}`;
// What an open "File & provenance" offers beyond the concept's text-only disclosure: D38's Show in folder right after
// the Path value, then D20's Download original.
const FILE_CONTROLS=[{tag:'button',text:'Show in folder',name:'Show alpine_00001.png in folder'},{tag:'a',text:'Download original',name:'Download original'}];
/** D22: production's confirmed prompt text is focusable, so it follows its block's actions among the controls. */
function withFocusablePrompts(inventory) {
const controls=[...inventory.controls];
for(const section of inventory.sections) {
const send=section.actions.buttons.at(-1);
const index=controls.findIndex(control=>control.name===send.name);
controls.splice(index+1,0,{tag:'p',text:section.text.text,name:section.text.text,pressed:null,primary:false,minHeight:'0px'});
}
return {...inventory,controls,sections:inventory.sections.map(section=>({...section,text:{...section.text,tabIndex:0}}))};
}
/** D21: the category chip names its scope in a visually hidden prefix; here the linked positive's (D39 fallback). */
function withCategoryScope(inventory) {
const [badge,category,...rest]=inventory.badges.chips;
return {...inventory,badges:{...inventory.badges,chips:[badge,{...category,hidden:'Positive prompt category: '},...rest]}};
}
/**
* Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34 and D38); everything else must equal
* production. `reference.viewerPlain` is the concept's own full details
* with its tag chips made plain (D20) and, under a coarse pointer, D34's targets, both at runtime: the browser lays out
* what follows them, as the Images spec's removed toolbar items do.
*/
export function mapInspectorConcept(reference,{viewport}) {
const {panel,viewerPlain,fileControls}=reference;
return {
panel:withCategoryScope(withFocusablePrompts({...panel,
// D3: the production-only inspector rules (`.file-inspector`, workspace.css) hook on this class.
className:`${panel.className} file-inspector`,
// D20/D21 side panel: the sticky panel scrolls inside the window, so long real prompts keep the footer
// reachable; at 700px and below, where the concept unsticks it, it grows instead.
overflowX:'auto',overflowY:'auto',maxHeight:viewport.width>700?`${viewport.height-PANEL_INSET}px`:'none'})),
// D3 as above; D20: the viewer's tag chips are plain (the concept's go to the Library).
viewerDetails:withCategoryScope(withFocusablePrompts({...viewerPlain,className:'file-inspector'})),
// D38 and D20: the open "File & provenance" offers Show in folder and Download original.
fileControls:[...fileControls,...FILE_CONTROLS],
};
}
function compare(reference,actual,viewport) {
return diffInventory(mapInspectorConcept(reference,{viewport}),actual);
}
/** The comparison as a gate: throws, naming every differing path, unless production equals the mapped concept. */
function assertInspectorMatch(reference,actual,viewport) {
const diff=compare(reference,actual,viewport);
if(diff.length)throw new Error(`the inspector differs from the concept at ${diff.map(item=>item.path).join(', ')}`);
}
// Two frames, then every running transition finished.
const settle=page=>page.evaluate(async()=>{
await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));
await Promise.race([Promise.all(document.getAnimations().map(animation=>animation.finished.catch(()=>{}))),
new Promise(resolve=>setTimeout(resolve,2000))]);
});
const panelOf=page=>page.locator('#image-inspector');
/**
* The concept with every fixture image loaded through its own Load more (it loads 12 per batch, D37), as production
* has them: where the panel follows the grid (390px), reaching it would otherwise load the batch mid-comparison.
*/
async function openConceptPanel(page,theme) {
await openConcept(page,'images',theme);await page.mouse.move(0,0);
if(await page.locator(CARDS).count()<FIXTURE_IMAGES)await page.getByRole('button',{name:'Load more images',exact:true}).dispatchEvent('click');
await expect(page.locator(CARDS)).toHaveCount(FIXTURE_IMAGES);
await page.evaluate(()=>{document.activeElement?.blur();window.scrollTo(0,0);});
await expect(panelOf(page).locator('.panel-heading h2')).toHaveText(TITLE);
await settle(page);
}
async function openProductionPanel(page,theme) {
await openProduction(page,'images',theme);await page.mouse.move(0,0);
await expect(page.locator(CARDS).first()).toBeVisible();
await expect(panelOf(page).locator('.panel-heading h2')).toHaveText(TITLE);
await expect(panelOf(page).locator('.detail-footer')).toBeVisible();
await expect(panelOf(page).locator('.detail-inner > .tags')).toHaveCount(2);
await settle(page);
}
/** The concept's tag chips in the open viewer as plain `span.chip`s (D20) and D34's coarse targets; returns an undo. */
async function plainViewerChips(page) {
const restoreTargets=await mutate(page,{css:D34_COARSE_TARGETS});
await page.evaluate(()=>{
window.__plainChips=[...document.querySelectorAll('#viewer-details .tags > button.chip')].map(button=>{
const span=document.createElement('span');span.className='chip';span.textContent=button.textContent;
button.replaceWith(span);return [span,button];
});
});
await settle(page);
return async()=>{
await page.evaluate(()=>{for(const [span,button] of window.__plainChips)span.replaceWith(button);delete window.__plainChips;});
await restoreTargets();
};
}
/** The controls an open "File & provenance" offers between its summary and the next disclosure's (m5). */
async function fileControlsOf(page) {
const disclosure=page.locator('#viewer-details details').first();
await disclosure.evaluate(node=>{node.open=true;});await settle(page);
const {controls}=(await inspectorOf(page)).viewerDetails;
await disclosure.evaluate(node=>{node.open=false;});await settle(page);
const names=controls.map(control=>control.name);
return controls.slice(names.indexOf('File & provenance')+1,names.indexOf('Workflow & raw metadata')).map(({tag,text,name})=>({tag,text,name}));
}
/**
* The first card's viewer: its full details and open "File & provenance" measured (in the concept also with plain
* chips), then closed again. `mutation` (production) is applied once the viewer is open and undone before it closes.
*/
async function viewerOf(page,concept,mutation=null) {
await page.locator(CARDS).first().locator('.image-open').click();
await expect(page.locator('#viewer-dialog[open] #viewer-details .panel-heading h2')).toHaveText(TITLE);
await settle(page);
const restoreMutation=mutation?await mutate(page,mutation):null;
if(mutation)await settle(page);
const {viewerDetails}=await inspectorOf(page);
const fileControls=await fileControlsOf(page);
let viewerPlain=null;
if(concept) {const restore=await plainViewerChips(page);viewerPlain=(await inspectorOf(page)).viewerDetails;await restore();}
await restoreMutation?.();
await page.keyboard.press('Escape');
await expect(page.locator('#viewer-dialog[open]')).toHaveCount(0);
// The concept re-renders the Images page on the viewer's `close` event (app.js updateImages): let it land first.
await page.evaluate(()=>new Promise(resolve=>setTimeout(resolve,0)));await settle(page);
return {viewerDetails,viewerPlain,fileControls};
}
async function inventory(page,concept=false) {
const {panel}=await inspectorOf(page);
const {viewerDetails,viewerPlain,fileControls}=await viewerOf(page,concept);
return concept?{panel,viewerDetails,viewerPlain,fileControls}:{panel,viewerDetails,fileControls};
}
/** A ComfyUI host in a second tab that acknowledges what it applies (the shipped prompt bridge). */
async function fakeHost(context) {
const host=await context.newPage();await host.goto('/prompt_manager/workspace#/overview');
await host.evaluate(async()=>{
const {createBridgeHost}=await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');
window.hostValues={positive:'Existing positive',negative:'Existing negative'};window.applied=[];
const target={id:'pair-1',label:'My prompt pair',type:'PromptManagerPair',roles:['positive','negative']};
window.fixtureHost=createBridgeHost({origin:location.origin,namespace:'/',label:'Test ComfyUI',adapter:{
listTargets:()=>[target],
preview:(_id,roles)=>({preview:{target,fields:Object.entries(roles).map(([role,after])=>({role,before:window.hostValues[role],after}))},snapshot:roles}),
apply:roles=>{Object.assign(window.hostValues,roles);window.applied.push(roles);return {targetId:target.id,values:roles};},
}});
});
return host;
}
/** Copy the positive: the concept's outcome is the toast, or its "Copy prompt" dialog where the clipboard is refused. */
async function copyOf(page) {
// An earlier toast (the concept's Load more) is not the copy's outcome.
await panelOf(page).locator('.prompt-actions').first().scrollIntoViewIfNeeded();await settle(page);
await page.evaluate(()=>document.querySelectorAll('.toast').forEach(toast=>{toast.textContent='';}));
await panelOf(page).getByRole('button',{name:`Copy positive prompt: ${TITLE}`,exact:true}).click();
const dialog=page.getByRole('dialog',{name:'Copy prompt',exact:true});
await expect.poll(async()=>(await page.locator('.toast').textContent())||(await dialog.count()?'dialog':'')).not.toBe('');
const result={toast:await page.locator('.toast').textContent(),dialog:await dialog.count()>0};
if(result.dialog){await page.keyboard.press('Escape');await expect(dialog).toHaveCount(0);}
return result;
}
/** The concept's send: its dialog previews both roles (app.js openSend). */
async function conceptSendOf(page) {
await panelOf(page).getByRole('button',{name:`Send prompt pair to ComfyUI: ${TITLE}`,exact:true}).click();
const modal=page.locator('dialog#modal[open]');
await expect(modal.locator('#modal-title')).toHaveText('Send to ComfyUI');
const previews=await modal.locator('.send-preview').evaluateAll(nodes=>nodes.map(node=>[node.querySelector('h3').textContent,node.querySelector('p').textContent]));
await page.keyboard.press('Escape');await expect(modal).toHaveCount(0);
return previews;
}
/** Production's send: the acknowledged sender, previewing and applying both roles to a chosen node. */
async function productionSendOf(page,context) {
const host=await fakeHost(context);
try {
await panelOf(page).getByRole('button',{name:`Send prompt pair to ComfyUI: ${TITLE}`,exact:true}).click();
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});await expect(sender).toBeVisible();
const windows=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});
await expect(windows.locator('option')).toContainText(['Test ComfyUI']);await windows.selectOption({label:'Test ComfyUI'});
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'My prompt pair'});
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();
await expect(sender).toContainText('Sent to ComfyUI.');
const applied=await host.evaluate(()=>window.applied);
await sender.getByRole('button',{name:'Close',exact:true}).click();await expect(sender).toHaveCount(0);
return applied;
} finally {await host.evaluate(()=>window.fixtureHost.dispose());await host.close();}
}
/** A tag chip: the collection it leaves (the count line), then the chip pressed again for the same result. */
async function tagOf(page) {
const before=await page.locator('#result-count').textContent();
await panelOf(page).locator('.detail-inner > .tags').last().getByRole('button',{name:'cinematic',exact:true}).click();
await expect.poll(()=>page.locator('#result-count').textContent()).not.toBe(before);
await settle(page);
return {count:await page.locator('#result-count').textContent(),
cards:await page.locator(`${CARDS} .image-open`).evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')))};
}
const historyOf=async page=>{
await panelOf(page).getByRole('button',{name:'Prompt history',exact:true}).click();
await expect.poll(()=>page.evaluate(()=>location.hash)).not.toMatch(/images$/);
return page.evaluate(()=>location.hash);
};
test.beforeEach(async({request},testInfo)=>{testInfo.manifest=await seedConcept(request);});
for(const [width,height] of SIZES)for(const theme of THEMES)test(`inspector panel geometry, tokens and controls match the concept ${width}x${height} ${theme}`,async({page,context,browserName},testInfo)=>{
test.setTimeout(180_000);
// The concept's copy and production's both use the async clipboard; Chromium asks for the permission.
if(browserName==='chromium')await context.grantPermissions(['clipboard-read','clipboard-write']);
await page.setViewportSize({width,height});
const manifest=testInfo.manifest;
const viewport={width,height};
await openConceptPanel(page,theme);
await captureCanonicalState(page,testInfo,`concept-inspector-${width}-${theme}`);
const reference=await inventory(page,true);
const concept={copy:await copyOf(page),send:await conceptSendOf(page),tag:await tagOf(page)};
await openConceptPanel(page,theme);concept.history=await historyOf(page);
await openProductionPanel(page,theme);
await captureCanonicalState(page,testInfo,`production-inspector-${width}-${theme}`);
const actual=await inventory(page);
const production={copy:await copyOf(page),send:await productionSendOf(page,context)};
const listing=page.waitForRequest(item=>item.url().includes('/images/output?')&&new URL(item.url()).searchParams.get('tags')==='cinematic');
production.tag=await tagOf(page);await listing;
// D44: the applied tag is one active refinement, so the toolbar's Filters button counts it.
await page.getByRole('button',{name:'Filters · 1',exact:true}).click();
production.tagFilter=await page.getByRole('dialog').getByRole('textbox',{name:'Tags',exact:true}).inputValue();
await page.keyboard.press('Escape');
await openProductionPanel(page,theme);production.history=await historyOf(page);
const diff=compare(reference,actual,viewport);
const path=testInfo.outputPath('inspector-geometry.json');
await writeFile(path,JSON.stringify({reference,actual,diff,concept,production},null,2));
await testInfo.attach('inspector-geometry',{path,contentType:'application/json'});
// The concept itself is what this comparison assumes (spec §3.3, s523–615).
const {panel}=reference,coarse=await page.evaluate(()=>matchMedia('(pointer: coarse)').matches);
expect.soft(panel.width,'concept panel width').toBe(width>700?310:width-28);
expect.soft(panel.position,'concept panel position').toBe(width>700?'sticky':'static');
expect.soft(panel.cover.height,'concept cover').toBe(width>700?150:200);
expect.soft(panel.inner.paddingTop,'concept inner padding').toBe('17px');
expect.soft(panel.heading.h2.fontSize,'concept h2').toBe('14px');
for(const section of panel.sections) {
expect.soft(section.label,`concept ${section.className} label`).toMatchObject({fontSize:'10px',letterSpacing:'0.7px',textTransform:'uppercase',fontWeight:'650'});
expect.soft(section.text,`concept ${section.className} text`).toMatchObject({fontSize:'12px',lineHeight:'21px'});
for(const button of section.actions.buttons)expect.soft(button,`concept ${section.className} action`).toMatchObject(coarse?{minHeight:'36px',minWidth:'36px'}:{minHeight:'27px',minWidth:'28px'});
}
expect.soft(panel.sections.map(section=>section.label.color)[0],'concept role colors').not.toBe(panel.sections[1].label.color);
expect.soft(panel.facts,'concept facts').toMatchObject({columns:2,columnGap:'12px',rowGap:'12px',fontSize:width>700?'11px':'12px'});
expect.soft(panel.footer,'concept footer').toMatchObject({paddingTop:'13px',paddingRight:'17px',paddingBottom:'13px',paddingLeft:'17px'});
expect.soft(panel.badges.chips.map(chip=>chip.text),'concept badges').toEqual(['Execution recorded','Landscapes']);
expect.soft(reference.viewerDetails.structure.inner.slice(-2),'concept full details').toEqual(['details','details']);
expect.soft(diff,'differences after the recorded deviations').toEqual([]);
// Copy: the concept's toast "Prompt copied." (or its dialog), in both documents.
expect.soft(production.copy,'copy').toEqual(concept.copy);
if(browserName==='chromium')expect.soft(production.copy,'copy toast').toEqual({toast:'Prompt copied.',dialog:false});
// Send: the concept previews both roles; production applies exactly those texts and ComfyUI acknowledges them.
const [positive,negative]=concept.send;
expect.soft(concept.send.map(([heading])=>heading),'concept send').toEqual(['+ Positive · replace','− Negative · replace']);
expect.soft(production.send,'acknowledged send').toEqual([{positive:positive[1],negative:negative[1]}]);
expect.soft(negative[1],'fixture negative').toBe('blurry, low quality, low resolution');
// Tag chip: D21, the Images Tags filter (the concept searches the tag's text instead), leaving the same images.
expect.soft(production.tagFilter,'tag chip filter').toBe('cinematic');
// The concept's search also matches the forest prompt's text ("cinematic still"), which is not tagged cinematic
// (app.js 73, 115, 130): production leaves the tagged prompts' images, in the concept's order.
const tagged=concept.tag.cards.filter(card=>!card.startsWith('Open forest_'));
expect.soft(concept.tag.count,'concept tag search').toBe('12 of 12 images · 3 positive prompts');
expect.soft(production.tag,'tag chip collection').toEqual({count:countCopy(`${tagged.length} of ${tagged.length} images · 2 positive prompts`),cards:tagged});
// Prompt history: the concept's `#prompt/<id>` is production's Library history of the linked positive.
expect.soft(concept.history,'concept history').toBe('#prompt/alpine');
expect.soft(production.history,'history').toBe(`#/library/${manifest.prompts[0].positive_id}`);
});
// D39 (ruled 2026-10-21): a recipe-linked image shows its recipe's own category and tags when set, otherwise its
// positive's, field by field. The concept fixture's recipes have neither, so this recipe-linked image shows its
// positive's, as the concept does, and its tag chips are Tags filter buttons again (D21: the positive's own tags).
test('a recipe-linked image without recipe metadata shows its positive prompt metadata (D39)',async({page})=>{
await page.setViewportSize({width:1600,height:1100});
await openProductionPanel(page,'dark');
await page.locator(CARDS).nth(1).locator('[aria-label^="Inspect"]').click();
await expect(panelOf(page).locator('.panel-heading h2')).toHaveText('Quiet geometry');
const rows=panelOf(page).locator('.detail-inner > .tags');
await expect(rows).toHaveCount(2);
await expect(rows.first().locator('.chip')).toHaveText(['Execution recorded','Positive prompt category: Architecture']);
await expect(rows.first().locator('.chip .ca-visually-hidden')).toHaveText('Positive prompt category: ');
await expect(rows.nth(1).locator('button.chip')).toHaveText(['minimal','warm_light','editorial']);
await expect(rows.nth(1).locator('span.chip')).toHaveCount(0);
await expect(panelOf(page).locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Prompt history']);
});
// The comparison is only worth something if it notices a change (spec §7 mutation check): on the panel one CSS
// mutation (the role label's letter spacing) and one DOM mutation (the footer removed); in the viewer (final review m5)
// its pair actions removed and D38's Show in folder removed; each applied after an exact match.
test('inspector comparison detects mutations',async({page})=>{
test.setTimeout(120_000);
const viewport={width:1600,height:1100};
await page.setViewportSize(viewport);
await openConceptPanel(page,'dark');
const reference=await inventory(page,true);
await openProductionPanel(page,'dark');
const {viewerDetails,fileControls}=await inventory(page);
const measure=async()=>({panel:(await inspectorOf(page)).panel,viewerDetails,fileControls});
assertInspectorMatch(reference,await measure(),viewport);
const restoreCss=await mutate(page,{css:'.prompt-heading .role-label { letter-spacing: 0 }'});
const cssMutated=await measure();
expect(()=>assertInspectorMatch(reference,cssMutated,viewport)).toThrow(/panel\.sections\.0\.label\.letterSpacing/);
await restoreCss();await settle(page);
assertInspectorMatch(reference,await measure(),viewport);
const panel=(await inspectorOf(page)).panel;
const viewerMutated=async mutation=>{const {viewerDetails,fileControls}=await viewerOf(page,false,mutation);return {panel,viewerDetails,fileControls};};
const withoutActions=await viewerMutated({remove:['#viewer-details > button']});
expect(()=>assertInspectorMatch(reference,withoutActions,viewport)).toThrow(/viewerDetails\.actions/);
const withoutReveal=await viewerMutated({remove:['#viewer-details button[aria-label$=" in folder"]']});
expect(()=>assertInspectorMatch(reference,withoutReveal,viewport)).toThrow(/fileControls/);
assertInspectorMatch(reference,await viewerMutated(),viewport);
await mutate(page,{remove:['#image-inspector > .detail-footer']});
const domMutated=await measure();
expect(()=>assertInspectorMatch(reference,domMutated,viewport)).toThrow(/panel\.footer/);
});
+12 -13
View File
@@ -1,7 +1,7 @@
import {writeFile} from 'node:fs/promises';
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import {test,expect} from './fixtures.mjs';
import {captureCanonicalState} from './history-canonical-comparison.mjs';
import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact,countCopy,reflow,withoutItems} from './concept-inventory.mjs';
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,
@@ -18,8 +18,8 @@ const CONCEPT_PROMPTS=6;
const nocase=(a,b)=>{const x=a.toLowerCase(),y=b.toLowerCase();return x<y?-1:x>y?1:a<b?-1:a>b?1:0;};
export function mapLibraryConcept(reference) {
const library=reference.library;
// D16 (follow-up PR): no Save view until the saved-views store ships; the remaining items flow again (`reflow`).
const toolbar=withoutItems(library.toolbar,item=>item.text==='Save view');
// Save view is the concept's own (saved views, #249 D16 retired).
const toolbar={...library.toolbar};
toolbar.items=toolbar.items.map(item=>{
// D14: the Library appends its Oldest first after the concept's three sorts.
if(item.id==='sort')return {...item,options:[...item.options,'Oldest first']};
@@ -158,6 +158,10 @@ async function filteredOf(page,apply,undo) {
await apply();
await expect.poll(()=>captionOf(page)).not.toBe(before);
await expect.poll(()=>page.locator(CARDS).count()).toBeLessThan(CONCEPT_PROMPTS);
// Production empties the shelf when the filter applies, writes the answer's caption at once and places its cards
// in the next animation frame (one paint for a viewer), so the empty shelf already counts as "fewer": read the
// titles only after that frame, as the Images and Inspector comparisons do.
await settle(page);
const result={caption:await captionOf(page),titles:await titlesOf(page)};
await undo();
await expect.poll(()=>captionOf(page)).toBe(before);
@@ -251,11 +255,7 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`library geomet
await openConceptLibrary(page,theme,motion);
const reference=await conceptInventory(page);
if(capture)await captureCanonicalState(page,testInfo,`concept-library-${width}-${theme}`);
// The browser as the re-layout's oracle: the concept page itself without Save view (D16).
const restore=await mutate(page,{remove:['.toolbar > [data-action="save-view"]']});
const removed=(await inventoryOf(page,'#main')).library.toolbar;
await restore();
// ...and with the D23 clamp applied to its cards.
// The browser as the re-layout's oracle: the concept page itself with the D23 clamp applied to its cards.
const restoreClamp=await mutate(page,{css:CLAMP_CSS});
const clamped=(await inventoryOf(page,'#main')).library;
await restoreClamp();
@@ -269,13 +269,13 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`library geomet
const actual=await inventoryOf(page,'#workspace-main');
if(capture)await captureCanonicalState(page,testInfo,`production-library-${width}-${theme}`);
const actualBehaviors=capture?await behaviorsOf(page,{back:()=>page.getByRole('button',{name:'← Prompt library',exact:true}).click(),sent:productionSent(host)}):null;
result[motion]={reference,removed,sorted,clamped,actual,diff:compare(reference,actual),referenceBehaviors,actualBehaviors};
result[motion]={reference,sorted,clamped,actual,diff:compare(reference,actual),referenceBehaviors,actualBehaviors};
}
const path=testInfo.outputPath('library-geometry.json');await writeFile(path,JSON.stringify(result,null,2));
await testInfo.attach('library-geometry',{path,contentType:'application/json'});
for(const [motion,{reference,removed,sorted,clamped,actual,diff,referenceBehaviors:concept,actualBehaviors:production}] of Object.entries(result)) {
const {grid,cards,toolbar}=reference.library,card=cards[0];
for(const [motion,{reference,sorted,clamped,actual,diff,referenceBehaviors:concept,actualBehaviors:production}] of Object.entries(result)) {
const {grid,cards}=reference.library,card=cards[0];
// The concept itself is what this comparison assumes (style.css s648–708, 1424–1428, 1475–1483).
expect.soft(grid,`${motion} concept grid`).toMatchObject({columns:narrow?1:2,columnGap:'18px',rowGap:'18px',cardCount:CONCEPT_PROMPTS});
expect.soft(card,`${motion} concept card`).toMatchObject({borderTopLeftRadius:'8px',borderTopWidth:'1px',overflow:'hidden'});
@@ -291,7 +291,6 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`library geomet
expect.soft(card.meta.items.map(item=>item.text),`${motion} concept meta text`).toEqual(['− 3 negative variants','4 runs','4 images']);
expect.soft(card.footer,`${motion} concept footer`).toMatchObject({paddingTop:'11px',paddingRight:'18px',paddingBottom:'11px',paddingLeft:'18px',borderTopWidth:'1px',borderTopStyle:'solid'});
expect.soft(card.footer.facts.text,`${motion} concept footer facts`).toBe('★★★★★ · Used today');
expect.soft(diffInventory(stripExact(withoutItems(toolbar,item=>item.text==='Save view')),stripExact(removed)),`${motion} toolbar without Save view`).toEqual([]);
expect.soft(diffInventory(stripExact(cards.map(item=>sortedTags(item.tags))),stripExact(sorted)),`${motion} tags in alphabetical order`).toEqual([]);
expect.soft(diffInventory(stripExact(clampedCards(reference.library)),stripExact({grid:clamped.grid,cards:clamped.cards})),`${motion} clamped cards`).toEqual([]);
expect.soft(diff,`${motion} differences after the recorded deviations`).toEqual([]);
+2 -2
View File
@@ -1,4 +1,4 @@
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import {test,expect} from './fixtures.mjs';
import {captureCanonicalState} from './history-canonical-comparison.mjs';
// Artifact-only captures of the editor states that have no concept counterpart (archive confirmation, Show archived,
@@ -19,7 +19,7 @@ for(const [width,height,theme] of [[1600,1100,'dark'],[390,844,'light']])test(`e
await page.setViewportSize({width,height});const f=page.fixture;
await page.goto(`${workspace}#/library/${f.positive_id}`);await expect(variants(page)).toBeVisible();
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
const shot=name=>captureCanonicalState(page,testInfo,`production-${name}-${width}-${theme}`,name.startsWith('editor-archived')?'.library-variants':'dialog.library-editor');
const shot=name=>captureCanonicalState(page,testInfo,`production-${name}-${width}-${theme}`,name.startsWith('editor-archived')?'.library-history .timeline-panel':'dialog.library-editor');
await variants(page).getByRole('button',{name:'Select negative variant 2',exact:true}).click();
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();await expect(editor(page)).toBeVisible();
+1 -1
View File
@@ -1,5 +1,5 @@
import {readFileSync} from 'node:fs';
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import {test,expect} from './fixtures.mjs';
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
const pair={id:4,positive_text:'Library positive',negative_text:'Library negative',category:'Portrait',tags:['studio']};
+1 -1
View File
@@ -1,4 +1,4 @@
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import { test, expect } from './fixtures.mjs';
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
const moduleUrl = '/prompt_manager/js/shared/toolkit/prompt-bridge.js';
+14 -13
View File
@@ -1,5 +1,5 @@
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import {test,expect} from './fixtures.mjs';
const workspace='/prompt_manager/workspace#/images';
const details=page=>page.getByRole('complementary',{name:'Selected image details',exact:true});
@@ -75,9 +75,10 @@ test('physical image inspector resolves positive and negative metadata separatel
test('explicit empty negative is sendable while unrecorded text is never invented',async({page})=>{
await page.goto(workspace);await select(page,'0:empty-negative.png');
await expect(role(details(page),'Negative').locator('p.prompt-text')).toHaveText('No negative prompt (explicitly empty)');
await expect(details(page).getByRole('button',{name:'Send negative prompt to ComfyUI',exact:true})).toBeEnabled();
await expect(details(page).getByRole('button',{name:/^Send negative prompt to ComfyUI: /})).toBeEnabled();
await select(page,'0:uncertain.png');await expect(details(page)).toContainText('Not recorded in this file.');
await expect(details(page).getByRole('button',{name:/^Send (positive|negative) prompt to ComfyUI$/})).toHaveCount(0);
// Every inspector send is named after its prompt ("…to ComfyUI: <title>"); an untitled one ends at "ComfyUI".
await expect(details(page).getByRole('button',{name:/^Send (positive|negative) prompt to ComfyUI(: |$)/})).toHaveCount(0);
});
test('inspection failures retry and a delayed previous selection cannot replace the current prompt',async({page})=>{
@@ -157,7 +158,7 @@ test('ComfyUI ↗ opens the pair send for a selected resolved image, else follow
test('host apply rejection retains the current text and requires a new preview',async({page,context})=>{
const host=await fakeHost(context,page,{rejectApply:true});
try {
await page.goto(workspace);await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:'Send positive prompt to ComfyUI',exact:true}).click();
await page.goto(workspace);await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:/^Send positive prompt to ComfyUI: /}).click();
const sender=await chooseDestination(page);await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText(/Workflow changed/);
await expect(sender.getByRole('button',{name:'Apply to ComfyUI',exact:true})).toBeDisabled();
@@ -169,7 +170,7 @@ test('nested viewer sender Escape restores focus without closing the image and n
await page.goto(workspace);await select(page,'0:shared/same.png');
await page.locator('.media-card.selected .image-open').click();
const viewer=page.locator('dialog[open]').filter({has:page.locator('.viewer-canvas')});await expect(viewer).toBeVisible();
const send=viewer.getByRole('button',{name:'Send positive prompt to ComfyUI',exact:true});await send.click();await expect(dialog(page)).toBeVisible();
const send=viewer.getByRole('button',{name:/^Send positive prompt to ComfyUI: /});await send.click();await expect(dialog(page)).toBeVisible();
await page.keyboard.press('Escape');await expect(dialog(page)).toBeHidden();await expect(viewer).toBeVisible();await expect(send).toBeFocused();
await send.click();await page.evaluate(()=>{location.hash='#/overview';});await expect(page.locator('dialog[open]')).toHaveCount(0);
});
@@ -178,7 +179,7 @@ test('empty negative send clears only the negative destination and preserves its
const host=await fakeHost(context,page);
try {
await page.goto(workspace);await select(page,'0:empty-negative.png');
await details(page).getByRole('button',{name:'Send negative prompt to ComfyUI',exact:true}).click();
await details(page).getByRole('button',{name:/^Send negative prompt to ComfyUI: /}).click();
const sender=await chooseDestination(page);await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
expect(await host.evaluate(()=>window.hostValues)).toEqual({positive:'Existing positive',negative:''});
@@ -187,7 +188,7 @@ test('empty negative send clears only the negative destination and preserves its
test('no open ComfyUI window is actionable and rediscovery finds a newly opened host',async({page,context})=>{
await page.goto(workspace);await select(page,'0:shared/same.png');
await details(page).getByRole('button',{name:'Send positive prompt to ComfyUI',exact:true}).click();
await details(page).getByRole('button',{name:/^Send positive prompt to ComfyUI: /}).click();
const sender=dialog(page);await expect(sender).toContainText('No compatible ComfyUI windows found.');
const host=await fakeHost(context,page);
try {
@@ -205,11 +206,11 @@ test('mobile inspector exposes full long prompts, truthful uncertainty and copy
await page.setViewportSize({width:390,height:844});await page.goto(workspace);await select(page,'0:shared/same.png');
const positive=role(details(page),'Positive');await expect(positive.locator('p.prompt-text')).toHaveText(text);
await expect(role(details(page),'Negative')).toContainText('Multiple possible prompts. Source could not be determined.');
await expect(details(page).getByRole('button',{name:'Send negative prompt to ComfyUI',exact:true})).toHaveCount(0);
await positive.getByRole('button',{name:'Copy positive prompt',exact:true}).click();await expect(page.getByRole('dialog',{name:'Copy prompt',exact:true})).toBeVisible();
await expect(details(page).getByRole('button',{name:/^Send negative prompt to ComfyUI: /})).toHaveCount(0);
await positive.getByRole('button',{name:/^Copy positive prompt: /}).click();await expect(page.getByRole('dialog',{name:'Copy prompt',exact:true})).toBeVisible();
await page.getByRole('button',{name:'Close dialog',exact:true}).click();
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);
await positive.getByRole('button',{name:'Send positive prompt to ComfyUI',exact:true}).click();
await positive.getByRole('button',{name:/^Send positive prompt to ComfyUI: /}).click();
await expect(dialog(page)).toBeVisible();const bounds=await dialog(page).boundingBox();
expect(bounds.x).toBeGreaterThanOrEqual(0);expect(bounds.x+bounds.width).toBeLessThanOrEqual(391);
});
@@ -284,8 +285,8 @@ test('prompt role headings contain the concept label and compact real actions wi
const block=role(details(page),name),heading=block.locator('.prompt-heading');
await expect(block).toHaveClass(new RegExp(`^prompt-section ${name.toLowerCase()}$`));
await expect(heading.locator('span.role-label')).toHaveText(label);
await expect(heading.locator('.prompt-actions button.icon-btn').first()).toHaveAccessibleName(`Copy ${name.toLowerCase()} prompt`);
await expect(heading.locator('.prompt-actions button.send.icon-btn')).toHaveAccessibleName(`Send ${name.toLowerCase()} prompt to ComfyUI`);
await expect(heading.locator('.prompt-actions button.icon-btn').first()).toHaveAccessibleName(new RegExp(`^Copy ${name.toLowerCase()} prompt: same\\.png$`));
await expect(heading.locator('.prompt-actions button.send.icon-btn')).toHaveAccessibleName(new RegExp(`^Send ${name.toLowerCase()} prompt to ComfyUI: same\\.png$`));
await expect(heading.locator('[data-copy-role] svg')).toHaveCount(1);
expect(await heading.evaluate(node=>node.nextElementSibling.matches('p.prompt-text')&&!node.nextElementSibling.nextElementSibling)).toBe(true);
}
@@ -301,7 +302,7 @@ test('copy toast and fallback dialog',async({page})=>{
await page.addInitScript(()=>{window.copied=[];window.clipboardFails=false;Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>{if(window.clipboardFails)throw new Error('Permission denied');window.copied.push(text);}}});});
await page.goto(workspace);await select(page,'0:shared/same.png');
const positive=role(details(page),'Positive'),text=await positive.locator('p.prompt-text').textContent();
const copy=positive.getByRole('button',{name:'Copy positive prompt',exact:true});await copy.click();
const copy=positive.getByRole('button',{name:/^Copy positive prompt: /});await copy.click();
await expect(page.locator('#workspace-toast')).toHaveText('Prompt copied.');expect(await page.evaluate(()=>window.copied)).toEqual([text]);
await expect(positive.getByRole('status')).toHaveCount(0);
await page.evaluate(()=>{window.clipboardFails=true;});await copy.click();
+149 -6
View File
@@ -1,19 +1,24 @@
"""Serve production pages and API routes with disposable state for browser tests."""
import argparse
import asyncio
import json
import os
import shutil
import socket
from pathlib import Path
import sys
import tempfile
import threading
import time
import types
from aiohttp import web
from aiohttp.web_runner import GracefulExit
from PIL import Image
from PIL.PngImagePlugin import PngInfo
from tests.browser.workspace_activity_fixture import register_activity_commands
from tests.browser.workspace_concept_fixture import (
MAX_EXTRA_IMAGES as CONCEPT_MAX_EXTRA_IMAGES,
MAX_SEEDLESS_IMAGES as CONCEPT_MAX_SEEDLESS_IMAGES,
@@ -76,6 +81,53 @@ class TestModel:
return ["generated", Path(image_path).parent.parent.name]
REVEAL_OUTCOMES = ("opened", "unavailable", "fail")
def register_reveal_stub(routes, api, root: Path):
"""D38: Show in folder never opens a real file manager here.
The API's opener records the resolved path; ``POST /__test__/reveal``
sets the next outcome and ``GET /__test__/reveal`` lists the opened paths.
"""
from utils.file_reveal import OpenerUnavailable, RevealGate
state = {"outcome": "opened", "opened": []}
def opener(path: Path, gallery_root: Path) -> None:
if state["outcome"] == "unavailable":
raise OpenerUnavailable()
if state["outcome"] == "fail":
raise OSError("stubbed start failure")
state["opened"].append(Path(path).relative_to(root.resolve()).as_posix())
api._reveal_opener = opener
# Each click gets an answer here; the cooldown is a unit-tested default.
api._reveal_gate = RevealGate(cooldown=0)
@routes.post("/__test__/reveal")
async def set_reveal_outcome(request: web.Request) -> web.Response:
data = await request.json() if request.can_read_body else None
if (
not isinstance(data, dict)
or set(data) != {"outcome"}
or data["outcome"] not in REVEAL_OUTCOMES
):
raise web.HTTPBadRequest()
state["outcome"] = data["outcome"]
return web.json_response({"success": True})
@routes.get("/__test__/reveal")
async def reveal_calls(request: web.Request) -> web.Response:
return web.json_response({"opened": state["opened"]})
def reset() -> None:
state["outcome"] = "opened"
state["opened"].clear()
return types.SimpleNamespace(reset=reset)
def create_app(root: Path) -> web.Application:
os.environ["PROMPT_MANAGER_CONFIG_PATH"] = str(root / "config.json")
os.chdir(root)
@@ -111,6 +163,8 @@ def create_app(root: Path) -> web.Application:
history_commands = register_workspace_history_commands(routes, api, root)
library_editor_commands = register_library_editor_commands(routes, api, root)
register_vocabulary_commands(routes, api, root)
reveal_stub = register_reveal_stub(routes, api, root)
register_activity_commands(app, routes, api)
async def canonical_asset(request: web.Request) -> web.Response:
filename, content_type = CANONICAL_ASSETS[request.path]
@@ -133,6 +187,7 @@ def create_app(root: Path) -> web.Application:
) -> None:
with api.db.model.get_connection() as conn:
for table in (
"saved_views",
"image_favorites",
"image_prompt_pairs",
"prompt_pair_tags",
@@ -311,6 +366,7 @@ def create_app(root: Path) -> web.Application:
history_fixture = manifest
return web.json_response({"success": True, **manifest})
history_commands.reset()
reveal_stub.reset()
workspace_visual = data.get("workspace_visual", False)
full_inspector = data.get("full_inspector", False)
extra = data.get("extra_images", 0)
@@ -408,6 +464,7 @@ def create_app(root: Path) -> web.Application:
raise web.HTTPBadRequest()
history_commands.reset()
library_editor_commands.reset()
reveal_stub.reset()
history_fixture = None
manifest = await api._run_in_executor(
lambda: seed_workspace_concept(
@@ -419,6 +476,29 @@ def create_app(root: Path) -> web.Application:
)
return web.json_response({"success": True, **manifest})
@routes.post("/__test__/saved-views/raw")
async def insert_raw_saved_view(request: web.Request) -> web.Response:
"""Store a view row as an older build wrote it, bypassing the API's rules."""
data = await request.json()
if (
not isinstance(data, dict)
or set(data) != {"page", "name", "state"}
or not all(isinstance(data[key], str) for key in data)
):
raise web.HTTPBadRequest()
def insert() -> int:
with api.db.model.get_connection() as conn:
return conn.execute(
"INSERT INTO saved_views (page, name, state, position, "
"created_at, updated_at) VALUES (?, ?, ?, 0, 'x', 'x')",
(data["page"], data["name"], data["state"]),
).lastrowid
return web.json_response(
{"success": True, "id": await api._run_in_executor(insert)}
)
@routes.post("/__test__/add-nested-image")
async def add_nested_image(request: web.Request) -> web.Response:
destination = root / "output" / "project" / "day" / "new-output.png"
@@ -439,14 +519,77 @@ def create_app(root: Path) -> web.Application:
return app
def main() -> None:
HOST = "127.0.0.1"
PORT_ANNOUNCEMENT = "PM_TEST_SERVER_PORT="
def bind_listener(port: int) -> socket.socket:
"""Bind the server's socket before the app is built.
``port`` 0 lets the OS choose a free port, which this process then holds,
so parallel launchers never race for one between choosing and binding.
"""
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
if port and os.name != "nt":
# aiohttp's own default on POSIX; on Windows it would let a
# second process share the port.
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
listener.bind((HOST, port))
listener.listen(128)
except OSError:
listener.close()
raise
return listener
def _graceful_exit() -> None:
raise GracefulExit()
def exit_when_stdin_closes(loop: asyncio.AbstractEventLoop) -> threading.Thread:
"""Stop the app gracefully once stdin reaches end of file.
The launcher keeps the write end of this process's stdin pipe, and the OS
closes it however the launcher dies, SIGKILL included. GracefulExit runs
aiohttp's cleanup (closing the database) and lets the temp root be removed,
on every OS, where a signal from a dead parent would not.
"""
def watch() -> None:
while sys.stdin.buffer.read(65536):
pass
loop.call_soon_threadsafe(_graceful_exit)
thread = threading.Thread(target=watch, name="stdin-eof", daemon=True)
thread.start()
return thread
def main(argv: list[str] | None = None) -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--port", type=int, default=8765)
args = parser.parse_args()
parser.add_argument(
"--port", type=int, default=8765, help="0 binds an OS-chosen free port"
)
parser.add_argument(
"--exit-on-stdin-eof",
action="store_true",
help="shut down when stdin closes (the launcher's parent-death guard)",
)
args = parser.parse_args(argv)
listener = bind_listener(args.port)
# One machine-readable line tells the launcher which port this process holds.
print(f"{PORT_ANNOUNCEMENT}{listener.getsockname()[1]}", flush=True)
loop = asyncio.new_event_loop()
if args.exit_on_stdin_eof:
exit_when_stdin_closes(loop)
original_cwd = os.getcwd()
with tempfile.TemporaryDirectory(prefix="pm-browser-") as directory:
web.run_app(
create_app(Path(directory).resolve()), host="127.0.0.1", port=args.port
)
try:
web.run_app(create_app(Path(directory).resolve()), sock=listener, loop=loop)
finally:
# create_app works inside the root; Windows cannot remove a process's cwd.
os.chdir(original_cwd)
if __name__ == "__main__":
+64 -14
View File
@@ -1,7 +1,7 @@
import {writeFile} from 'node:fs/promises';
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import {test,expect} from './fixtures.mjs';
import {captureCanonicalState} from './history-canonical-comparison.mjs';
import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,focusRingOf,hoverStateOf,mutate} from './concept-inventory.mjs';
import {SIZES,THEMES,MOTIONS,seedConcept,openConcept as openConceptPage,openProduction,inventoryOf,diffInventory,focusRingOf,hoverStateOf,mutate} from './concept-inventory.mjs';
// Task A6 of #249: the production shell (sidebar, topbar, heading, footer) against the immutable concept, with the
// concept-shaped real fixture so the sidebar counts are the concept's own (24 images, 6 prompts, 15 tags). Both themes,
@@ -16,7 +16,16 @@ const FULL_HEADING=new Set(['tags']);
const rowNamed=(nav,text)=>nav.rows.find(row=>row.text===text);
const removedItem=item=>String(item.className).includes('concept-badge')||(item.tag==='button'&&item.text.startsWith('Activity'));
const removedItem=item=>String(item.className).includes('concept-badge');
/**
* The concept with its Activity count hidden (D52): production counts running jobs only and hides the count at 0, as
* the comparison fixture has none. The concept's own CSS then lays out its button without the count.
*/
async function openConcept(page,route,theme,motion) {
await openConceptPage(page,route,theme,motion);
await mutate(page,{css:'#activity-count { display: none }'});
}
/** Left offsets of flex items packed along a row of `width` by its `justify-content` and `gap`. */
function packedLefts(items,width,justify,gap) {
@@ -37,10 +46,12 @@ function remainingTopActions(actions) {
const gap=parseFloat(actions.columnGap);
// A full-width row (<=700px) keeps its width; otherwise it shrinks to its remaining items.
const width=actions.fullWidth?actions.width:shown.reduce((sum,item)=>sum+item.width,0)+gap*(shown.length-1);
// At <=700px the concept spreads three items with `space-between`, so its Theme select sits in the middle; with
// Activity removed (D5) the same rule puts Theme at the row's start. Offsets come from the concept's own sizes.
// At <=700px the concept spreads its shown items with `space-between`; the hidden pill (D53) takes no place.
// Offsets come from the concept's own sizes.
const lefts=new Map(shown.map((item,index)=>[item,packedLefts(shown,width,actions.justifyContent,gap)[index]]));
return {...actions,height,width,
// D52: the full-width row (<=700px) wraps its items when a zoomed phone is narrower than they are.
flexWrap:actions.fullWidth?'wrap':actions.flexWrap,
items:kept.map(item=>item.shown?{...item,top:Math.round((height-item.height)/2),left:lefts.get(item)}:item)};
}
@@ -53,20 +64,32 @@ function remainingFooter(footer,spans) {
}
// D28 ruling (final review A, Minor 1): Overview gets a distinct outline home glyph. The concept has no home icon, and
// its `activity` pulse stays reserved for the Activity row D5 omits, so the two entries never share one glyph.
// its `activity` pulse belongs to the Activity row, so the two entries never share one glyph.
const OVERVIEW_GLYPH='M3 11 12 4l9 7M5 10v10h14V10M10 20v-6h4v6';
/**
* The concept's SAVED VIEWS rows (app.js a331–335) as production lists them: Favorites with its count (D51), then the
* saved views. D50: no "Needs review" row until the tag-review slice (maintainer ruling 2026-10-21).
*/
function savedNav(nav) {
const rows=nav.rows.filter(row=>row.text!=='Needs review');
return {...nav,rows:rows.map((row,index)=>index===rows.length-1?{...row,gapAfter:null}:row)};
}
/** Recorded shell deviations only (spec §8). Everything else in the concept inventory must equal production. */
function mapShellConcept(reference) {
const {chrome}=reference;
const navs=chrome.navs
// D28: SAVED VIEWS and COLLECTIONS are proposals with no persisted store, so their navs are not rendered.
.filter(nav=>nav.id!=='saved-nav'&&nav.id!=='collections-nav')
// D28: COLLECTIONS is a proposal with no persisted store, so its nav is not rendered.
.filter(nav=>nav.id!=='collections-nav')
.map(nav=>{
if(nav.id==='saved-nav')return savedNav(nav);
if(nav.id!=='system-nav')return nav;
const system=rowNamed(nav,'System');
return {...nav,rows:[
// D5: no Activity row until an activity center exists. D28: no Design review (prototype page).
// D52: the Activity row counts running jobs only, so with none it has no count.
{...rowNamed(nav,'Activity'),count:null},
// D28: no Design review (prototype page).
system,
// D28: the production Overview hub follows System, as a concept nav row without a count. Its icon is
// the ruling's own outline home glyph (OVERVIEW_GLYPH), never the concept's `activity` pulse.
@@ -76,17 +99,20 @@ function mapShellConcept(reference) {
return {...reference,chrome:{...chrome,
// D29: no prototype suffix in the document title.
title:chrome.title.replace(/ · Prompt Manager concept$/,' · Prompt Manager'),
// D28: only the WORKSPACE group label remains.
labels:chrome.labels.filter(label=>label.text==='WORKSPACE'),
// D28: no COLLECTIONS label. D49: SAVED VIEWS without its "PROPOSED" badge.
labels:chrome.labels.filter(label=>label.text!=='COLLECTIONS PROPOSED')
.map(label=>label.text==='SAVED VIEWS PROPOSED'?{...label,text:'SAVED VIEWS'}:label),
navs,
connection:{...chrome.connection,
// D28: truthful copy instead of "Demo connection · Classic / Nodes 2.0".
small:'Your library, on your server',
// D28: no fake connection state, so the dot is the neutral muted token (the concept's label color).
dot:{...chrome.connection.dot,backgroundColor:chrome.labels[0].color}},
// D29: no "Interactive concept" badge. D5: no Activity button. The row's size and the remaining items'
// centered offsets follow from the concept's own item sizes.
topActions:remainingTopActions(chrome.topActions),
// D29: no "Interactive concept" badge. D53: the progress pill before the Activity button, hidden with no running
// job. D52: the button's count is hidden at 0. The row's size and the remaining items' centered offsets follow
// from the concept's own item sizes.
topActions:remainingTopActions({...chrome.topActions,items:chrome.topActions.items.flatMap(item=>
item.tag==='button'&&item.text==='Activity'?[{tag:'a',className:'activity-pill',shown:false},{...item,count:'0'}]:[item])}),
footer:(()=>{
// D29: production footer copy (one line, as the concept's own copy is), and no "What changes & why"
// (a prototype review page); the footer then holds only its span.
@@ -199,6 +225,30 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`shell geometry
}
});
// Saved views (#249 D16): a view saved from the Images toolbar adds the concept's own saved row (search icon, name, no
// count) to SAVED VIEWS. The concept's demo action reads "Apply in prototype"; production's is "Save" (D29).
for(const [width,height] of SIZES)test(`a saved view row matches the concept's ${width}x${height}`,async({page})=>{
await page.setViewportSize({width,height});
const save=async(action,mainSelector)=>{
await page.locator(`${mainSelector} .toolbar`).getByRole('button',{name:'Save view',exact:true}).click();
const dialog=page.getByRole('dialog',{name:'Save this view',exact:true});
await expect(dialog.getByRole('textbox',{name:'View name',exact:true})).toHaveValue('My workspace view');
await dialog.getByRole('textbox',{name:'View name',exact:true}).fill('Golden hour');
await dialog.getByRole('button',{name:action,exact:true}).click();
await expect(page.locator('#saved-nav').getByRole('link',{name:'Golden hour',exact:true})).toHaveCount(1);
await page.mouse.move(0,0);
return inventoryOf(page,mainSelector);
};
await openConcept(page,'images','dark');
const reference=await save('Apply in prototype','#main');
expect(reference.chrome.navs.find(nav=>nav.id==='saved-nav').rows.map(row=>row.text)).toEqual(['Favorites','Needs review','Golden hour']);
await openProduction(page,'images','dark');
const actual=await save('Save','#workspace-main');
expect(diffInventory(mapShellConcept(reference).chrome.navs,actual.chrome.navs)).toEqual([]);
expect(actual.chrome.navs.find(nav=>nav.id==='saved-nav').rows.map(row=>[row.text,row.count]))
.toEqual([['Favorites',reference.chrome.navs.find(nav=>nav.id==='saved-nav').rows[0].count],['Golden hour',null]]);
});
test('the inventory reports a missing shell element by its path instead of failing',async({page})=>{
await page.setViewportSize({width:1600,height:1100});
await openConcept(page,'tags','dark');await page.mouse.move(0,0);
+1 -1
View File
@@ -1,4 +1,4 @@
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import {test,expect} from './fixtures.mjs';
import {captureCanonicalState} from './history-canonical-comparison.mjs';
// Artifact-only captures of Tags & categories states that have no concept counterpart: the Manage categories
+35
View File
@@ -0,0 +1,35 @@
// Each Playwright worker owns its server: `request`, `page` and manual
// `browser.newContext()` all reach it through `baseURL`, and another server
// process never sees its database.
import { test, expect } from './fixtures.mjs';
import { startBrowserServer } from '../../tools/vendor/start-browser-server.mjs';
const search = text => `/prompt_manager/search?text=${encodeURIComponent(text)}`;
test('every fixture reaches this worker\'s own server and another server keeps its own database', async ({ request, page, browser, baseURL, workerServer }, testInfo) => {
expect(baseURL).toBe(workerServer.url);
await expect(await request.post('/__test__/reset')).toBeOK();
const marker = `worker ${testInfo.workerIndex} isolation ${testInfo.project.name} ${testInfo.repeatEachIndex}`;
await expect(await request.post('/prompt_manager/save', { data: { text: marker } })).toBeOK();
expect((await (await request.get(search(marker))).json()).count).toBe(1);
const viaPage = await page.request.get(search(marker));
expect(viaPage.url().startsWith(`${baseURL}/`)).toBe(true);
expect((await viaPage.json()).count).toBe(1);
const context = await browser.newContext();
try {
const response = await (await context.newPage()).goto(search(marker));
expect(response.url().startsWith(`${baseURL}/`)).toBe(true);
expect((await response.json()).count).toBe(1);
} finally {
await context.close();
}
const other = await startBrowserServer();
try {
expect(other.url).not.toBe(baseURL);
expect((await (await fetch(`${other.url}${search(marker)}`)).json()).count).toBe(0);
} finally {
await other.stop();
}
});
+6 -1
View File
@@ -1,5 +1,5 @@
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import { test, expect } from './fixtures.mjs';
test.beforeEach(async ({ request, page, browserName }) => {
await startCoverage(page, browserName);
@@ -64,11 +64,16 @@ test('actual Tags page reloads the selected filter and opens its image viewer',
await page.goto('/prompt_manager/admin#/tags');
await page.locator('[data-tag-name="cats"]').click();
await expect(page.locator('#tagPromptsGrid')).toContainText('Cat portrait');
const dogs = page.waitForResponse(response => response.url().includes('/tags/dogs/prompts?'));
await page.locator('[data-tag-name="dogs"]').click();
const [shown] = (await (await dogs).json()).prompts[0].images;
await expect(page.locator('#tagPromptsGrid')).toContainText('Dog landscape');
await expect(page.locator('#tagPromptsGrid')).not.toContainText('Cat portrait');
await page.locator('#tagPromptsGrid img').first().click();
await expect(page.locator('.viewer-canvas img')).toBeVisible();
// The viewer's title names the shown image through the page's title callback. ViewerJS renders it only once the
// image is viewed, so closing at "visible" alone left the callback unrun on slower hosts (CI coverage missed it).
await expect(page.locator('.viewer-title')).toHaveText(shown.filename);
await page.locator('.viewer-container').getByRole('button', { name: 'Close', exact: true }).click();
await expect(page.locator('.viewer-container')).toHaveCount(0);
});
+801
View File
@@ -0,0 +1,801 @@
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
import { seedConcept, openConcept, openProduction } from './concept-inventory.mjs';
// 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.
const workspace = '/prompt_manager/workspace';
const main = page => page.locator('#workspace-main');
const pill = page => page.locator('.top-actions #activity-pill');
const activityButton = page => page.locator('.top-actions button#activity-open');
const topCount = page => page.locator('#activity-count');
const navRow = page => page.locator('#system-nav a[data-route="activity"]');
const toast = page => page.locator('#workspace-toast');
const items = page => main(page).locator('section.surface > .activity-item');
const LOST = 'This job ended when ComfyUI restarted.';
// The job view (Task 3, D56): the page heading, then the Progress and Results surfaces and the Scan log.
const heading = page => main(page).locator(':scope > .heading');
const progressSection = page => main(page).getByRole('region', { name: 'Progress', exact: true });
const resultsSection = page => main(page).getByRole('region', { name: 'Results', exact: true });
const tiles = page => progressSection(page).locator('dl.stat-row > div.stat');
const outcomeButtons = page => resultsSection(page).locator('.outcome-list > button');
const fileRows = page => resultsSection(page).locator('.outcome-files > .file-row');
const resultsNote = page => resultsSection(page).locator('.outcome-files > p.small');
const filter = page => resultsSection(page).getByRole('searchbox', { name: 'Filter results', exact: true });
const scanLog = page => main(page).locator('details.term');
const TILE_LABELS = ['Added to Library', 'Already known', 'Needs review', 'No usable metadata', 'Errors'];
const VIEWER_HINT = 'each row opens the image in the viewer';
async function tileValues(page) {
return tiles(page).evaluateAll(nodes => nodes.map(node => [node.className, node.querySelector('dt').textContent,
node.querySelector('dd').textContent]));
}
async function openJob(page, id) {
await page.goto(`${workspace}#/activity/${id}`);
await expect(progressSection(page)).toBeVisible();
}
async function startJob(request, data = {}) {
const response = await request.post('/__test__/jobs/start', { data: { total: 19622, ...data } });
await expect(response).toBeOK();
return (await response.json()).job;
}
async function step(request, id, data) {
await expect(await request.post(`/__test__/jobs/${id}/step`, { data })).toBeOK();
}
async function finish(request, id, state = 'finished') {
const response = await request.post(`/__test__/jobs/${id}/finish`, { data: { state } });
await expect(response).toBeOK();
return (await response.json()).job;
}
async function listed(request) {
const response = await request.get('/prompt_manager/jobs');
await expect(response).toBeOK();
return (await response.json()).jobs;
}
test.beforeEach(async ({ page, request, browserName }) => {
await startCoverage(page, browserName);
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
await expect(await request.post('/__test__/reset')).toBeOK();
page.activityErrors = [];
page.on('pageerror', error => page.activityErrors.push(error.message));
});
test.afterEach(async ({ page, request, browserName }) => {
// The store's own reads can meet a route handler at teardown; that must not fail the test it outlived.
await page.unrouteAll({ behavior: 'ignoreErrors' });
await saveCoverage(page, browserName);
expect(page.activityErrors).toEqual([]);
await request.post('/__test__/jobs/restart');
});
/** The concept's item structure and the computed styles its CSS gives it, measured the same way in both documents. */
function measureItems(selector) {
return [...document.querySelectorAll(selector)].map(item => {
const css = (node, ...keys) => node && Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]]));
const tree = node => ({ tag: node.localName, className: [...node.classList].sort().join(' '), children: [...node.children].map(tree) });
const progress = item.querySelector('progress');
return {
tree: tree(item),
title: item.querySelector('h3').textContent,
chip: item.querySelector('.chip').textContent,
buttons: [...item.querySelectorAll('.row-actions button')].map(button => button.textContent),
styles: {
item: css(item, 'paddingTop', 'paddingBottom', 'borderBottomWidth', 'borderBottomStyle', 'borderBottomColor'),
heading: css(item.querySelector('.panel-heading'), 'display', 'justifyContent', 'alignItems', 'columnGap'),
h3: css(item.querySelector('h3'), 'fontSize', 'fontWeight', 'color', 'marginTop', 'marginBottom'),
chip: css(item.querySelector('.chip'), 'fontSize', 'color', 'backgroundColor', 'paddingTop', 'paddingLeft', 'borderTopLeftRadius'),
p: css(item.querySelector('p'), 'fontSize', 'color', 'marginTop', 'lineHeight'),
progress: progress && { ...css(progress, 'height', 'marginTop', 'marginBottom', 'accentColor'),
fills: Math.abs(progress.getBoundingClientRect().width - item.getBoundingClientRect().width) < 1,
max: progress.max, label: Boolean(progress.getAttribute('aria-label')) },
actions: css(item.querySelector('.row-actions'), 'display', 'columnGap', 'marginTop'),
button: css(item.querySelector('.row-actions button'), 'fontSize', 'paddingTop', 'paddingLeft', 'minHeight', 'borderTopLeftRadius'),
},
};
});
}
/** D56: an item tree whose `h3` holds the link to the job's view. */
function withTitleLink(tree) {
const [heading, ...rest] = tree.children;
const link = { tag: 'a', className: '', children: [] };
return { ...tree, children: [{ ...heading, children: heading.children.map(node => node.tag === 'h3' ? { ...node, children: [link] } : node) }, ...rest] };
}
function measurePage(mainSelector) {
const root = document.querySelector(mainSelector);
const section = root.querySelector(':scope > section.surface');
return {
h1: root.querySelector('.heading h1').textContent,
description: root.querySelector('.heading p.muted').textContent,
actions: [...root.querySelector('.heading .heading-actions').children].map(node => node.textContent),
h2: section.querySelector(':scope > h2').textContent,
surface: Object.fromEntries(['paddingTop', 'borderTopWidth', 'backgroundColor', 'borderTopLeftRadius']
.map(key => [key, getComputedStyle(section)[key]])),
};
}
for (const theme of ['dark', 'light']) test(`activity page matches the concept list markup ${theme}`, async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
await seedConcept(request);
// The concept: its finished sample, then a running job started from System (startJob, app.js 940–952).
await openConcept(page, 'activity', theme);
const conceptPage = await page.evaluate(measurePage, '#main');
const [conceptDone] = await page.evaluate(measureItems, '#main .activity-item');
await page.goto('/__test__/canonical/workspace#system');
await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption(theme);
await page.getByRole('button', { name: 'Review thumbnails', exact: true }).click();
await expect(page.locator('#main .activity-item progress')).toHaveCount(1);
const [conceptRunning] = await page.evaluate(measureItems, '#main .activity-item');
const done = await startJob(request);
await step(request, done.id, { processed: 19622, counters: { added: 1420, errors: 18 } });
await finish(request, done.id);
const running = await startJob(request);
await step(request, running.id, { processed: 8240 });
await openProduction(page, 'activity', theme);
await expect(items(page)).toHaveCount(2);
const productionPage = await page.evaluate(measurePage, '#workspace-main');
const [productionRunning, productionDone] = await page.evaluate(measureItems, '#workspace-main .activity-item');
// D52: no "Unified activity · proposed" chip (prototype copy); the rest of the heading and the section are the concept's.
expect(conceptPage.actions).toEqual(['Unified activity · proposed']);
expect(productionPage).toEqual({ ...conceptPage, actions: [] });
// A completed job is the concept's completed item; D56: its title is a link to the job's view, in the title's colour.
expect(conceptDone.title).toBe('✓ Library metadata enriched');
expect(productionDone).toEqual({ ...conceptDone, tree: withTitleLink(conceptDone.tree), title: '✓ Scan for metadata' });
const links = await items(page).locator('h3 > a').evaluateAll(nodes => nodes.map(node => [node.getAttribute('href'),
getComputedStyle(node).color === getComputedStyle(node.parentElement).color, getComputedStyle(node).textDecorationLine]));
expect(links).toEqual([[`#/activity/${running.id}`, true, 'none'], [`#/activity/${done.id}`, true, 'none']]);
// D54: a running job's chip names its state ("Running", where the concept's sample reads "Needs attention"), and
// its actions are Cancel job only (no "Simulate completion").
expect(conceptRunning.buttons).toEqual(['Simulate completion', 'Cancel job']);
const [, ...cancelOnly] = conceptRunning.tree.children.at(-1).children;
const conceptActions = { ...conceptRunning.tree.children.at(-1), children: cancelOnly };
expect(productionRunning).toEqual({ ...conceptRunning,
tree: withTitleLink({ ...conceptRunning.tree, children: [...conceptRunning.tree.children.slice(0, -1), conceptActions] }),
title: '○ Scan for metadata', chip: 'Running', buttons: ['Cancel job'] });
await expect(items(page).nth(1).locator('p')).toHaveText(/^Finished \d\d:\d\d · 1,420 added · 18 errors$/);
await expect(items(page).first().locator('p')).toHaveText(/^Started \d\d:\d\d · 2 output folders · closing this view does not cancel it$/);
await expect(items(page).first().locator('progress')).toHaveAttribute('aria-label', 'Scan for metadata progress');
expect(await items(page).first().locator('progress').evaluate(node => node.value)).toBe(41);
});
test('pill shows while a job runs and opens its view', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
await page.goto(`${workspace}#/images`);
// D52: no running job, so neither count shows; the button is the concept's.
await expect(activityButton(page)).toHaveAccessibleName('Activity');
await expect(topCount(page)).toBeHidden();
await expect(navRow(page)).toHaveText('Activity');
await expect(navRow(page).locator('.nav-count')).toHaveCount(0);
await expect(pill(page)).toBeHidden();
const job = await startJob(request);
// The list is read again on its own (15 s), or at once on a reload or when Activity opens.
await page.reload();
await expect(pill(page)).toBeVisible();
await expect(pill(page)).toHaveText('Scanning · 0% · 0 / 19,622');
await step(request, job.id, { processed: 8240 });
const text = 'Scanning · 41% · 8,240 / 19,622';
await expect(pill(page)).toHaveText(text);
await expect(pill(page)).toHaveAttribute('aria-label', text);
await expect(pill(page)).toHaveAttribute('href', `#/activity/${job.id}`);
expect(await pill(page).locator('.activity-pill-bar i').evaluate(node => node.style.width)).toBe('41%');
// The pill sits before the button, in the mockup's place, and both counts read 1.
expect(await page.locator('.top-actions > *').evaluateAll(nodes => nodes.map(node => node.id || node.className)))
.toEqual(['activity-pill', 'activity-open', 'theme-label', 'canvas-link']);
await expect(topCount(page)).toBeVisible();
await expect(topCount(page)).toHaveText('1');
await expect(navRow(page).locator('.nav-count')).toHaveText('1');
// The pill opens the job's own view (Task 3, D56), named by the job.
await pill(page).click();
await expect(page).toHaveURL(new RegExp(`#/activity/${job.id}$`));
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Scan for metadata');
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Activity');
await expect(page).toHaveTitle('Scan for metadata · Prompt Manager');
await expect(navRow(page)).toHaveAttribute('aria-current', 'page');
await expect(progressSection(page).locator('progress')).toHaveJSProperty('value', 41);
// The Activity button opens the list itself from any page.
await page.locator('nav [data-route="library"]').click();
await activityButton(page).click();
await expect(page).toHaveURL(/#\/activity$/);
await expect(items(page)).toHaveCount(1);
await finish(request, job.id);
await expect(pill(page)).toBeHidden();
await expect(topCount(page)).toBeHidden();
await expect(navRow(page).locator('.nav-count')).toHaveCount(0);
await expect(items(page).first().locator('.chip.green')).toHaveText('Completed');
await expect(items(page).first().locator('progress, .row-actions')).toHaveCount(0);
// A finished job's title still opens its view (D56).
await items(page).first().locator('h3 > a').click();
await expect(page).toHaveURL(new RegExp(`#/activity/${job.id}$`));
await expect(heading(page).locator('p.muted')).toHaveText(/^Finished \d\d:\d\d · 0 added · 0 errors$/);
});
test('a phone keeps the pill and its controls inside the topbar', async ({ page, request }) => {
await page.setViewportSize({ width: 390, height: 844 });
const job = await startJob(request);
await step(request, job.id, { processed: 8240 });
await page.goto(`${workspace}#/images`);
await expect(pill(page)).toHaveText('Scanning · 41% · 8,240 / 19,622');
await expect(pill(page)).toHaveAttribute('aria-label', 'Scanning · 41% · 8,240 / 19,622');
await expect(pill(page).locator('.activity-pill-files, .activity-pill-bar')).toHaveCount(2);
await expect(pill(page).locator('.activity-pill-files')).toBeHidden();
const layout = await page.locator('.top-actions').evaluate(row => ({
scroll: document.documentElement.scrollWidth <= document.documentElement.clientWidth,
inside: [...row.children].filter(node => node.getBoundingClientRect().width)
.every(node => node.getBoundingClientRect().right <= row.getBoundingClientRect().right + 0.5
&& node.getBoundingClientRect().left >= row.getBoundingClientRect().left - 0.5),
}));
expect(layout).toEqual({ scroll: true, inside: true });
});
test('reload during a scan reattaches to the running job', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const job = await startJob(request);
await step(request, job.id, { processed: 1000 });
await page.goto(`${workspace}#/activity`);
await expect(items(page)).toHaveCount(1);
await expect(pill(page)).toHaveText('Scanning · 5% · 1,000 / 19,622');
await page.reload();
await expect(pill(page)).toHaveText('Scanning · 5% · 1,000 / 19,622');
// The reloaded page follows the same job live and started nothing.
await step(request, job.id, { processed: 9811 });
await expect(pill(page)).toHaveText('Scanning · 50% · 9,811 / 19,622');
expect(await items(page).first().locator('progress').evaluate(node => node.value)).toBe(50);
await expect(items(page)).toHaveCount(1);
expect((await listed(request)).map(item => [item.id, item.state])).toEqual([[job.id, 'running']]);
// A second tab attaches to the same job and sees the same progress.
const other = await page.context().newPage();
await other.goto(`${workspace}#/activity`);
await expect(pill(other)).toHaveText('Scanning · 50% · 9,811 / 19,622');
await expect(other.locator('#workspace-main .activity-item')).toHaveCount(1);
await other.close();
expect((await listed(request)).length).toBe(1);
// Cancel job asks the server to stop at a file boundary; the job ends cancelled with its partial counts.
await step(request, job.id, { counters: { added: 7 } });
await items(page).first().getByRole('button', { name: 'Cancel job', exact: true }).focus();
await page.keyboard.press('Enter');
await expect(toast(page)).toHaveText('Scan cancelled · 7 added');
// The removed Cancel job hands its focus to the item's title link (review M4).
await expect(items(page).first().locator('h3 > a')).toBeFocused();
await expect(items(page).first().locator('.chip.blue')).toHaveText('Cancelled');
await expect(items(page).first().locator('p')).toHaveText(/^Cancelled \d\d:\d\d at a file boundary · 7 added · completed work is kept$/);
await expect(pill(page)).toBeHidden();
});
test('lost server clears the pill and marks jobs ended', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const job = await startJob(request);
await step(request, job.id, { processed: 4000 });
await page.goto(`${workspace}#/activity`);
await expect(pill(page)).toBeVisible();
await expect(topCount(page)).toHaveText('1');
const reads = [];
page.on('request', item => { if (new URL(item.url()).pathname === '/prompt_manager/jobs') reads.push(Date.now()); });
// ComfyUI restarts: the stream drops without a final event and the new registry is empty.
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
await expect(pill(page)).toBeHidden();
await expect(topCount(page)).toBeHidden();
await expect(items(page).first().locator('.chip.blue')).toHaveText('Ended');
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.
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.
const second = await startJob(request);
await page.reload();
await expect(pill(page)).toBeVisible();
const failed = [];
await page.route('**/prompt_manager/jobs**', route => { failed.push(Date.now()); return route.abort(); });
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
await expect(pill(page)).toBeHidden();
await expect(items(page).filter({ hasText: LOST })).toHaveCount(1);
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);
});
test('start and finish toasts', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const job = await startJob(request);
await page.goto(`${workspace}#/images`);
await expect(pill(page)).toBeVisible();
await step(request, job.id, { processed: 19622, counters: { added: 1420, errors: 18 } });
await finish(request, job.id);
await expect(toast(page)).toHaveText('Scan finished · 1,420 added · 18 errors');
await expect(pill(page)).toBeHidden();
const single = await startJob(request);
await page.reload();
await expect(pill(page)).toBeVisible();
await step(request, single.id, { processed: 19622, counters: { added: 1, errors: 1 } });
await finish(request, single.id, 'failed');
await expect(toast(page)).toHaveText('Scan failed · 1 added · 1 error');
// The store's own start: the real metadata scan over this server's outputs, announced, followed and finished.
await page.goto(`${workspace}#/activity`);
const started = await page.evaluate(async () => {
const { createActivityStore } = await import('/prompt_manager/js/workspace/activity-store.mjs');
const controller = new AbortController();
const store = createActivityStore({ api: window.ApiClient.createApiClient(), pollMs: 0,
scope: { signal: controller.signal, cleanup: () => {} } });
const result = await store.start('scan');
// Read before the busy attempt: its list read may already see the small scan finished and toast that.
const toast = document.getElementById('workspace-toast').textContent;
const busy = await store.start('scan').catch(error => ({ error: error.message }));
return { id: result.job.id, title: result.job.title, toast, busy: busy.busy?.id ?? null, message: busy.message ?? null };
});
expect(started.title).toBe('Scan for metadata');
expect(started.toast).toBe('Scan started.');
// A second start while the scan runs is refused and names it (unless the small fixture scan already ended).
if (started.busy !== null) expect(started.busy).toBe(started.id);
await expect(toast(page)).toHaveText(/^Scan (started\.|finished · \d+ added · \d+ errors?)$/);
await expect.poll(async () => (await listed(request)).find(item => item.id === started.id)?.state, { timeout: 15000 }).toBe('finished');
});
// ── Task 3: Scan for metadata, its review dialog and the job view ─────────────────────────────
async function startFromImport(page) {
await page.goto(`${workspace}#/images`);
await page.getByRole('button', { name: 'Import', exact: true }).click();
const importDialog = page.getByRole('dialog', { name: 'Import & inspect', exact: true });
const row = importDialog.locator('.operation').filter({ hasText: 'Scan for metadata' });
await row.getByRole('button', { name: 'Review scan', exact: true }).click();
const review = page.getByRole('dialog', { name: 'Review metadata scan', exact: true });
await expect(review).toBeVisible();
await review.getByRole('button', { name: 'Start scan', exact: true }).click();
await expect(review).toHaveCount(0);
}
test('start scan creates a job, toasts, and opens its view', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const posted = [];
page.on('request', item => { if (item.method() === 'POST' && new URL(item.url()).pathname === '/prompt_manager/jobs/scan') posted.push(item); });
await startFromImport(page);
await expect(page).toHaveURL(/#\/activity\/[0-9a-f]{32}$/);
const id = page.url().split('/').at(-1);
expect(posted).toHaveLength(1);
// The fixture scan is small and may already have finished; the start toast came first either way.
await expect(toast(page)).toHaveText(/^Scan (started\.|finished · \d+ added · \d+ errors?)$/);
await expect(heading(page).locator('h1')).toHaveText('Scan for metadata');
await expect(heading(page).locator('h1')).toBeFocused();
expect((await listed(request)).map(item => item.id)).toEqual([id]);
await expect.poll(async () => (await listed(request))[0].state, { timeout: 15000 }).toBe('finished');
await expect(heading(page).locator('p.muted')).toHaveText(/^Finished \d\d:\d\d · \d+ added · \d+ errors?$/);
// The finished scan's tiles add up to the files it processed, and its log is there to open.
const job = (await listed(request))[0];
const values = await tileValues(page);
expect(values.map(([, label]) => label)).toEqual(TILE_LABELS);
expect(values.reduce((sum, [, , value]) => sum + Number(value.replaceAll(',', '')), 0)).toBe(job.processed);
// Opening the log reads its last lines (review M12).
await scanLog(page).locator('summary').click();
await expect(scanLog(page).locator('pre')).toContainText('Scan finished');
});
// ── Task 4 (Activity-2): scan scope, new and unresolved files by default ─────────────────────
test('all files checkbox changes the scope line and the request', async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const bodies = [];
page.on('request', item => {
if (item.method() === 'POST' && new URL(item.url()).pathname === '/prompt_manager/jobs/scan') bodies.push(item.postDataJSON());
});
const review = page.getByRole('dialog', { name: 'Review metadata scan', exact: true });
const scopeValue = review.locator('.meta-grid > div').filter({ has: page.locator('dt', { hasText: 'Scope' }) }).locator('dd');
// The concept's switch: a checkbox with role=switch.
const allFiles = review.getByRole('switch', { name: 'All files', exact: true });
const openReview = async () => {
await page.goto(`${workspace}#/images`);
await page.getByRole('button', { name: 'Import', exact: true }).click();
await page.getByRole('dialog', { name: 'Import & inspect', exact: true }).locator('.operation')
.filter({ hasText: 'Scan for metadata' }).getByRole('button', { name: 'Review scan', exact: true }).click();
await expect(review).toBeVisible();
};
// The default: the concept's "New and unresolved files", the box unchecked; the request asks for "new".
await openReview();
await expect(scopeValue).toHaveText('New and unresolved files');
await expect(allFiles).not.toBeChecked();
await expect(review.locator('.setting-row p')).toHaveText('Also read the files already linked to their prompt again.');
await expect(allFiles).toHaveAccessibleDescription('Also read the files already linked to their prompt again.');
// Checking and unchecking switches the Scope value both ways.
await allFiles.check();
await expect(scopeValue).toHaveText('All files');
await allFiles.uncheck();
await expect(scopeValue).toHaveText('New and unresolved files');
await review.getByRole('button', { name: 'Start scan', exact: true }).click();
await expect(page).toHaveURL(/#\/activity\/[0-9a-f]{32}$/);
await expect.poll(() => bodies.length).toBe(1);
expect(bodies[0]).toEqual({ scope: 'new' });
await expect(heading(page).locator('h1')).toHaveText('Scan for metadata');
// Wait for the first scan to end so the second is not refused as busy.
await scanLog(page).locator('summary').click();
await expect(scanLog(page).locator('pre')).toContainText('Scan finished', { timeout: 15000 });
await expect(scanLog(page).locator('pre')).toContainText('new and unresolved files');
// Checked: "All files", and the request asks for "all".
await openReview();
await allFiles.check();
await expect(scopeValue).toHaveText('All files');
await review.getByRole('button', { name: 'Start scan', exact: true }).click();
await expect.poll(() => bodies.length).toBe(2);
expect(bodies[1]).toEqual({ scope: 'all' });
await scanLog(page).locator('summary').click();
await expect(scanLog(page).locator('pre')).toContainText('Scan started · ', { timeout: 15000 });
await expect(scanLog(page).locator('pre')).toContainText('all files');
});
test('job view counters follow the mapping and update live', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const job = await startJob(request);
await step(request, job.id, { processed: 8240, counters: { added: 1420, known: 6511, review: 37, no_metadata: 254, errors: 18 } });
await openJob(page, job.id);
// The header: the title, the detail line, then Download report (enabled in Task 5) and Cancel scan (danger).
await expect(heading(page).locator('h1')).toHaveText('Scan for metadata');
await expect(heading(page).locator('p.muted')).toHaveText(/^Started \d\d:\d\d · 2 output folders · closing this view does not cancel it$/);
await expect(heading(page).locator('.heading-actions > button')).toHaveText(['Download report', 'Cancel scan']);
await expect(heading(page).getByRole('button', { name: 'Download report', exact: true })).toBeEnabled();
await expect(heading(page).getByRole('button', { name: 'Cancel scan', exact: true })).toHaveClass(/danger/);
// Progress: the bar, the server's line and the five tiles in order and colour.
await expect(progressSection(page).locator('.panel-heading > span.small')).toHaveText('Processing file 8,240 of 19,622');
await expect(progressSection(page).locator('progress')).toHaveAttribute('aria-label', 'Scan progress');
await expect(progressSection(page).locator('progress')).toHaveJSProperty('value', 41);
expect(await tileValues(page)).toEqual([
['stat added', 'Added to Library', '1,420'], ['stat', 'Already known', '6,511'], ['stat warn', 'Needs review', '37'],
['stat', 'No usable metadata', '254'], ['stat bad', 'Errors', '18'],
]);
const colours = await tiles(page).evaluateAll(nodes => nodes.map(node => getComputedStyle(node.querySelector('dd')).color));
const expected = await page.evaluate(() => ['--green', '--ca-text', '--warning', '--ca-text', '--red'].map(name => {
const probe = document.createElement('span');
probe.style.color = `var(${name})`;
document.querySelector('#workspace-main').append(probe);
const colour = getComputedStyle(probe).color;
probe.remove();
return colour;
}));
expect([colours[0], colours[2], colours[4]]).toEqual([expected[0], expected[2], expected[4]]);
expect(colours[1]).toBe(colours[3]);
// The outcome list follows the mockup's order with the same counts.
await expect(outcomeButtons(page)).toHaveText(['Added to Library1,420', 'Needs review37', 'Errors18', 'No usable metadata254', 'Already known6,511']);
// Live: the next step updates the bar, the line and the tiles without a reload.
await step(request, job.id, { processed: 9811, counters: { added: 1500, errors: 19 }, records: [
{ group: 'added', path: 'live/new.png', detail: 'Added to the Library as “Live”' },
] });
await expect(progressSection(page).locator('.panel-heading > span.small')).toHaveText('Processing file 9,811 of 19,622');
await expect(progressSection(page).locator('progress')).toHaveJSProperty('value', 50);
await expect(tiles(page).first().locator('dd')).toHaveText('1,500');
await expect(tiles(page).last().locator('dd')).toHaveText('19');
await expect(fileRows(page)).toHaveCount(1);
// A focused row keeps its focus while later reads append rows.
await fileRows(page).first().focus();
await step(request, job.id, { records: [{ group: 'added', path: 'live/next.png', detail: 'Added' }] });
await expect(fileRows(page)).toHaveCount(2);
await expect(fileRows(page).first()).toBeFocused();
// The log is collapsed by default and holds the raw timestamped lines.
await expect(scanLog(page)).not.toHaveAttribute('open', '');
await expect(scanLog(page).locator('summary')).toContainText('Scan log');
await scanLog(page).locator('summary').click();
await expect(scanLog(page).locator('pre')).toHaveAttribute('tabindex', '0');
await expect(scanLog(page).locator('pre')).toHaveText(/^\d\d:\d\d:\d\d \+ live\/new\.png · Added to the Library as “Live”\n\d\d:\d\d:\d\d \+ live\/next\.png · Added$/);
// Finished: the header says so, Cancel scan is gone and the counters stay.
await finish(request, job.id);
await expect(heading(page).locator('p.muted')).toHaveText(/^Finished \d\d:\d\d · 1,501 added · 19 errors$/);
await expect(heading(page).locator('.heading-actions > button')).toHaveText(['Download report']);
await expect(tiles(page).first().locator('dd')).toHaveText('1,501');
});
test('added rows open the viewer, error rows are plain', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const job = await startJob(request);
// The fixture's two listed files share a name in the output and input roots; the row's root label tells them apart.
await step(request, job.id, { processed: 3, records: [
{ group: 'added', path: 'shared/same.png', root: 'output', detail: 'Added to the Library as “Cat portrait”' },
{ group: 'known', path: 'shared/same.png', root: 'input', detail: 'Already linked to “Dog portrait”' },
{ group: 'errors', path: 'broken/truncated.png', root: 'output', detail: 'Error: the file could not be processed (see the server log)' },
] });
await openJob(page, job.id);
await expect(outcomeButtons(page).first()).toHaveAttribute('aria-pressed', 'true');
await expect(fileRows(page)).toHaveCount(1);
const added = fileRows(page).first();
expect(await added.evaluate(node => node.localName)).toBe('button');
await expect(added.locator('.path')).toHaveText('shared/same.png');
await expect(added.locator('.why')).toHaveText('Added to the Library as “Cat portrait”');
await expect(resultsNote(page)).toHaveText('Each row opens the image in the viewer.');
await added.click();
const viewer = page.locator('dialog#viewer-dialog[open]');
await expect(viewer).toBeVisible();
await expect(viewer.locator('#viewer-title')).toContainText('same.png');
await expect(viewer.locator('#viewer-stage img').first()).toHaveAttribute('src', /[?&]root=0(&|$)/);
await page.keyboard.press('Escape');
await expect(viewer).toHaveCount(0);
await expect(added).toBeFocused();
// Already known opens the input root's file.
await outcomeButtons(page).filter({ hasText: 'Already known' }).click();
await expect(outcomeButtons(page).filter({ hasText: 'Already known' })).toHaveAttribute('aria-pressed', 'true');
await fileRows(page).first().click();
await expect(page.locator('dialog#viewer-dialog[open] #viewer-stage img').first()).toHaveAttribute('src', /[?&]root=1(&|$)/);
await page.keyboard.press('Escape');
// Errors: plain text rows, no viewer hint.
await outcomeButtons(page).filter({ hasText: 'Errors' }).click();
await expect(fileRows(page)).toHaveCount(1);
expect(await fileRows(page).first().evaluate(node => node.localName)).toBe('div');
await expect(fileRows(page).first().locator('.path')).toHaveText('broken/truncated.png');
await expect(resultsNote(page)).toHaveCount(0);
// An empty group says so.
await outcomeButtons(page).filter({ hasText: 'Needs review' }).click();
await expect(resultsSection(page).locator('.outcome-files > p.empty-state')).toHaveText('No files in this group.');
// A listed row whose file has left the listing toasts instead of opening an empty viewer.
await step(request, job.id, { records: [{ group: 'added', path: 'gone/missing.png', root: 'output', detail: 'Added' }] });
await outcomeButtons(page).first().click();
await expect(fileRows(page)).toHaveCount(2);
await fileRows(page).nth(1).click();
await expect(toast(page)).toHaveText('This file is no longer in Images. Rescan from Import and try again.');
await expect(page.locator('dialog#viewer-dialog')).toHaveCount(0);
// A row opens only its own root's file: a root whose label no longer matches (a replaced source) never opens the
// other root's same path, and a row without a root index opens nothing (review M8, M9).
const lookups = [];
page.on('request', item => { const url = new URL(item.url()); if (url.pathname === '/prompt_manager/images/output' && url.searchParams.get('path')) lookups.push(url.search); });
await step(request, job.id, { records: [
{ group: 'added', path: 'shared/same.png', root: 'output', root_index: 1, detail: 'Added' },
{ group: 'added', path: 'shared/same.png', root: 'output', root_index: null, detail: 'Added' },
] });
await expect(fileRows(page)).toHaveCount(4);
for (const index of [2, 3]) {
await fileRows(page).nth(index).click();
await expect(toast(page)).toHaveText('This file is no longer in Images. Rescan from Import and try again.');
await expect(page.locator('dialog#viewer-dialog')).toHaveCount(0);
}
expect(lookups).toEqual([]);
// The lookup is the exact identity, not a name search.
await fileRows(page).first().click();
await expect(page.locator('dialog#viewer-dialog[open]')).toBeVisible();
expect(lookups).toEqual(['?mode=workspace&root=0&path=shared%2Fsame.png&limit=5']);
await page.keyboard.press('Escape');
});
test('filter narrows the selected outcome', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const job = await startJob(request);
await step(request, job.id, { processed: 4, records: [
{ group: 'added', path: 'CCC/portrait_0001.png', detail: 'Added' },
{ group: 'added', path: 'CCC/portrait_0002.png', detail: 'Added' },
{ group: 'added', path: 'forest/pines_0008.png', detail: 'Added' },
{ group: 'errors', path: 'CCC/broken.png', detail: 'Error' },
] });
await openJob(page, job.id);
await expect(fileRows(page)).toHaveCount(3);
await expect(filter(page)).toHaveAttribute('placeholder', 'Filter by file path…');
await filter(page).fill('ccc/');
await expect(fileRows(page).locator('.path')).toHaveText(['CCC/portrait_0001.png', 'CCC/portrait_0002.png']);
await expect(resultsNote(page)).toHaveText(`Showing 2 of 3 · ${VIEWER_HINT}`);
// The filter stays while another outcome is chosen.
await outcomeButtons(page).filter({ hasText: 'Errors' }).click();
await expect(fileRows(page).locator('.path')).toHaveText(['CCC/broken.png']);
await filter(page).fill('pines');
await expect(fileRows(page)).toHaveCount(0);
await expect(resultsSection(page).locator('.outcome-files > p.empty-state')).toHaveText('No listed file matches this filter.');
await outcomeButtons(page).first().click();
await expect(fileRows(page).locator('.path')).toHaveText(['forest/pines_0008.png']);
await filter(page).fill('');
await expect(fileRows(page)).toHaveCount(3);
});
test('job view renders 200-row cap note', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
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' })),
] });
// Every detail read of this job: in flight, and whether one has returned the full listing.
const detailUrl = `/prompt_manager/jobs/${job.id}`;
const isDetail = item => new URL(item.url()).pathname === detailUrl;
const detailReads = { inFlight: 0, full: false };
page.on('request', item => { if (isDetail(item)) detailReads.inFlight += 1; });
page.on('requestfailed', item => { if (isDetail(item)) detailReads.inFlight -= 1; });
page.on('response', async response => {
if (!isDetail(response)) return;
const body = await response.json().catch(() => null);
const outcomes = body?.job?.outcomes ?? {};
if (outcomes.added?.length === 200 && outcomes.no_metadata?.length === 10) detailReads.full = true;
detailReads.inFlight -= 1;
});
await openJob(page, job.id);
await expect(tiles(page).first().locator('dd')).toHaveText('250');
await expect(fileRows(page)).toHaveCount(200);
await expect(resultsNote(page)).toHaveText(`Showing 200 of 250 · ${VIEWER_HINT}`);
await outcomeButtons(page).filter({ hasText: 'No usable metadata' }).click();
await expect(fileRows(page)).toHaveCount(10);
await expect(resultsNote(page)).toHaveCount(0);
// Every listed row is in the page and it stays responsive: a filter answers at once.
await outcomeButtons(page).first().click();
await filter(page).fill('file_00199');
await expect(fileRows(page)).toHaveCount(1);
await expect(resultsNote(page)).toHaveText(`Showing 1 of 250 · ${VIEWER_HINT}`);
// Review M12: with the list capped and the log closed, more files only update the counters; the detail is read
// again only when the log opens with new lines.
// The reads from opening the view have settled: one returned the full listing and none is in flight. The view
// re-reads only while what the summary lists differs from the last read, so no further read is due.
await expect.poll(() => detailReads.full && detailReads.inFlight === 0).toBe(true);
const reads = [];
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');
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');
});
test('cancel scan ends cancelled with partial counts', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const job = await startJob(request);
await step(request, job.id, { processed: 9, counters: { added: 7, errors: 2 } });
await openJob(page, job.id);
// From the keyboard: when the job ends and Cancel scan goes away, focus moves to the job's heading (review M4).
await heading(page).getByRole('button', { name: 'Cancel scan', exact: true }).focus();
await page.keyboard.press('Enter');
await expect(toast(page)).toHaveText('Scan cancelled · 7 added');
await expect(heading(page).locator('h1')).toBeFocused();
await expect(heading(page).locator('p.muted')).toHaveText(/^Cancelled \d\d:\d\d at a file boundary · 7 added · completed work is kept$/);
await expect(heading(page).getByRole('button', { name: 'Cancel scan', exact: true })).toHaveCount(0);
await expect(progressSection(page).locator('.panel-heading > span.small')).toHaveText('Cancelled after 9 of 19,622 files');
expect((await tileValues(page)).map(([, , value]) => value)).toEqual(['7', '0', '0', '0', '2']);
expect((await listed(request))[0].state).toBe('cancelled');
// A failed cancel request keeps the button and says so.
const second = await startJob(request);
await openJob(page, second.id);
await page.route('**/cancel', route => route.fulfill({ status: 500, json: { success: false, error: 'nope' } }));
await heading(page).getByRole('button', { name: 'Cancel scan', exact: true }).click();
await expect(toast(page)).toHaveText('The job could not be cancelled. Try again.');
await expect(heading(page).getByRole('button', { name: 'Cancel scan', exact: true })).not.toHaveAttribute('aria-disabled', 'true');
});
test('busy start names the running job', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
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.
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');
// A job of another kind cancels as "Cancel job".
await expect(heading(page).locator('.heading-actions > button')).toHaveText(['Download report', 'Cancel job']);
});
test('an unknown or restarted job view says so', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
await page.goto(`${workspace}#/activity/${'0'.repeat(32)}`);
await expect(main(page).locator('section.surface p.empty-state')).toHaveText('This job is not in this session’s Activity. Jobs are kept until ComfyUI restarts.');
await expect(main(page).getByRole('link', { name: 'All activity', exact: true })).toHaveAttribute('href', '#/activity');
const job = await startJob(request);
await step(request, job.id, { processed: 5, records: [{ group: 'added', path: 'kept/row.png', detail: 'Added' }] });
await openJob(page, job.id);
await expect(fileRows(page)).toHaveCount(1);
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
await expect(heading(page).locator('p.muted')).toHaveText(LOST);
await expect(heading(page).getByRole('button', { name: 'Cancel scan', exact: true })).toHaveCount(0);
// A lost job has no report on the server (review M9): Download report is disabled rather than failing.
await expect(heading(page).getByRole('button', { name: 'Download report', exact: true })).toBeDisabled();
// What was read before the restart stays on the page.
await expect(fileRows(page)).toHaveCount(1);
await expect(pill(page)).toBeHidden();
});
// Review M3: a finished job the server no longer lists is gone from this tab too, and its open view says so.
test('a finished job the server pruned leaves its open view honest', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const job = await startJob(request);
await step(request, job.id, { processed: 1, records: [{ group: 'added', path: 'kept/row.png', detail: 'Added' }] });
await finish(request, job.id);
await openJob(page, job.id);
await expect(fileRows(page)).toHaveCount(1);
await filter(page).focus();
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
// The next periodic list read (15 s) no longer has the finished job.
test.slow();
await expect(main(page).locator('section.surface p.empty-state')).toHaveText('This job is not in this session’s Activity. Jobs are kept until ComfyUI restarts.', { timeout: 20000 });
await expect(progressSection(page)).toHaveCount(0);
await expect(heading(page).locator('h1')).toHaveText('Activity');
await expect(heading(page).locator('h1')).toBeFocused();
await expect(page).toHaveTitle('Activity · Prompt Manager');
});
// Activity-2 Task 5 (D59): Download report and the log's scroll position.
test('download report saves the job JSON', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const job = await startJob(request);
await step(request, job.id, { processed: 2, records: [
{ group: 'added', path: 'sub/a.png', root: 'output', detail: 'Added' },
{ group: 'errors', path: 'broken/b.png', root: 'output', detail: 'Error' },
] });
await finish(request, job.id);
await openJob(page, job.id);
const button = heading(page).getByRole('button', { name: 'Download report', exact: true });
await expect(button).toBeEnabled();
const [download] = await Promise.all([page.waitForEvent('download'), button.click()]);
expect(download.suggestedFilename()).toMatch(/^prompt-manager-scan-\d{8}-\d{4}\.json$/);
const path = await download.path();
const report = JSON.parse(await (await import('node:fs/promises')).readFile(path, 'utf8'));
expect(report.job.id).toBe(job.id);
expect(report.outcomes.added.rows.map(row => row.path)).toEqual(['sub/a.png']);
expect(report.outcomes.errors.truncated).toBe(false);
expect(report.log.length).toBeGreaterThan(0);
await expect(toast(page)).toHaveText('Job report downloaded.');
});
test('log keeps the reader\'s scroll position', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const job = await startJob(request);
const more = (from, count) => Array.from({ length: count }, (_, index) => ({ group: 'known', path: `log/f_${from + index}.png`,
root: 'output', detail: 'Known' }));
await step(request, job.id, { processed: 60, records: more(0, 60) });
await openJob(page, job.id);
await scanLog(page).locator('summary').click();
const pre = scanLog(page).locator('pre');
await expect(pre).toContainText('log/f_59.png');
const metrics = () => pre.evaluate(node => ({ top: node.scrollTop, max: node.scrollHeight - node.clientHeight }));
// Opening starts at the newest line.
await expect.poll(async () => { const m = await metrics(); return m.max > 0 && m.top >= m.max - 2; }).toBe(true);
// Pinned to the bottom: new lines are followed.
await step(request, job.id, { processed: 90, records: more(60, 30) });
await expect(pre).toContainText('log/f_89.png');
await expect.poll(async () => { const m = await metrics(); return m.top >= m.max - 2; }).toBe(true);
// Scrolled up: the position stays while more lines arrive.
await pre.evaluate(node => { node.scrollTop = 40; });
await step(request, job.id, { processed: 120, records: more(90, 30) });
await expect(pre).toContainText('log/f_119.png');
expect((await metrics()).top).toBe(40);
// Closed and opened again (review M6): every open starts at the newest line, also after lines arrived meanwhile.
await scanLog(page).locator('summary').click();
await expect(scanLog(page)).not.toHaveAttribute('open', '');
await step(request, job.id, { processed: 150, records: more(120, 30) });
await scanLog(page).locator('summary').click();
await expect(pre).toContainText('log/f_149.png');
await expect.poll(async () => { const m = await metrics(); return m.max > 0 && m.top >= m.max - 2; }).toBe(true);
});
test('a failed report download says so and leaves the button usable', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const job = await startJob(request);
await finish(request, job.id);
await openJob(page, job.id);
await page.route(`**/prompt_manager/jobs/${job.id}/report`, route => route.fulfill({ status: 500, body: 'no' }));
const button = heading(page).getByRole('button', { name: 'Download report', exact: true });
await button.click();
await expect(toast(page)).toHaveText('The report could not be downloaded. Try again.');
await expect(button).not.toHaveAttribute('aria-disabled', 'true');
});
+328 -20
View File
@@ -1,5 +1,5 @@
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import {test,expect} from './fixtures.mjs';
import {readFile,writeFile} from 'node:fs/promises';
import {createHash} from 'node:crypto';
@@ -9,8 +9,9 @@ const dialog=page=>page.getByRole('dialog',{name:'Recorded run',exact:true});
const saved=page=>page.getByRole('region',{name:'Saved prompt texts',exact:true});
const gallery=page=>page.getByRole('region',{name:'Shared gallery',exact:true});
const open=async(page,id)=>{await page.goto(`/prompt_manager/workspace#/library/${id}`);await expect(recorded(page)).toBeVisible();};
// A history opens on its first negative variant, as in the concept; these contracts start from the whole shared gallery.
const showAllImages=async page=>{const all=page.getByRole('button',{name:'All images for this positive',exact:true});await all.click();await expect(all).toHaveAttribute('aria-pressed','true');};
// A history opens on its first negative variant, as in the concept; these contracts start from the lane that links the
// fixture image (#249 D26: All images for this positive is a dialog now, not a gallery filter).
const showLinkedImage=async page=>{const dot=page.getByRole('button',{name:'Select negative Saved exact negative',exact:true});await dot.click();await expect(dot).toHaveAttribute('aria-pressed','true');};
const marker=(page,id,anchor='10')=>recorded(page).locator(`button[data-run-id="${id}"][data-anchor-id="${anchor}"]`);
const exact=async locator=>locator.evaluate(node=>node.textContent);
@@ -93,7 +94,7 @@ test('same-path replacement is current content and a during-inspection change is
test('root reconfiguration rejects in-flight candidates and every old scope URL without changing receipts',async({page,request})=>{
const f=page.fixture,window=await historyRead(request,f.detail_id),base=`/prompt_manager/history/runs/${f.runs.shared}`;
await open(page,f.detail_id);await showAllImages(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
await open(page,f.detail_id);await showLinkedImage(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
const before=await(await request.get(`${base}/outputs?epoch=${window.history_epoch}`)).json(),media=before.items[0].current_candidate.media;
await command(request,{action:'arm',boundary:'candidate'});const pending=request.get(`${base}/outputs?epoch=${window.history_epoch}`);
try {
@@ -163,8 +164,8 @@ test('strict reset seeds real A,A,B positions, two contexts and independently se
const fixture=page.fixture;let galleryReads=0;page.on('request',request=>{if(new URL(request.url()).pathname===`/prompt_manager/library/${fixture.positive_id}/images`)galleryReads++;});
await open(page,fixture.positive_id);
const detail=await(await request.get(`/prompt_manager/library/${fixture.positive_id}`)).json();
const selected=detail.variants.findIndex(v=>v.negative_text==='A negative')+1;
await page.getByRole('button',{name:`Select negative variant ${selected}`,exact:true}).click();
const selected=detail.variants.find(v=>v.negative_text==='A negative').title;
await page.getByRole('button',{name:`Select negative ${selected}`,exact:true}).click();
await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
const before=galleryReads,keys=await gallery(page).locator('article[data-image-key]').evaluateAll(nodes=>nodes.map(n=>n.dataset.imageKey));
await expect(recorded(page)).toContainText('3 distinct recorded runs as of this read');
@@ -182,7 +183,7 @@ test('strict reset seeds real A,A,B positions, two contexts and independently se
await expect(marker(page,id)).toBeFocused();
}
await expect(saved(page).getByRole('region',{name:'Negative prompt',exact:true})).toContainText('A negative');
await expect(page.getByRole('button',{name:`Select negative variant ${selected}`,exact:true})).toHaveAttribute('aria-pressed','true');
await expect(page.getByRole('button',{name:`Select negative ${selected}`,exact:true})).toHaveAttribute('aria-pressed','true');
expect(galleryReads).toBe(before);expect(await gallery(page).locator('article[data-image-key]').evaluateAll(nodes=>nodes.map(n=>n.dataset.imageKey))).toEqual(keys);
});
@@ -282,7 +283,7 @@ test('active stale inspection echo reaches history ownership without a second me
});
test('unavailable exact receipt selection and targeted retry observe a newly available file without writes',async({page,request})=>{
const f=page.fixture;await open(page,f.detail_id);await page.getByRole('button',{name:'Select negative variant 1',exact:true}).click();
const f=page.fixture;await open(page,f.detail_id);await page.getByRole('button',{name:'Select negative Saved empty',exact:true}).click();
const keys=await gallery(page).locator('article[data-image-key]').evaluateAll(nodes=>nodes.map(node=>node.dataset.imageKey));
let writes=0,galleryReads=0,outputReads=0;
page.on('request',request=>{const url=new URL(request.url());if(url.pathname.startsWith('/prompt_manager/')&&request.method()!=='GET')writes++;if(url.pathname.endsWith('/images'))galleryReads++;if(url.pathname.endsWith('/outputs'))outputReads++;});
@@ -301,7 +302,7 @@ test('unavailable exact receipt selection and targeted retry observe a newly ava
await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
await missing.getByRole('button',{name:'View current file',exact:true}).click();await expect(page.locator('dialog#viewer-dialog')).toBeVisible();await page.keyboard.press('Escape');
await expect(missing).toHaveAttribute('aria-current','true');await page.keyboard.press('Escape');
await expect(page.getByRole('button',{name:'Select negative variant 1',exact:true})).toHaveAttribute('aria-pressed','true');
await expect(page.getByRole('button',{name:'Select negative Saved empty',exact:true})).toHaveAttribute('aria-pressed','true');
expect(await gallery(page).locator('article[data-image-key]').evaluateAll(nodes=>nodes.map(node=>node.dataset.imageKey))).toEqual(keys);expect(writes).toBe(0);
});
@@ -380,7 +381,7 @@ for(const [width,height] of [[1600,1100],[390,844]])for(const theme of ['dark','
await recorded(page).getByRole('button',{name:'View selected run',exact:true}).click();await expect(dialog(page)).toContainText(oldRun);await expect(dialog(page).getByRole('button',{name:'Effective',exact:true})).toHaveAttribute('aria-pressed','true');
await reviewCapture(page,testInfo,`outside-selected-dialog-${width}-${theme}`,dialog(page));
const hashes={};
for(const path of ['docs/concepts/workspace/index.html','docs/concepts/workspace/app.js','docs/concepts/workspace/style.css','database/history_reads.py','py/api/history.py','web/js/workspace/history-state.mjs','web/js/workspace/history-media.mjs','web/js/workspace/history.mjs','web/js/workspace/recorded-run.mjs','web/js/workspace/library.css'])hashes[path]=createHash('sha256').update(await readFile(new URL(`../../${path}`,import.meta.url))).digest('hex');
for(const path of ['docs/concepts/workspace/index.html','docs/concepts/workspace/app.js','docs/concepts/workspace/style.css','database/history_reads.py','py/api/history.py','web/js/workspace/history-state.mjs','web/js/workspace/history-media.mjs','web/js/workspace/history.mjs','web/js/workspace/recorded-run.mjs','web/js/workspace/workspace.css'])hashes[path]=createHash('sha256').update(await readFile(new URL(`../../${path}`,import.meta.url))).digest('hex');
expect(hashes['docs/concepts/workspace/index.html']).toBe('d84712c7284bb232919a1ed9b154575c8ac574f63b2404a4ac4bc06cbf323c03');expect(hashes['docs/concepts/workspace/app.js']).toBe('e33e7652da12a68cd3fe31e94d77d680aa439b6434ceee94f21cc4ce8883263e');expect(hashes['docs/concepts/workspace/style.css']).toBe('2c479ca1a2ddbbd14a550eae27dd731c326ccdb91fe9ddc87e2b81cd5298fce8');
const manifest=testInfo.outputPath('final-fix-visual-source-and-scroll.json');await writeFile(manifest,JSON.stringify({width,height,theme,oldRun,before,after,hashes},null,2));
await testInfo.attach('final-fix-visual-source-and-scroll.json',{path:manifest,contentType:'application/json'});
@@ -399,7 +400,7 @@ test('history, run and output failures retry their own reads and route exit igno
const f=page.fixture;let windowFailure=true,runFailure=true,outputFailure=true;
await page.route(/\/library\/\d+\/history\?/,async route=>windowFailure?route.fulfill({status:503,json:{success:false}}):route.continue());
await page.goto(`/prompt_manager/workspace#/library/${f.detail_id}`);await expect(recorded(page).getByRole('button',{name:'Retry recorded history',exact:true})).toBeVisible();
await showAllImages(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);windowFailure=false;await recorded(page).getByRole('button',{name:'Retry recorded history',exact:true}).click();
await showLinkedImage(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);windowFailure=false;await recorded(page).getByRole('button',{name:'Retry recorded history',exact:true}).click();
await page.route(/\/history\/runs\/[^/]+\?/,async route=>runFailure?route.fulfill({status:503,json:{success:false}}):route.continue());
await page.route(/\/history\/runs\/[^/]+\/outputs\?/,async route=>outputFailure?route.fulfill({status:503,json:{success:false}}):route.continue());
await marker(page,f.runs.shared).click();await expect(dialog(page).getByRole('button',{name:'Retry run details',exact:true})).toBeVisible();runFailure=false;await dialog(page).getByRole('button',{name:'Retry run details',exact:true}).click();
@@ -474,7 +475,7 @@ test('the inline current file names its prompts apart from the frozen run fields
const current=dialog(page).getByRole('region',{name:'Current reported file',exact:true});await expect(current).toBeVisible();
await expect(current.getByRole('region',{name:'Current file positive prompt',exact:true}).locator('p.prompt-text')).toHaveText('current file positive');
await expect(current.getByRole('region',{name:'Current file negative prompt',exact:true}).locator('p.prompt-text')).toHaveText('current file negative');
await expect(current.getByRole('button',{name:'Copy current file positive prompt',exact:true})).toBeVisible();
await expect(current.getByRole('button',{name:/^Copy current file positive prompt: /})).toBeVisible();
for(const role of ['Positive','Negative'])await expect(dialog(page).getByRole('region',{name:`${role} prompt`,exact:true})).toHaveCount(1);
await expect(current.getByRole('region',{name:/^(Positive|Negative) prompt$/})).toHaveCount(0);
expect(await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'))).toBe(before);
@@ -564,7 +565,7 @@ test('capture recovery reloads only reported outputs while retaining frozen run
});
test('history epoch recovery reports saved-parent read failures and retries without resetting the gallery',async({page})=>{
const f=page.fixture;await open(page,f.detail_id);await showAllImages(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
const f=page.fixture;await open(page,f.detail_id);await showLinkedImage(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
let parentFailure=true,galleryReads=0;page.on('request',request=>{if(new URL(request.url()).pathname===`/prompt_manager/library/${f.detail_id}/images`)galleryReads++;});
await page.route(new RegExp(`/prompt_manager/library/${f.detail_id}(?:\\?|$)`),async route=>parentFailure?route.fulfill({status:503,json:{success:false,error:'Unavailable'}}):route.continue());
await page.route(/\/history\/runs\/[^/]+\/outputs\?/,async route=>route.fulfill({status:409,json:{success:false,history_version:1,code:'history-changed',error:'History changed',recovery:{action:'refresh-history'}}}));
@@ -575,7 +576,7 @@ test('history epoch recovery reports saved-parent read failures and retries with
});
test('changed saved-owner identity is disclosed without silently relabeling or rebinding the existing gallery',async({page,request})=>{
const f=page.fixture;await open(page,f.detail_id);await showAllImages(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
const f=page.fixture;await open(page,f.detail_id);await showLinkedImage(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
const positive=saved(page).getByRole('region',{name:'Positive prompt',exact:true});const original=await exact(positive.locator('p.prompt-text'));
const cards=gallery(page).locator('article[data-image-key]');await cards.first().evaluate(node=>{window.savedGalleryCard=node;});let galleryReads=0;
page.on('request',request=>{if(new URL(request.url()).pathname===`/prompt_manager/library/${f.detail_id}/images`)galleryReads++;});
@@ -595,7 +596,7 @@ test('a bounded saved-parent prefix leaves previously loaded variants unverified
const f=page.fixture;const detail=await(await request.get(`/prompt_manager/library/${f.detail_id}`)).json();
for(let index=0;index<41;index++)await expect(await request.post('/prompt_manager/pairs',{data:{positive_text:detail.positive.text,negative_text:`Bounded negative ${index}`}})).toBeOK();
await open(page,f.detail_id);await expect(page.getByRole('button',{name:'Load more negative variants',exact:true})).toBeVisible();await page.getByRole('button',{name:'Load more negative variants',exact:true}).click();
const choices=page.getByRole('button',{name:/^Select negative variant /});await expect(choices).toHaveCount(43);await choices.last().click();await expect(choices.last()).toHaveAttribute('aria-pressed','true');
const choices=page.getByRole('button',{name:/^Select negative /});await expect(choices).toHaveCount(43);await choices.last().click();await expect(choices.last()).toHaveAttribute('aria-pressed','true');
const negative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).textContent();
await page.route(/\/history\/runs\/[^/]+\/outputs\?/,async route=>route.fulfill({status:409,json:{success:false,history_version:1,code:'history-changed',error:'History changed',recovery:{action:'refresh-history'}}}));
await marker(page,f.runs.shared).click();await expect(dialog(page)).toContainText('Recorded evidence changed');await page.keyboard.press('Escape');
@@ -608,7 +609,7 @@ test('an authoritative save after a stale revalidation makes the saved view curr
const f=page.fixture;const detail=await(await request.get(`/prompt_manager/library/${f.detail_id}`)).json();
for(let index=0;index<41;index++)await expect(await request.post('/prompt_manager/pairs',{data:{positive_text:detail.positive.text,negative_text:`Bounded negative ${index}`}})).toBeOK();
await open(page,f.detail_id);await page.getByRole('button',{name:'Load more negative variants',exact:true}).click();
const choices=page.getByRole('button',{name:/^Select negative variant /});await expect(choices).toHaveCount(43);await choices.last().click();
const choices=page.getByRole('button',{name:/^Select negative /});await expect(choices).toHaveCount(43);await choices.last().click();
const negative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).textContent();
await page.route(/\/history\/runs\/[^/]+\/outputs\?/,async route=>route.fulfill({status:409,json:{success:false,history_version:1,code:'history-changed',error:'History changed',recovery:{action:'refresh-history'}}}));
await marker(page,f.runs.shared).click();await expect(dialog(page)).toContainText('Recorded evidence changed');await page.keyboard.press('Escape');
@@ -617,7 +618,7 @@ test('an authoritative save after a stale revalidation makes the saved view curr
await expect(editor.getByRole('button',{name:'Save',exact:true})).toBeEnabled();await editor.getByLabel('Notes',{exact:true}).fill('Saved after stale revalidation');
await editor.getByRole('button',{name:'Save',exact:true}).click();await expect(editor).toHaveCount(0);
await expect(page.locator('.library-parent-status')).toBeHidden();await expect(page.getByRole('button',{name:'Reload saved Library view',exact:true})).toBeHidden();
await expect(choices.first()).toBeEnabled();await expect(page.locator('.library-variant-dot[aria-pressed="true"]')).toHaveCount(1);
await expect(choices.first()).toBeEnabled();await expect(page.locator('.variant-dot[aria-pressed="true"]')).toHaveCount(1);
expect(await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).textContent()).toBe(negative);
const names=await choices.evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')));expect(new Set(names).size).toBe(names.length);
});
@@ -625,14 +626,14 @@ test('an authoritative save after a stale revalidation makes the saved view curr
async function retainSavedSelection(page,request,fixture) {
await open(page,fixture.detail_id);
const details=await(await request.get(`/prompt_manager/library/${fixture.detail_id}`)).json();
const index=details.variants.findIndex(variant=>variant.id===fixture.saved_pair);
await page.getByRole('button',{name:`Select negative variant ${index+1}`,exact:true}).click();
const name=details.variants.find(variant=>variant.id===fixture.saved_pair).title;
await page.getByRole('button',{name:`Select negative ${name}`,exact:true}).click();
await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
const negative=await exact(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text'));
await gallery(page).locator('article[data-image-key]').first().evaluate(node=>{window.fix1SavedGalleryCard=node;});
let reads=0;page.on('request',request=>{if(new URL(request.url()).pathname===`/prompt_manager/library/${fixture.detail_id}/images`)reads++;});
return async()=>{
await expect(page.getByRole('button',{name:`Select negative variant ${index+1}`,exact:true})).toHaveAttribute('aria-pressed','true');
await expect(page.getByRole('button',{name:`Select negative ${name}`,exact:true})).toHaveAttribute('aria-pressed','true');
expect(await exact(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text'))).toBe(negative);
expect(await gallery(page).locator('article[data-image-key]').first().evaluate(node=>node===window.fix1SavedGalleryCard)).toBe(true);expect(reads).toBe(0);
};
@@ -721,3 +722,310 @@ test('completed page one stays bounded across mode changes until explicit output
await page.keyboard.press('Escape');await retained();
} finally {releaseNext();}
});
// #249 PR F1: the concept heading (app.js a591–597) and summary surface (a598) with real counts (D24).
const historyHeading=page=>page.locator('#workspace-main > .heading');
const summary=page=>page.locator('#workspace-main .library-history article.surface');
test('history heading actions stay on one row at 1600 and are enabled after load',async({page,request})=>{
await page.setViewportSize({width:1600,height:1100});const f=page.fixture;
let releaseDetail;const held=new Promise(resolve=>{releaseDetail=resolve;});
await page.route(new RegExp(`/prompt_manager/library/${f.positive_id}\\?`),async route=>{await held;await route.continue().catch(()=>{});});
try {
await page.goto(`/prompt_manager/workspace#/library/${f.positive_id}`);
const heading=historyHeading(page),send=heading.locator('.heading-actions > button.send');
await expect(heading.locator('p.muted')).toHaveText('Prompt history · one positive, one shared gallery');
// D24: disabled only while the selected pair's roles are loading.
await expect(send).toBeDisabled();releaseDetail();
await expect(recorded(page)).toBeVisible();await expect(send).toBeEnabled();
} finally {releaseDetail();}
const heading=historyHeading(page),title=await heading.locator('h1').textContent();
const actions=heading.locator('.heading-actions > button');
await expect(actions).toHaveCount(3);
expect(await actions.evaluateAll(nodes=>nodes.map(node=>node.textContent.trim()))).toEqual(['← Prompt library','Edit metadata','Send to ComfyUI']);
await expect(actions.nth(2)).toHaveClass('send');
await expect(actions.nth(2)).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${title}`);
for(const action of [actions.nth(0),actions.nth(1)]) await expect(action).toBeEnabled();
const boxes=await actions.evaluateAll(nodes=>nodes.map(node=>{const box=node.getBoundingClientRect();return {y:box.y,height:box.height};}));
expect(new Set(boxes.map(box=>box.y)).size).toBe(1);
const row=await heading.locator('.heading-actions').evaluate(node=>node.getBoundingClientRect().height);
expect(row).toBe(boxes[0].height);
});
test('summary meta uses recorded runs',async({page,request})=>{
const f=page.fixture;await open(page,f.positive_id);
const detail=await(await request.get(`/prompt_manager/library/${f.positive_id}`)).json(),runs=(await historyRead(request,f.positive_id)).recorded_run_count;
const surface=summary(page),title=await historyHeading(page).locator('h1').textContent();
await expect(surface).toHaveCount(1);
await expect(surface.locator(':scope > span.chip')).toHaveText(detail.positive.category || 'Uncategorized');
const plural=(count,word)=>`${count} ${count===1?word:`${word}s`}`;
await expect(surface.locator(':scope > .card-meta > span')).toHaveText([plural(runs,'tracked run'),plural(detail.positive.image_count,'image'),plural(detail.positive.variant_count,'negative variant')]);
// The concept order: category chip, positive block, selected negative block, then the meta line.
expect(await surface.evaluate(node=>[...node.querySelectorAll('.chip, .prompt-section, .card-meta')].filter(item=>item.checkVisibility()).map(item=>item.className))).toEqual(['chip','prompt-section positive','prompt-section negative','card-meta']);
for(const name of ['Copy positive prompt','Send positive prompt to ComfyUI','Copy negative prompt','Send negative prompt to ComfyUI'])
await expect(saved(page).getByRole('button',{name:`${name}: ${title}`,exact:true})).toBeVisible();
await expect(surface.locator('.prompt-section').first()).toHaveCSS('border-top-width','1px');
});
// #249 F2: the concept's negative variants panel (app.js a598, style.css 1059-1154) and its two dialogs (D26).
const variantsPanel=page=>page.getByRole('region',{name:'Saved negative variants',exact:true});
const modalDialog=(page,name)=>page.getByRole('dialog',{name,exact:true});
test('variants panel matches the concept',async({page})=>{
const f=page.fixture;await open(page,f.canonical_id);const panel=variantsPanel(page);
await expect(panel).toHaveClass('surface timeline-panel');
const heading=panel.locator(':scope > .panel-heading');
await expect(heading.locator(':scope > div > h2')).toHaveText('Negative variants');
await expect(heading.locator(':scope > div > p.small')).toHaveText('Choose a red dot to change the negative and filter this gallery.');
const actions=heading.locator(':scope > .row-actions > button');
expect(await actions.evaluateAll(nodes=>nodes.map(node=>node.textContent))).toEqual(['Compare negatives','+ Add negative','Show archived (0)']);
const compare=actions.nth(0);await expect(compare).toHaveAttribute('aria-pressed','false');
await compare.click();await expect(compare).toHaveAttribute('aria-pressed','true');await compare.click();await expect(compare).toHaveAttribute('aria-pressed','false');
const timeline=panel.locator(':scope > .timeline');
await expect(timeline.locator(':scope > .positive-lane > span')).toHaveText('Positive prompt · unchanged');
const lanes=timeline.locator(':scope > .variant-lane');await expect(lanes).toHaveCount(3);
// Newest saved pair first, as the bounded variant page reads; every lane is offset 38 + 47i.
const names=['No negative','Clean geometry','Quality baseline'],runs=['0 runs','2 runs','2 runs'];
for(const [index,name] of names.entries()) {
const lane=lanes.nth(index);
await expect(lane).toHaveAttribute('style',`--lane-offset: ${38+index*47}px;`);
const dot=lane.locator(':scope > button.variant-dot');
await expect(dot).toHaveText('−');await expect(dot).toHaveAccessibleName(`Select negative ${name}`);
await expect(dot).toHaveAttribute('aria-pressed',String(index===0));
await expect(lane.locator(':scope > span')).toHaveText(`${name} · ${runs[index]}`);
expect(await lane.evaluate(node=>[...node.children].filter(child=>!child.hidden).map(child=>child.tagName.toLowerCase()+'.'+child.className))).toEqual(index===0?['button.variant-dot','span.']:['button.variant-dot','span.','button.run-dot']);
}
await lanes.nth(2).locator('.variant-dot').click();await expect(lanes.nth(2).locator('.variant-dot')).toHaveAttribute('aria-pressed','true');
await expect(lanes.nth(0).locator('.variant-dot')).toHaveAttribute('aria-pressed','false');
await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(2);
const lower=panel.locator(':scope > .timeline-lower');
await expect(lower.locator(':scope > span.small')).toHaveText('Green + positive · red − negative · small dot repeat run');
await expect(lower.locator(':scope > button')).toHaveText('All images for this positive');
await expect(lower.locator(':scope > button')).not.toHaveAttribute('aria-pressed',/.*/);
await expect(panel).not.toContainText('Saved combinations, not an execution timeline.');
// The concept tokens reach the lanes (style.css 1096-1137).
await expect(lanes.first()).toHaveCSS('height','57px');await expect(lanes.nth(1)).toHaveCSS('margin-left','85px');
await expect(lanes.first().locator('.variant-dot')).toHaveCSS('border-radius','50%');
await expect(lanes.nth(1).locator('.run-dot')).toHaveCSS('left','190px');
});
test('run dot appears only for pairs with more than one run and opens its run',async({page})=>{
const f=page.fixture;await open(page,f.canonical_id);const panel=variantsPanel(page);
await expect(panel.getByRole('button',{name:/^Show repeat run for /})).toHaveCount(2);
await expect(panel.getByRole('button',{name:'Show repeat run for No negative',exact:true})).toHaveCount(0);
// D45: the lane text wraps before the run dot, and a coarse pointer reaches the 12px dot through a 44px target.
for(const lane of await panel.locator('.variant-lane:has(> .run-dot:not([hidden]))').all()) {
const [text,dot]=await Promise.all([lane.locator(':scope > span').boundingBox(),lane.locator(':scope > .run-dot').boundingBox()]);
expect(text.width).toBeLessThanOrEqual(139.5);expect(text.x+text.width).toBeLessThan(dot.x);
}
if(await page.evaluate(()=>matchMedia('(pointer: coarse)').matches)) {
const dot=panel.getByRole('button',{name:'Show repeat run for Quality baseline',exact:true});await dot.scrollIntoViewIfNeeded();
const reach=await dot.evaluate(node=>{
const box=node.getBoundingClientRect(),x=box.x+box.width/2,y=box.y+box.height/2;
return [[x-20,y],[x+20,y],[x,y-20],[x,y+20]].map(([px,py])=>{const hit=document.elementFromPoint(px,py);return hit?.closest('button')===node?'dot':`${hit?.tagName}.${hit?.className}`;});
});
expect(reach).toEqual(['dot','dot','dot','dot']);
}
// The baseline has a recorded execution: the dot opens Recorded run for it.
const baseline=panel.getByRole('button',{name:'Show repeat run for Quality baseline',exact:true});await baseline.click();
await expect(dialog(page)).toBeVisible();await expect(dialog(page)).toContainText(f.canonical_run);
await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();await expect(dialog(page)).toHaveCount(0);
// Clean geometry was counted but never recorded: Repeat run shows its newest linked image only.
const clean=panel.getByRole('button',{name:'Show repeat run for Clean geometry',exact:true});
const reads=[];page.on('request',request=>{const url=new URL(request.url());if(url.pathname===`/prompt_manager/library/${f.canonical_id}/images`)reads.push(url.searchParams);});
await clean.click();const repeat=modalDialog(page,'Repeat run');await expect(repeat).toBeVisible();
await expect(repeat.locator('#modal-body > p.small')).toHaveText('Same positive and negative, another execution.');
await expect(repeat.locator('#modal-body > .spaced-15 article[data-image-key]')).toHaveCount(1);
// D47: as in the concept (a1291-1304), the dialog title names the image; no collection heading or count repeats it.
await expect(repeat.locator('.section-caption')).toHaveCount(0);
await expect(repeat.getByRole('heading',{name:'Linked images',exact:true})).toHaveCount(0);
await expect(repeat.getByText(/ loaded · /)).toHaveCount(0);
expect(await repeat.evaluate(node=>[...node.querySelectorAll('h1, h2')].filter(item=>!item.closest('aside')).map(item=>item.textContent))).toEqual(['Repeat run']);
expect(reads.at(-1).get('pair_id')).toBe(String(f.clean_pair));expect(reads.at(-1).get('limit')).toBe('1');
await page.keyboard.press('Escape');await expect(repeat).toHaveCount(0);await expect(clean).toBeFocused();
});
test('all images dialog lists every image for the positive',async({page,request})=>{
const f=page.fixture;await open(page,f.detail_id);
const detail=await(await request.get(`/prompt_manager/library/${f.detail_id}`)).json();
const all=variantsPanel(page).getByRole('button',{name:'All images for this positive',exact:true});
// Opening on the first lane, the shared gallery shows that lane only.
await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(0);
await all.click();const listing=modalDialog(page,'All images for this positive');await expect(listing).toBeVisible();
await expect(listing.locator('article[data-image-key]')).toHaveCount(detail.positive.image_count);
await page.keyboard.press('Escape');await expect(listing).toHaveCount(0);await expect(all).toBeFocused();
await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(0);
// Leaving the route closes an open dialog and its gallery.
await all.click();await expect(listing.locator('article[data-image-key]')).toHaveCount(detail.positive.image_count);
await page.evaluate(()=>{location.hash='#/overview';});await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
await expect(page.locator('dialog[open]')).toHaveCount(0);await expect(page.locator('#modal')).toHaveCount(0);
});
test('compare toggles, follows the selected dot and stacks at 390',async({page})=>{
const f=page.fixture;await open(page,f.canonical_id);const panel=variantsPanel(page);
const compare=panel.getByRole('button',{name:'Compare negatives',exact:true});
const section=page.locator('.library-history .timeline-panel + section.surface');
await expect(section).toHaveCount(0);
await compare.click();await expect(compare).toHaveAttribute('aria-pressed','true');
// The concept section (app.js a599-616) follows the timeline panel; the baseline is the oldest active lane (D25).
await expect(section).toHaveCount(1);
expect(await section.evaluate(node=>[...node.children].map(child=>child.tagName.toLowerCase()+(child.className?'.'+child.className:'')))).toEqual(['h2','p','div.compare-grid','p.small','p.small']);
await expect(section.locator(':scope > h2')).toHaveText('Compare negative variants');
await expect(section.locator(':scope > p').first()).toHaveText('Baseline compared with No negative. Choose another red dot to change the comparison.');
const items=section.locator(':scope > .compare-grid > .compare-item');await expect(items).toHaveCount(2);
await expect(items.nth(0).locator('.prompt-section.negative .prompt-text')).toHaveText('blurred edges, watermark');
await expect(items.nth(1).locator('.prompt-section.negative .prompt-text')).toHaveText('No negative prompt (explicitly empty)');
await expect(items.nth(0).locator('.prompt-section.positive')).toHaveCount(0);
// D46: the two blocks are named apart, so no region or action name repeats on the page.
await expect(section.getByRole('region',{name:'Baseline negative prompt',exact:true})).toBeVisible();
await expect(section.getByRole('region',{name:'Selected negative prompt',exact:true})).toBeVisible();
await expect(section.locator(':scope > p.small').first()).toHaveText('Added terms');
await expect(section.locator(':scope > p.small').nth(1)).toHaveText('No added terms.');
await expect(section.locator('.diff-added')).toHaveCount(0);
// Another dot updates the same section in place.
const node=await section.elementHandle();
await panel.getByRole('button',{name:'Select negative Clean geometry',exact:true}).click();
await expect(section.locator(':scope > p').first()).toHaveText('Baseline compared with Clean geometry. Choose another red dot to change the comparison.');
await expect(items.nth(1).locator('.prompt-text')).toHaveText('flat light');
await expect(section.locator(':scope > span.diff-added')).toHaveText(['+ flat light']);
await expect(section.locator(':scope > p.small')).toHaveCount(1);
expect(await node.evaluate(element=>element.isConnected)).toBe(true);
await expect(section.locator('.diff-added')).toHaveCSS('display','inline-block');
await expect(section.locator('.compare-item').first()).toHaveCSS('padding','15px');
await page.setViewportSize({width:1600,height:1100});
let boxes=await items.evaluateAll(nodes=>nodes.map(node=>node.getBoundingClientRect().toJSON()));
expect(boxes[1].x).toBeGreaterThan(boxes[0].x+boxes[0].width-1);expect(Math.abs(boxes[1].y-boxes[0].y)).toBeLessThan(1);
await page.setViewportSize({width:390,height:844});
boxes=await items.evaluateAll(nodes=>nodes.map(node=>node.getBoundingClientRect().toJSON()));
expect(Math.abs(boxes[1].x-boxes[0].x)).toBeLessThan(1);expect(boxes[1].y).toBeGreaterThanOrEqual(boxes[0].y+boxes[0].height);
expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
await panel.getByRole('button',{name:'Select negative Quality baseline',exact:true}).click();
await expect(section.locator(':scope > p').first()).toHaveText('Baseline compared with Quality baseline. Choose another red dot to change the comparison.');
await expect(section.locator(':scope > p.small').nth(1)).toHaveText('No added terms.');
await compare.click();await expect(compare).toHaveAttribute('aria-pressed','false');await expect(section).toHaveCount(0);
});
test('compare waits for the page holding the oldest active negative',async({page})=>{
const f=page.fixture;
// A bounded first page that does not reach the oldest saved pair: the baseline is not guessed from the newest lanes (D46).
await page.route(new RegExp(`/prompt_manager/library/${f.canonical_id}\\?`),async route=>{
const url=new URL(route.request().url());if(url.searchParams.has('variant_cursor') || url.searchParams.get('archive')) return route.continue();
const response=await route.fetch(),body=await response.json();
await route.fulfill({response,json:{...body,variants:body.variants.slice(0,2),variants_has_more:true,variants_next_cursor:'held'}});
});
await open(page,f.canonical_id);const panel=variantsPanel(page);
await panel.getByRole('button',{name:'Compare negatives',exact:true}).click();
const section=page.locator('.library-history .timeline-panel + section.surface');
await expect(section.locator(':scope > h2')).toHaveText('Compare negative variants');
await expect(section.locator(':scope > p.small')).toHaveText('Load more negative variants to reach the baseline.');
await expect(section.locator('.compare-grid')).toHaveCount(0);
await page.unroute(new RegExp(`/prompt_manager/library/${f.canonical_id}\\?`));
});
test('run dot falls back to Repeat run when the lookup fails or Recorded run cannot open',async({page})=>{
const f=page.fixture;
await page.route(/\/history\/pairs\/\d+\/newest/,route=>route.fulfill({status:503,json:{success:false}}));
await open(page,f.canonical_id);const panel=variantsPanel(page);
const baseline=panel.getByRole('button',{name:'Show repeat run for Quality baseline',exact:true});
await baseline.click();const repeat=modalDialog(page,'Repeat run');await expect(repeat).toBeVisible();
await expect(page.locator('#workspace-toast')).toHaveText('Recorded runs could not be read. Showing the newest linked image.');
await expect(dialog(page)).toHaveCount(0);
await page.keyboard.press('Escape');await expect(repeat).toHaveCount(0);await expect(baseline).toBeFocused();
await page.unroute(/\/history\/pairs\/\d+\/newest/);
// The lookup names a run, but the recorded history never loaded, so Recorded run cannot open: Repeat run, no toast.
await page.route(/\/library\/\d+\/history\?/,route=>route.fulfill({status:503,json:{success:false}}));
await page.reload();await expect(recorded(page).getByRole('button',{name:'Retry recorded history',exact:true})).toBeVisible();
await page.locator('#workspace-toast').evaluate(node=>{node.textContent='';});
const newest=page.waitForResponse(response=>/\/history\/pairs\/\d+\/newest/.test(response.url()));
await variantsPanel(page).getByRole('button',{name:'Show repeat run for Quality baseline',exact:true}).click();
expect((await(await newest).json()).run.run_id).toBe(f.canonical_run);
await expect(modalDialog(page,'Repeat run')).toBeVisible();await expect(dialog(page)).toHaveCount(0);
await expect(page.locator('#workspace-toast')).toHaveText('');
});
test('an archived pair\'s Repeat run reads the archived gallery',async({page,request})=>{
const f=page.fixture;
await expect(await request.patch(`/prompt_manager/pairs/${f.clean_pair}`,{data:{archived:true}})).toBeOK();
await open(page,f.canonical_id);const panel=variantsPanel(page);
await panel.getByRole('button',{name:'Show archived (1)',exact:true}).click();
const reads=[];page.on('request',request=>{const url=new URL(request.url());if(url.pathname===`/prompt_manager/library/${f.canonical_id}/images`)reads.push(url.searchParams);});
await panel.getByRole('button',{name:'Show repeat run for Clean geometry',exact:true}).click();
const repeat=modalDialog(page,'Repeat run');await expect(repeat.locator('.spaced-15 article[data-image-key]')).toHaveCount(1);
expect(reads.at(-1).get('pair_id')).toBe(String(f.clean_pair));expect(reads.at(-1).get('archive')).toBe('archived');
// Each open replaces the previous dialog and its gallery.
await page.keyboard.press('Escape');await panel.getByRole('button',{name:'All images for this positive',exact:true}).click();
await expect(modalDialog(page,'All images for this positive')).toBeVisible();await expect(page.locator('dialog[open]')).toHaveCount(1);
});
test('Compare negatives is disabled without an active variant',async({page,request})=>{
const f=page.fixture,detail=await(await request.get(`/prompt_manager/library/${f.canonical_id}`)).json();
for(const variant of detail.variants) await expect(await request.patch(`/prompt_manager/pairs/${variant.id}`,{data:{archived:true}})).toBeOK();
await open(page,f.canonical_id);const compare=variantsPanel(page).getByRole('button',{name:'Compare negatives',exact:true});
await expect(compare).toBeDisabled();await expect(compare).toHaveAttribute('aria-pressed','false');
await expect(page.locator('.library-history .timeline-panel + section.surface')).toHaveCount(0);
});
test('dot names never repeat for trimmed titles or titles that read like a fallback',async({page,request})=>{
const f=page.fixture,detail=await(await request.get(`/prompt_manager/library/${f.canonical_id}`)).json();
const byTitle=Object.fromEntries(detail.variants.map(variant=>[variant.title,variant.id]));
await expect(await request.patch(`/prompt_manager/pairs/${byTitle['No negative']}`,{data:{title:'variant 2'}})).toBeOK();
await expect(await request.patch(`/prompt_manager/pairs/${byTitle['Quality baseline']}`,{data:{title:' Clean geometry '}})).toBeOK();
await open(page,f.canonical_id);
const names=await variantsPanel(page).locator('.variant-dot').evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')));
expect(new Set(names).size).toBe(names.length);
expect(names).toEqual(['Select negative variant 1','Select negative variant 2','Select negative variant 3']);
});
// #249 F4: the concept's shared gallery (app.js a616-621, style.css 1546-1548) of PR C media cards (C3 activation).
test('shared gallery uses concept cards, image opens the viewer, empty copy',async({page,request})=>{
const f=page.fixture;await open(page,f.canonical_id);
const section=gallery(page),heading=section.locator(':scope > .panel-heading');
expect(await section.evaluate(node=>`${node.tagName.toLowerCase()}.${node.className}`)).toBe('section.history-gallery');
await expect(heading.locator(':scope > h2')).toHaveText('Shared gallery');
// The page opens on the newest lane, a saved variant with no runs: the concept's empty copy.
await expect(heading.locator(':scope > span.small')).toHaveText('No negative · same positive prompt');
await expect(section.locator('.empty')).toHaveText('Saved variant · no runs yet.');await expect(section.locator('.empty')).toBeVisible();
await expect(section.locator('article[data-image-key]')).toHaveCount(0);
// D47 (maintainer ruling 2026-10-21): the concept's "Shared gallery" heading is the gallery's only heading, so the
// collection's "Linked images" heading and its "N loaded · N linked images" count are gone; the layout row and the
// selected-image side panel stay.
await expect(section.locator('.section-caption')).toHaveCount(0);
await expect(section.getByRole('heading',{name:'Linked images',exact:true})).toHaveCount(0);
await expect(section.getByText(/ loaded · /)).toHaveCount(0);
// Outside the side panel (whose own heading names the selected image), the gallery has one section heading.
expect(await section.evaluate(node=>[...node.querySelectorAll('h1, h2')].filter(item=>!item.closest('aside')).map(item=>item.textContent))).toEqual(['Shared gallery']);
await expect(section.getByRole('group',{name:'Image layout',exact:true})).toBeVisible();
await expect(section.getByRole('combobox',{name:'Thumbnail size',exact:true})).toBeVisible();
await expect(section.getByRole('complementary',{name:'Selected image details',exact:true})).toBeVisible();
// As in the concept, the gallery follows the variants panel; recorded executions (#209) stay below it.
const order=await page.locator('.library-history').evaluate(node=>[...node.querySelectorAll('section[aria-label]')].map(item=>item.getAttribute('aria-label')).filter(name=>['Saved negative variants','Shared gallery','Recorded executions'].includes(name)));
expect(order).toEqual(['Saved negative variants','Shared gallery','Recorded executions']);
const dot=variantsPanel(page).getByRole('button',{name:'Select negative Quality baseline',exact:true});await dot.click();await expect(dot).toHaveAttribute('aria-pressed','true');
await expect(heading.locator(':scope > span.small')).toHaveText('Quality baseline · same positive prompt');
const cards=section.locator('article.media-card[data-image-key]');await expect(cards).toHaveCount(2);
await expect(section.locator('.empty')).toBeHidden();
const details=await(await request.get(`/prompt_manager/library/${f.canonical_id}`)).json();
const pair=details.variants.find(variant=>variant.title==='Quality baseline').id;
const listing=await(await request.get(`/prompt_manager/library/${f.canonical_id}/images?pair_id=${pair}`)).json();
const first=cards.first(),filename=(await first.locator('.image-open').getAttribute('aria-label')).replace(/^Open /,'');
const image=listing.images.find(item=>item.filename===filename);expect(typeof image.favorite).toBe('boolean');
// The concept card: art (open button + overlays), then the info row; the star where the read returns favorite and
// no checkbox (the selection bar stays Images-only, #259).
expect(await first.evaluate(node=>[...node.children].map(child=>child.className))).toEqual(['media-art','media-info']);
expect(await first.locator(':scope > .media-art').evaluate(node=>[...node.children].map(child=>`${child.tagName.toLowerCase()}.${child.className}`))).toEqual(['button.image-open','div.image-overlays']);
await expect(first.locator('.image-overlays input[type="checkbox"]')).toHaveCount(0);
const star=first.locator('.image-overlays > button.star-button');
await expect(star).toHaveAccessibleName(`Favorite ${filename}`);await expect(star).toHaveAttribute('aria-pressed',String(image.favorite));
await expect(star).toHaveText(image.favorite?'★':'☆');
await expect(first.locator('.media-info h3')).toHaveCount(1);
await expect(first.locator('.image-card-actions > button.icon-btn')).toHaveAccessibleName(`Inspect ${filename}`);
// C3 activation: the image opens the viewer and Escape returns focus to it.
const openButton=first.locator('.image-open');await openButton.click();
const viewer=page.locator('dialog#viewer-dialog');await expect(viewer).toBeVisible();
await page.keyboard.press('Escape');await expect(viewer).toBeHidden();await expect(openButton).toBeFocused();
await expect(section.locator(':scope > .panel-heading')).toHaveCSS('justify-content','space-between');
await expect(section).toHaveCSS('margin-top','24px');
// D47 (maintainer ruling 2026-10-21) covers the history page's dialogs too: the All images dialog is named by its
// own title, with no "Linked images" heading or "N loaded · N linked images" count, as the concept's (a1282-1290).
await variantsPanel(page).getByRole('button',{name:'All images for this positive',exact:true}).click();
const all=page.getByRole('dialog',{name:'All images for this positive',exact:true});
await expect(all.locator('article[data-image-key]').first()).toBeVisible();
await expect(all.locator('.section-caption')).toHaveCount(0);
await expect(all.getByRole('heading',{name:'Linked images',exact:true})).toHaveCount(0);
await expect(all.getByText(/ loaded · /)).toHaveCount(0);
expect(await all.evaluate(node=>[...node.querySelectorAll('h1, h2')].filter(item=>!item.closest('aside')).map(item=>item.textContent))).toEqual(['All images for this positive']);
await page.keyboard.press('Escape');await expect(all).toHaveCount(0);
// At 390px the heading stays inside the gallery and the page never scrolls sideways.
await page.setViewportSize({width:390,height:844});
await expect.poll(()=>page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
const [box,text]=await Promise.all([section.boundingBox(),heading.locator(':scope > span.small').boundingBox()]);
expect(text.x+text.width).toBeLessThanOrEqual(box.x+box.width+0.5);
});
+136 -4
View File
@@ -1,5 +1,5 @@
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import { test, expect } from './fixtures.mjs';
import { seedConcept, openConcept, openProduction } from './concept-inventory.mjs';
const workspace = '/prompt_manager/workspace#/images';
@@ -174,7 +174,9 @@ for (const embedded of [false, true]) {
if (close !== 'parent abort') {
await expect(preview).toBeFocused();
await expect.poll(() => page.evaluate(() => window.continuationFixture.reads)).toEqual([null, 'next-0', 'next-1']);
await expect(caption(page)).toContainText(embedded ? '80 loaded' : '80 of 80');
// An embedded collection has no caption (D47); its loaded cards show the continuation instead.
if (embedded) { await expect(cards(page)).toHaveCount(80); await expect(caption(page)).toHaveCount(0); }
else await expect(caption(page)).toContainText('80 of 80');
}
await page.evaluate(() => window.continuationFixture.scope.dispose());
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
@@ -806,7 +808,9 @@ test('images toolbar and filter row match the concept controls', async ({ page,
await expect(page.getByRole('button', { name: 'Refresh', exact: true })).toHaveCount(0);
await expect(page.getByRole('link', { name: 'Current gallery' })).toHaveCount(0);
await expect(main.locator('h2', { hasText: /^All images$/ })).toHaveCount(0);
await expect(main.locator('search.toolbar > *')).toHaveCount(4);
// app.js toolbar() a347–360: search, category, sort, Save view, Filters.
await expect(main.locator('search.toolbar > *')).toHaveCount(5);
await expect(main.locator('search.toolbar > button')).toHaveText(['Save view', 'Filters']);
await expect(sortBy(page).locator('option')).toHaveText(['Recently added', 'Name A–Z', 'Highest rated', 'Oldest first', 'Name Z–A']);
const row = main.locator('.filter-row');
await expect(row.locator(':scope > *')).toHaveCount(5);
@@ -1483,6 +1487,30 @@ test('a star press leaves the continuation status, count and caption date untouc
expect(await page.evaluate(() => window.__statusMutations)).toEqual([]);
});
// Opened before the source and folder discovery answered (a slow server), Refine lists them once it does.
test('Refine opened before discovery settles lists the discovered sources and folders', async ({ page }) => {
let release, started;
const held = new Promise(resolve => { release = resolve; }), hit = new Promise(resolve => { started = resolve; });
await page.route('**/prompt_manager/gallery/subfolders?*', async route => { started(); await held; await route.continue(); }, { times: 1 });
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
await hit;
await filtersButton(page).click();
const source = refineDialog(page).getByLabel('Source', { exact: true });
const folder = refineDialog(page).getByLabel('Folder', { exact: true });
const values = select => select.locator('option').evaluateAll(options => options.map(option => option.value));
expect(await values(source)).toEqual(['all']);
expect(await values(folder)).toEqual(['']);
release();
await expect.poll(() => values(source)).toEqual(['all', '0', '1']);
expect(await values(folder)).toEqual(['', 'shared']);
await expect(source).toHaveValue('all');
await source.selectOption('1');
await refineDialog(page).getByRole('button', { name: 'Apply filters', exact: true }).click();
await expect(cards(page)).toHaveCount(1);
await expect(cards(page)).toHaveAttribute('data-image-key', '1:shared/same.png');
});
// #249 final review m1: as in the old field row, a Source offers only its own folders.
test('Refine offers only the chosen source folders', async ({ page, request }) => {
await expect(await request.post('/__test__/add-nested-image')).toBeOK();
@@ -1569,6 +1597,63 @@ test('unreachable sources and categories announce one error', async ({ page }) =
await expect(page.getByRole('alert')).toHaveCount(0);
});
// PR D-b Task 3 (D42): the shared toast keeps the concept's fixed bottom placement, so a discovery alert that a rescan
// leaves visible must not stay under it. The window ends just below the alert, inside the toast's band, as on a short
// window or a phone with its browser bars shown.
const boxOf = locator => locator.evaluate(node => {
const { top, right, bottom, left } = node.getBoundingClientRect();
return { top, right, bottom, left };
});
const overlaps = (a, b) => a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom;
for (const width of [1600, 390]) {
test(`Rescan toast does not cover the discovery alert it leaves at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 1100 });
await page.route('**/prompt_manager/library/options*', route => route.abort('failed'));
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
const alert = page.getByRole('alert');
const failed = 'Categories could not be loaded. Rescan from Import to retry.';
await expect(alert).toHaveText(failed);
const toastInset = width > 700 ? 22 : 12;
await page.setViewportSize({ width, height: Math.ceil((await boxOf(alert)).bottom) + toastInset + 16 });
await rescan(page);
await expect(toast(page)).toHaveText(/^Rescan finished\. /);
await expect(alert).toHaveText(failed);
await expect.poll(async () => overlaps(await boxOf(alert), await boxOf(toast(page)))).toBe(false);
const [shown, placed] = [await boxOf(alert), await boxOf(toast(page))];
expect(shown.top).toBeGreaterThanOrEqual(0);
expect(shown.bottom).toBeLessThanOrEqual(placed.top);
// The concept's toast stays where the concept puts it: centred, its inset from the bottom.
const viewport = page.viewportSize();
expect(Math.round(viewport.height - placed.bottom)).toBe(toastInset);
expect(Math.abs((placed.left + placed.right) / 2 - viewport.width / 2)).toBeLessThan(1);
});
}
// Final review m3: D42 scrolls only when the alert meets the toast's band, never back up to one above the window.
test('Rescan does not scroll back to a discovery alert above the window', async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
await page.route('**/prompt_manager/library/options*', route => route.abort('failed'));
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
const alert = page.getByRole('alert');
await expect(alert).toHaveText('Categories could not be loaded. Rescan from Import to retry.');
await page.setViewportSize({ width: 1600, height: 260 });
// Import is at the top; the page is scrolled down to the last card while its dialog is open.
await page.getByRole('button', { name: 'Import', exact: true }).click();
await cards(page).last().evaluate(node => node.scrollIntoView({ block: 'end' }));
expect((await boxOf(alert)).bottom).toBeLessThan(0);
// The reload itself may move the page; what D42 must not do is scroll the alert back into view.
await alert.evaluate(node => {
window.alertScrolls = 0;
node.scrollIntoView = (...args) => { window.alertScrolls += 1; Element.prototype.scrollIntoView.apply(node, args); };
});
await importDialog(page).getByRole('button', { name: 'Rescan', exact: true }).click();
await expect(toast(page)).toHaveText(/^Rescan finished\. /);
await expect(alert).toBeAttached();
expect(await page.evaluate(() => window.alertScrolls)).toBe(0);
});
// #249 final review m8: D36's alphabetical categories hold whatever order the server sends.
test('toolbar categories are alphabetical regardless of server order', async ({ page }) => {
await page.route('**/prompt_manager/library/options*', route => route.fulfill({
@@ -1860,7 +1945,7 @@ test('Set category without a linked image posts nothing and opens no dialog', as
await page.route(categoryRoute, route => { posted += 1; return route.continue(); });
await checkbox(cards(page).last(), 'same.png').check();
await categoryButton(page).click();
await expect(toast(page)).toHaveText('None of the selected images has a linked prompt.');
await expect(toast(page)).toHaveText("None of the selected images is linked to a saved prompt, so there is no prompt category to set.");
await expect(page.locator('dialog[open]')).toHaveCount(0);
expect(posted).toBe(0);
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('1 selected');
@@ -1952,3 +2037,50 @@ test('Set category limits the field to the server length, shows the server count
await expect(bulkbar(page)).toBeEmpty();
expect(answers).toHaveLength(0);
});
// Activity-1 Task 3 (D55): Import → "Scan for metadata" → Review scan opens the concept's "Review metadata scan".
const scanReview = page => page.getByRole('dialog', { name: 'Review metadata scan', exact: true });
async function openScanReview(page) {
await page.getByRole('button', { name: 'Import', exact: true }).click();
const row = importDialog(page).locator('.operation').filter({ has: page.getByRole('heading', { name: 'Scan for metadata', exact: true }) });
await row.getByRole('button', { name: 'Review scan', exact: true }).click();
await expect(scanReview(page)).toBeVisible();
}
test('scan for metadata opens the review dialog from Import', async ({ page, request }) => {
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
await page.setViewportSize({ width: 1600, height: 1100 });
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
// Rescan stays the quick refresh; the metadata scan is its own row under it.
await page.getByRole('button', { name: 'Import', exact: true }).click();
await expect(importDialog(page).locator('.operation h3')).toHaveText(['Inspect a local image', 'Scan an output folder', 'Scan for metadata']);
await expect(importDialog(page).locator('.operation').nth(2).locator('p')).toHaveText('Discover and enrich existing files using the configured sources.');
await page.keyboard.press('Escape');
await openScanReview(page);
const dialog = scanReview(page);
await expect(dialog.locator('dl.meta-grid > div > dt')).toHaveText(['Sources', 'Scope']);
await expect(dialog.locator('dl.meta-grid > div > dd')).toHaveText(['2 output folders', 'New and unresolved files']);
await expect(dialog.locator('#modal-body > p.small')).toHaveText('Enrich positive / negative associations, reuse canonical text and report unchanged files. Do not inflate run counts or recreate existing thumbnails.');
await expect(dialog.locator('.notice h3')).toHaveText('Back up the database first (recommended)');
await expect(dialog.locator('.dialog-actions button')).toHaveText(['Cancel', 'Start scan']);
await expect(dialog.getByRole('button', { name: 'Start scan', exact: true })).toHaveClass(/ca-primary/);
// Back up now downloads the existing backup, toasts, and keeps the dialog open for the scan.
const download = page.waitForEvent('download');
await dialog.getByRole('button', { name: 'Back up now', exact: true }).click();
expect((await download).suggestedFilename()).toMatch(/^prompts_backup_\d{8}_\d{6}\.db$/);
await expect(toast(page)).toHaveText('Database backup downloaded.');
await expect(dialog).toBeVisible();
// A failed backup says so and downloads nothing.
await page.route('**/prompt_manager/backup', route => route.fulfill({ status: 500, json: { success: false, error: 'disk full' } }));
await dialog.getByRole('button', { name: 'Back up now', exact: true }).click();
await expect(toast(page)).toHaveText('The database could not be backed up. Try again.');
// Cancel closes it and starts nothing.
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(scanReview(page)).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Import', exact: true })).toBeFocused();
const jobs = await (await request.get('/prompt_manager/jobs')).json();
expect(jobs.jobs).toEqual([]);
});
+46 -20
View File
@@ -1,6 +1,6 @@
import {writeFile} from 'node:fs/promises';
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import {test,expect} from './fixtures.mjs';
import {captureCanonicalState} from './history-canonical-comparison.mjs';
import {inventoryOf,mapConcept,diffInventory} from './library-editor-canonical-comparison.mjs';
@@ -438,7 +438,7 @@ const variantsPanel=page=>page.getByRole('region',{name:'Saved negative variants
const savedTexts=page=>historyRegion(page).getByRole('region',{name:'Saved prompt texts',exact:true});
const toast=page=>page.locator('#workspace-toast');
const visibleNames=locator=>locator.evaluateAll(nodes=>nodes.map(node=>node.textContent.trim()));
const pressedVariant=page=>variantsPanel(page).locator('.library-variant-dot[aria-pressed="true"]');
const pressedVariant=page=>variantsPanel(page).locator('.variant-dot[aria-pressed="true"]');
async function openHistory(page,id) {
await page.goto(`${workspace}#/library/${id}`);await expect(variantsPanel(page)).toBeVisible();
}
@@ -450,7 +450,7 @@ async function leaveAndReturn(page,id) {
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
await page.evaluate(id=>{location.hash=`#/library/${id}`;},id);await expect(variantsPanel(page)).toBeVisible();await expect(pressedVariant(page)).toHaveCount(1);
}
const laneLabel=(page,text)=>variantsPanel(page).locator('.library-variant-lane').filter({hasText:text});
const laneLabel=(page,text)=>variantsPanel(page).locator('.variant-lane').filter({hasText:text});
test('library heading offers Export then + New prompt, and New opens the saved prompt history',async({page,request})=>{
await page.goto(`${workspace}#/library`);await expect(page.locator('article[data-prompt-id]').first()).toBeVisible();
@@ -497,7 +497,7 @@ test('history heading follows the concept and sends the selected saved pair',asy
await expect(page.getByRole('link',{name:'Back to library',exact:true})).toHaveCount(0);
const send=actions.nth(2);await expect(send).toHaveClass(/\bsend\b/);await expect(send.locator('svg')).toHaveCount(1);
await expect(send).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${f.metadata.title}`);
expect(await visibleNames(variantsPanel(page).locator('.row-actions button'))).toEqual(['+ Add negative','Show archived (1)']);
expect(await visibleNames(variantsPanel(page).locator('.row-actions button'))).toEqual(['Compare negatives','+ Add negative','Show archived (1)']);
await expect(variantsPanel(page).getByRole('button',{name:'Show archived (1)',exact:true})).toHaveAttribute('aria-pressed','false');
// The concept opens a prompt history on its first negative variant (app.js navigate: state.variant = 0), so Send starts enabled.
await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');await expect(send).toBeEnabled();
@@ -509,15 +509,17 @@ test('history heading follows the concept and sends the selected saved pair',asy
expect(page.writes).toHaveLength(0);
});
test('returning to a history opens on the first negative, forgetting a clicked variant or All images',async({page})=>{
test('returning to a history opens on the first negative, forgetting a clicked variant; All images is a dialog',async({page})=>{
const f=page.fixture;await openHistory(page,f.positive_id);
await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');
await selectPair(page,'Select negative variant 2');
await leaveAndReturn(page,f.positive_id);await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');
// #249 D26: All images opens the concept dialog and leaves the selected negative as it was.
const all=variantsPanel(page).getByRole('button',{name:'All images for this positive',exact:true});
await all.click();await expect(all).toHaveAttribute('aria-pressed','true');await expect(pressedVariant(page)).toHaveCount(0);
await all.click();const listing=page.getByRole('dialog',{name:'All images for this positive',exact:true});await expect(listing).toBeVisible();
await page.keyboard.press('Escape');await expect(listing).toHaveCount(0);await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');
await leaveAndReturn(page,f.positive_id);
await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');await expect(all).toHaveAttribute('aria-pressed','false');
await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');
await expect(headingActions(page).locator('button.send')).toBeEnabled();expect(page.writes).toHaveLength(0);
});
@@ -532,7 +534,7 @@ test('add negative re-renders history with the saved or reused pair selected',as
await add.click();dialog=editorDialog(page);await ready(dialog);await field(dialog,'Negative prompt').fill(` ${f.selected_negative.toUpperCase()} `);await button(dialog,'Save').click();
await expect(dialog).toHaveCount(0);await expect(toast(page)).toHaveText('This prompt pair already exists. Showing the saved version.');
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText(f.selected_negative);
await expect(laneLabel(page,/· 1 linked image$/)).toHaveCount(1);
await expect(laneLabel(page,/· 2 runs$/)).toHaveCount(1);
await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 3');
// The saved selection applies to that save only; returning opens on the first negative, as in the concept.
await leaveAndReturn(page,f.positive_id);await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');
@@ -552,7 +554,7 @@ test('reused pair outside first forty remains selected without loading every pag
const dialog=editorDialog(page);await ready(dialog);await field(dialog,'Negative prompt').fill(f.first_negative);await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText(f.first_negative);
await expect(pressedVariant(page)).toHaveCount(1);await expect(variantsPanel(page)).toContainText('40 of 44 saved negative variants');
await expect(variantsPanel(page).locator('.library-variant-lane')).toHaveCount(41);
await expect(variantsPanel(page).locator('.variant-lane')).toHaveCount(41);
expect(variantCursorRequests).toHaveLength(0);expect(page.writes).toHaveLength(1);
});
@@ -565,7 +567,7 @@ test('edit metadata stays on the page and re-reads the selected pair',async({pag
await field(dialog,'Title').fill('Edited in place');await field(dialog,'Category').fill('Studio work');await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);
await expect(page.locator('#workspace-main h1')).toHaveText('Edited in place');await expect(page.locator('#workspace-main h1')).toBeFocused();
await expect(toast(page)).toHaveText('Saved.');
await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));await expect(historyRegion(page).locator('.library-surface .library-chip').first()).toHaveText('Studio work');
await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));await expect(historyRegion(page).locator('article.surface > span.chip').first()).toHaveText('Studio work');
await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 2');
await expect.poll(()=>pairReads.length).toBeGreaterThan(before);
await expect(headingActions(page).locator('button.send')).toHaveAccessibleName('Send prompt pair to ComfyUI: Edited in place');
@@ -639,6 +641,29 @@ test('restored storage before an Edit or Add negative save offers only the Libra
expect((await detail(request,f.positive_id)).variants.some(variant=>variant.negative_text==='typed before the restore')).toBe(false);
});
// #249 F2: archived lanes (#235) are concept lanes marked archived, with the D45 reduced emphasis.
test('show archived and restore still work',async({page,request})=>{
const f=page.fixture;await openHistory(page,f.positive_id);
const timeline=variantsPanel(page).locator(':scope > .timeline');
expect(await visibleNames(variantsPanel(page).locator('.panel-heading .row-actions button'))).toEqual(['Compare negatives','+ Add negative','Show archived (1)']);
await expect(timeline.locator('.library-variant-archived')).toHaveCount(0);
const toggle=variantsPanel(page).getByRole('button',{name:'Show archived (1)',exact:true});await toggle.click();await expect(toggle).toHaveAttribute('aria-pressed','true');
const lane=timeline.locator(':scope > .variant-lane.library-variant-archived');await expect(lane).toHaveCount(1);
await expect(lane.locator(':scope > span')).toContainText(/ · \d+ runs? · Archived/);
const dot=lane.locator(':scope > button.variant-dot');await expect(dot).toHaveText('−');
expect(Number(await dot.evaluate(node=>getComputedStyle(node).opacity))).toBeLessThan(1);
expect(Number(await lane.evaluate(node=>getComputedStyle(node,'::before').opacity))).toBeLessThan(1);
await expect(lane.locator(':scope > span')).toHaveCSS('opacity','1');
await dot.click();await expect(dot).toHaveAttribute('aria-pressed','true');
const writes=page.writes.length;
await lane.getByRole('button',{name:/^Restore /}).click();
await expect(timeline.locator('.library-variant-archived')).toHaveCount(0);
await expect(variantsPanel(page).getByRole('button',{name:'Show archived (0)',exact:true})).toBeVisible();
expect(page.writes.slice(writes)).toEqual([{method:'PATCH',url:`/prompt_manager/pairs/${f.archived_pair_id}`,body:{archived:false}}]);
expect((await pairRead(request,f.archived_pair_id)).archived).toBe(false);
await expect(timeline.locator(':scope > .variant-lane > .variant-dot[aria-pressed="true"]')).toHaveCount(1);
});
test('Restore stays disabled until the restore settles and sends one PATCH',async({page,request})=>{
const f=page.fixture;await openHistory(page,f.positive_id);
let release;const held=new Promise(resolve=>{release=resolve;});
@@ -668,22 +693,23 @@ test('archive hides a pair, Show archived finds it and Restore keeps it selected
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();const dialog=editorDialog(page);await ready(dialog);
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0);
await expect(toast(page)).toHaveText('Archived. Find it under Show archived in Negative variants.');await expect(page.locator('#workspace-main h1')).toBeFocused();
await expect(variantsPanel(page).locator('.library-variant-lane')).toHaveCount(2);await expect(pressedVariant(page)).toHaveCount(1);
await expect(variantsPanel(page).locator('.variant-lane')).toHaveCount(2);await expect(pressedVariant(page)).toHaveCount(1);
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true})).not.toContainText(f.selected_negative);
expect(await evidence()).toEqual(before);
const toggle=variantsPanel(page).getByRole('button',{name:'Show archived (2)',exact:true});await expect(toggle).toHaveAttribute('aria-pressed','false');
await toggle.click();await expect(toggle).toHaveAttribute('aria-pressed','true');
const archived=variantsPanel(page).locator('.library-variant-archived');await expect(archived).toHaveCount(2);
const lane=archived.filter({hasText:'· 1 linked image · Archived'});await expect(lane).toHaveCount(1);
const opacity=await lane.locator('.library-variant-dot').evaluate(node=>getComputedStyle(node).opacity);expect(Number(opacity)).toBeLessThan(1);
const lane=archived.filter({hasText:'· 2 runs · Archived'});await expect(lane).toHaveCount(1);
const opacity=await lane.locator('.variant-dot').evaluate(node=>getComputedStyle(node).opacity);expect(Number(opacity)).toBeLessThan(1);
const galleryReads=[];page.on('request',item=>{if(item.url().includes('/images?'))galleryReads.push(new URL(item.url()).searchParams);});
await lane.locator('.library-variant-dot').click();await expect(lane.locator('.library-variant-dot')).toHaveAttribute('aria-pressed','true');
await lane.locator('.variant-dot').click();await expect(lane.locator('.variant-dot')).toHaveAttribute('aria-pressed','true');
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText(f.selected_negative);
await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).locator('article[data-image-key]')).toHaveCount(1);
// #249 D8 ruling (a): the Library-embedded gallery has no select checkbox and no selection bar.
await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).locator('.image-overlays input[type="checkbox"]')).toHaveCount(0);
await expect(page.locator('#bulkbar, .bulkbar')).toHaveCount(0);
await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).getByText('1 loaded · 1 linked image',{exact:true})).toBeVisible();
// D47: the archived pair's gallery is named by its "Shared gallery" heading, with no separate loaded count.
await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).locator('.panel-heading .small')).toHaveText(/ · Archived · same positive prompt$/);
expect(galleryReads.at(-1).get('archive')).toBe('archived');expect(galleryReads.at(-1).get('pair_id')).toBe(String(f.pair_id));
await expect(headingActions(page).getByRole('button',{name:/^Send prompt pair to ComfyUI/})).toBeEnabled();
const writes=page.writes.length;
@@ -728,7 +754,7 @@ test('export downloads the full Library JSON without a toast and reports a failu
expect(downloads).toBe(0);await expect(toast(page)).toBeHidden();expect(page.writes).toHaveLength(0);
});
const uniqueDotNames=async page=>{const names=await variantsPanel(page).locator('.library-variant-dot').evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')));expect(new Set(names).size).toBe(names.length);};
const uniqueDotNames=async page=>{const names=await variantsPanel(page).locator('.variant-dot').evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')));expect(new Set(names).size).toBe(names.length);};
test('archived reuse is reported, shown under Show archived and selected read-only from Add negative and New',async({page})=>{
const f=page.fixture;await openHistory(page,f.positive_id);
@@ -739,7 +765,7 @@ test('archived reuse is reported, shown under Show archived and selected read-on
await expect(toast(page)).toHaveText(message);
await expect(variantsPanel(page).getByRole('button',{name:'Show archived (1)',exact:true})).toHaveAttribute('aria-pressed','true');
const lane=variantsPanel(page).locator('.library-variant-archived');await expect(lane).toHaveCount(1);
await expect(lane.locator('.library-variant-dot')).toHaveAttribute('aria-pressed','true');
await expect(lane.locator('.variant-dot')).toHaveAttribute('aria-pressed','true');
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText(f.archived_negative);
await expect.poll(()=>galleryReads.at(-1)?.get('archive')).toBe('archived');expect(galleryReads.at(-1).get('pair_id')).toBe(String(f.archived_pair_id));
await uniqueDotNames(page);
@@ -748,7 +774,7 @@ test('archived reuse is reported, shown under Show archived and selected read-on
await field(dialog,'Category').fill(f.metadata.category);await field(dialog,'Tags, separated by commas').fill(f.metadata.tags.join(', '));await field(dialog,'Rating').selectOption(String(f.metadata.rating));await field(dialog,'Notes').fill(f.metadata.notes);
await button(dialog,'Save').click();await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));
await expect(toast(page)).toHaveText(message);
await expect(variantsPanel(page).locator('.library-variant-archived .library-variant-dot')).toHaveAttribute('aria-pressed','true');
await expect(variantsPanel(page).locator('.library-variant-archived .variant-dot')).toHaveAttribute('aria-pressed','true');
expect(page.writes.every(write=>write.method==='POST')).toBe(true);
});
@@ -809,7 +835,7 @@ test('turning Show archived on again after a hidden archive shows the newly arch
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0);
await expect(toggle()).toHaveText('Show archived (2)');await toggle().click();
await expect(variantsPanel(page).locator('.library-variant-archived')).toHaveCount(2);
await expect(laneLabel(page,'· 1 linked image · Archived')).toHaveCount(1);await uniqueDotNames(page);
await expect(laneLabel(page,'· 2 runs · Archived')).toHaveCount(1);await uniqueDotNames(page);
});
test('the comparison reports a wrong role color, a wrong field color and a removed Save control',async({page})=>{
@@ -873,7 +899,7 @@ test('long and error states retain footer focus and reflow',async({page})=>{
// Task A2: the editor dialogs keep matching the concept on the concept base stylesheet, with library-editor.css retired.
const CONCEPT_BASE=['js/shared/toolkit/controls.css','lib/viewerjs/viewer.min.css','js/workspace/concept.css',
'js/workspace/library.css','js/workspace/workspace.css'];
'js/workspace/workspace.css'];
for(const [width,height,theme] of [[1600,1100,'dark'],[390,844,'light']])test(`concept base keeps the tags screen and editor dialogs equal to the concept ${width} ${theme}`,async({page})=>{
await page.setViewportSize({width,height});const f=page.fixture;const references={};
for(const mode of Object.keys(conceptModes)) {
+46 -21
View File
@@ -1,5 +1,5 @@
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import {test,expect} from './fixtures.mjs';
const route='/prompt_manager/workspace#/library';
const cards=page=>page.locator('article[data-prompt-id]');
@@ -7,6 +7,7 @@ const history=page=>page.getByRole('region',{name:'Prompt history',exact:true});
const saved=page=>history(page).getByRole('region',{name:'Saved prompt texts',exact:true});
const gallery=page=>page.getByRole('region',{name:'Shared gallery',exact:true});
const media=page=>gallery(page).locator('article[data-image-key]');
const allImages=page=>page.getByRole('dialog',{name:'All images for this positive',exact:true});
const refine=page=>page.getByRole('dialog',{name:'Refine this view',exact:true});
/** The Library's Tags filter lives in "Refine this view" (#249 D14). */
@@ -24,12 +25,16 @@ async function loadShelfPages(page,more,pages) {
await more.evaluate(node=>node.click());await response;await expect(more).toHaveAttribute('aria-disabled','false');
}
}
/** Resolves in the rendering frame after every frame callback the page had already queued (callbacks run in request order). */
const nextFrame=page=>page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>resolve())));
async function openHistory(page,text='Cat portrait') {
await cards(page).filter({has:page.getByText(text,{exact:true})}).getByRole('button',{name:'Explore history',exact:true}).click();
await expect(history(page)).toBeVisible();
}
// The fixture's pairs are titled, so each dot is named by its title (#249 D45); these contracts address lanes by position.
const variantDot=(page,index)=>history(page).locator('.timeline > .variant-lane').nth(index).locator(':scope > .variant-dot');
async function selectVariant(page,index) {
const choice=history(page).getByRole('button',{name:`Select negative variant ${index+1}`,exact:true});
const choice=variantDot(page,index);
await choice.click();await expect(choice).toHaveAttribute('aria-pressed','true');
}
@@ -57,9 +62,10 @@ test('history displays the selected full negative beneath its positive and filte
const data=await(await request.get(`/prompt_manager/library/${cat.id}`)).json();
// The concept opens on the first negative variant and filters the gallery to it (app.js navigate: state.variant = 0).
await page.goto(route);await openHistory(page);
await expect(history(page).getByRole('button',{name:'Select negative variant 1',exact:true})).toHaveAttribute('aria-pressed','true');
await expect(variantDot(page,0)).toHaveAttribute('aria-pressed','true');
await expect(media(page)).toHaveCount(['blurry, watermark','low quality'].includes(data.variants[0].negative_text)?1:0);
await expect(history(page).getByRole('button',{name:'All images for this positive',exact:true})).toHaveAttribute('aria-pressed','false');
// #249 D26: All images for this positive opens a dialog; it is not a gallery filter.
await expect(history(page).getByRole('button',{name:'All images for this positive',exact:true})).not.toHaveAttribute('aria-pressed',/.*/);
for(const negative of ['blurry, watermark','low quality','low contrast','']) {
const index=data.variants.findIndex(variant=>variant.negative_text===negative);await selectVariant(page,index);
const neg=saved(page).getByRole('region',{name:'Negative prompt',exact:true});
@@ -70,8 +76,10 @@ test('history displays the selected full negative beneath its positive and filte
const order=await positive.evaluate((element)=>element.compareDocumentPosition(element.parentElement.querySelector('[aria-label="Negative prompt"]')));
expect(order&4).toBeTruthy();
}
await history(page).getByRole('button',{name:'All images for this positive',exact:true}).click();await expect(media(page)).toHaveCount(3);
expect(await media(page).evaluateAll(nodes=>nodes.map(node=>node.dataset.imageKey))).toEqual(expect.arrayContaining(['0:shared/same.png','1:shared/same.png','0:legacy-unassigned.png']));
await history(page).getByRole('button',{name:'All images for this positive',exact:true}).click();
const listing=allImages(page).locator('article[data-image-key]');await expect(listing).toHaveCount(3);
expect(await listing.evaluateAll(nodes=>nodes.map(node=>node.dataset.imageKey))).toEqual(expect.arrayContaining(['0:shared/same.png','1:shared/same.png','0:legacy-unassigned.png']));
await page.keyboard.press('Escape');await expect(allImages(page)).toHaveCount(0);
});
test('search, category and tag filters preserve their constraints and return standalone positives',async({page})=>{
@@ -97,7 +105,8 @@ test('library infinite loading keeps unique parents and keyboard-accessible hist
const appended=await (await nextPage).json();expect(appended.items.length).toBeGreaterThan(0);hasMore=appended.has_more;
if(hasMore)await expect(more).toHaveAttribute('aria-disabled','false');
}
await expect(more).toBeHidden();expect(await cards(page).count()).toBeLessThan(80);
// The last page's render queued the window update for the next frame; count after that frame, not before it.
await expect(more).toBeHidden();await nextFrame(page);expect(await cards(page).count()).toBeLessThan(80);
const ids=await cards(page).evaluateAll(nodes=>nodes.map(node=>node.dataset.promptId));expect(new Set(ids).size).toBe(ids.length);
await expect(page.getByRole('button',{name:/next page|previous page/i})).toHaveCount(0);
const firstId=await cards(page).first().getAttribute('data-prompt-id');
@@ -145,19 +154,19 @@ test('failed library reads retry while late search responses cannot overwrite ne
test('library toolbar matches the concept and tags live in Refine this view',async({page})=>{
await page.goto(route);await expect(cards(page)).toHaveCount(3);
// app.js toolbar() a347–360: search.toolbar > label.search-field (input#search), select#category-filter, select#sort, Filters.
// app.js toolbar() a347–360: search.toolbar > label.search-field (input#search), select#category-filter, select#sort, Save view, Filters.
const toolbar=page.locator('#workspace-main search.toolbar');await expect(toolbar).toHaveCount(1);
expect(await toolbar.evaluate(node=>[...node.children].map(child=>child.tagName.toLowerCase()+(child.id?`#${child.id}`:'')+(child.className?`.${child.className.split(' ').filter(name=>!name.startsWith('ca-')).join('.')}`:'')))).toEqual(['label.search-field','select#category-filter','select#sort','button']);
expect(await toolbar.evaluate(node=>[...node.children].map(child=>child.tagName.toLowerCase()+(child.id?`#${child.id}`:'')+(child.className?`.${child.className.split(' ').filter(name=>!name.startsWith('ca-')).join('.')}`:'')))).toEqual(['label.search-field','select#category-filter','select#sort','button','button']);
await expect(toolbar.locator('#search')).toHaveAttribute('placeholder','Search prompts, tags, filenames…');
await expect(toolbar.locator('#sort option')).toHaveText(['Recently added','Name A–Z','Highest rated','Oldest first']);
await expect(toolbar.getByRole('button')).toHaveText(['Filters']);
await expect(toolbar.getByRole('button')).toHaveText(['Save view','Filters']);
await expect(page.getByRole('button',{name:'Refresh library',exact:true})).toHaveCount(0);
await expect(page.getByRole('textbox',{name:'Tags',exact:true})).toHaveCount(0);
await expect(page.locator('.library-shelf > search.toolbar:first-child')).toHaveCount(1);
// Tags apply from the dialog only; Clear clears them and the toolbar category, as the concept's does.
await page.getByRole('combobox',{name:'Filter by category',exact:true}).selectOption('Portraits');await applyTags(page,'studio');await expect(cards(page)).toHaveCount(1);
// D44: the active Tags filter shows on the button; the category is the toolbar's own and is not counted.
await expect(toolbar.getByRole('button')).toHaveText(['Filters · 1']);
await expect(toolbar.getByRole('button')).toHaveText(['Save view','Filters · 1']);
await toolbar.getByRole('button',{name:'Filters · 1',exact:true}).click();
await expect(refine(page).locator('.field label')).toHaveText(['Tags']);await expect(refine(page).getByRole('textbox',{name:'Tags',exact:true})).toHaveValue('studio');
await refine(page).getByRole('button',{name:'Clear filters',exact:true}).click();await expect(refine(page)).toHaveCount(0);
@@ -200,8 +209,9 @@ test('the caption count is announced once per change and kept while the shelf re
test('a Library-embedded media card stars its file and the star survives a reload',async({page})=>{
await page.goto(route);await openHistory(page);
await history(page).getByRole('button',{name:'All images for this positive',exact:true}).click();await expect(media(page)).toHaveCount(3);
const card=gallery(page).locator('article[data-image-key="0:legacy-unassigned.png"]');
// The unassigned file is reached through the All images dialog (#249 D26).
await history(page).getByRole('button',{name:'All images for this positive',exact:true}).click();await expect(allImages(page).locator('article[data-image-key]')).toHaveCount(3);
const card=allImages(page).locator('article[data-image-key="0:legacy-unassigned.png"]');
const star=card.getByRole('button',{name:'Favorite legacy-unassigned.png',exact:true});
await expect(star).toHaveAttribute('aria-pressed','false');await expect(card.locator('input[type=checkbox]')).toHaveCount(0);
await expect(page.locator('.bulkbar, #bulkbar')).toHaveCount(0);
@@ -210,7 +220,7 @@ test('a Library-embedded media card stars its file and the star survives a reloa
await expect(star).toHaveAttribute('aria-pressed','true');await expect(star).toHaveText('★');
await expect(page.getByText('Added to favorites.',{exact:true})).toBeVisible();
await page.reload();await history(page).getByRole('button',{name:'All images for this positive',exact:true}).click();
await expect(gallery(page).locator('article[data-image-key="0:legacy-unassigned.png"]').getByRole('button',{name:'Favorite legacy-unassigned.png',exact:true})).toHaveAttribute('aria-pressed','true');
await expect(allImages(page).locator('article[data-image-key="0:legacy-unassigned.png"]').getByRole('button',{name:'Favorite legacy-unassigned.png',exact:true})).toHaveAttribute('aria-pressed','true');
});
test('a sort change atomically discards old row geometry and renders changed parent data',async({page,request})=>{
@@ -327,10 +337,10 @@ test('history shares real image metadata, vendored Viewer and the acknowledged p
test('large variant histories append bounded pages without replacing the selected negative',async({page,request})=>{
await expect(await request.post('/__test__/reset',{data:{native_library:true,extra_variants:41}})).toBeOK();
await page.goto(route);await openHistory(page);
const variants=history(page).getByRole('button',{name:/^Select negative variant \d+$/});await expect(variants).toHaveCount(40);
const variants=history(page).getByRole('button',{name:/^Select negative /});await expect(variants).toHaveCount(40);
await selectVariant(page,0);const chosenNegative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text').textContent();
await history(page).getByRole('button',{name:'Load more negative variants',exact:true}).click();await expect(variants).toHaveCount(45);
await expect(history(page).getByRole('button',{name:'Select negative variant 1',exact:true})).toHaveAttribute('aria-pressed','true');
await expect(variantDot(page,0)).toHaveAttribute('aria-pressed','true');
await expect(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText(chosenNegative);
});
@@ -369,14 +379,18 @@ async function observeLibrary(page, hold=false) {
holdFrames(){holdingFrames=true;},
releaseFrames(){holdingFrames=false;const callbacks=[...heldFrames.values()];heldFrames.clear();for(const callback of callbacks)callback();},
equal(){for(const record of records){const entries=[...record.latest.values()].filter(entry=>record.targets.has(entry.target));if(entries.length)record.callback(entries,record.native);}},
// Resolves once two consecutive rendering frames find no Library frame queued. Each check runs inside a native
// frame callback, so a frame the page queues later in that update (a scroll event, a ResizeObserver delivery) is
// still pending there. A timer is not a frame: under CPU load a 60 ms wait could end before the scroll's frame.
// A frame that never drains (a rAF loop, or settling while frames are held) fails after `limit` frames with the stats.
settled(limit=600){return new Promise((resolve,reject)=>{let quiet=0,frames=0;const check=()=>{frames++;quiet=pending.size?0:quiet+1;
if(quiet>=2)resolve();else if(frames>=limit)reject(new Error(`Library never settled within ${limit} frames: ${JSON.stringify(window.libraryObservation.stats())}`));else raf(check);};raf(check);});},
stats(){return {requested,pending:pending.size,heldFrames:heldFrames.size,measurements:records.reduce((sum,record)=>sum+record.entries.size,0),mutations,unmounted,targets:records.reduce((sum,record)=>sum+record.targets.size,0)};},
};
},{hold});
}
async function settleLibrary(page) {
await expect.poll(async()=>page.evaluate(()=>window.libraryObservation.stats().pending)).toBe(0);
await page.evaluate(()=>new Promise(resolve=>setTimeout(resolve,60)));
await expect.poll(async()=>page.evaluate(()=>window.libraryObservation.stats().pending)).toBe(0);
await page.evaluate(()=>window.libraryObservation.settled());
}
async function firstVisibleParent(page) {
return cards(page).evaluateAll(nodes=>{
@@ -553,10 +567,10 @@ test('saved choices retain curved staggered lanes and the full negative immediat
await page.goto(route);await openHistory(page);await selectVariant(page,1);
const positive=saved(page).getByRole('region',{name:'Positive prompt',exact:true});
expect(await positive.evaluate(node=>node.nextElementSibling?.getAttribute('aria-label'))).toBe('Negative prompt');
const values=await page.locator('.library-variant-lane').evaluateAll(nodes=>nodes.map(node=>({offset:parseFloat(getComputedStyle(node).marginInlineStart),radius:getComputedStyle(node,'::before').borderBottomLeftRadius})));
const values=await page.locator('.library-history .variant-lane').evaluateAll(nodes=>nodes.map(node=>({offset:parseFloat(getComputedStyle(node).marginInlineStart),radius:getComputedStyle(node,'::before').borderBottomLeftRadius})));
expect(values).toEqual([{offset:38,radius:'22px'},{offset:85,radius:'22px'},{offset:132,radius:'22px'},{offset:179,radius:'22px'}]);
const order=await page.locator('.library-history').evaluate(node=>[...node.querySelectorAll('section[aria-label]')].filter(section=>['Saved negative variants','Recorded executions','Shared gallery'].includes(section.getAttribute('aria-label'))).map(section=>section.getAttribute('aria-label')));
expect(order).toEqual(['Saved negative variants','Recorded executions','Shared gallery']);
expect(order).toEqual(['Saved negative variants','Shared gallery','Recorded executions']);
const rows=await(await request.get('/prompt_manager/library')).json();const cat=rows.items.find(item=>item.text==='Cat portrait');
const details=await(await request.get(`/prompt_manager/library/${cat.id}`)).json();
const selected=details.variants[1].negative_text;
@@ -676,6 +690,17 @@ test('title and Explore history are buttons that open history',async({page,reque
await page.keyboard.press('ArrowLeft');await expect(cards(page).nth(1).locator('.card-title > button')).toBeFocused();
});
test('settling fails with the observation stats when a Library frame stays queued',async({page,request})=>{
await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:200}});
await observeLibrary(page);await page.addInitScript(()=>{window.IntersectionObserver=undefined;});
await page.goto(route);await expect(cards(page).first()).toBeVisible();await settleLibrary(page);
await page.evaluate(()=>{window.libraryObservation.holdFrames();dispatchEvent(new Event('scroll'));});
await expect.poll(()=>page.evaluate(()=>window.libraryObservation.stats().heldFrames)).toBeGreaterThan(0);
const failure=await page.evaluate(()=>window.libraryObservation.settled(30).then(()=>'settled',error=>error.message));
expect(failure).toMatch(/^Library never settled within 30 frames: \{.*"heldFrames":[1-9]/);
await page.evaluate(()=>window.libraryObservation.releaseFrames());await settleLibrary(page);
});
test('virtualized rows keep measured heights with the clamp',async({page,request,isMobile})=>{
await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:200}});
await observeLibrary(page);await page.addInitScript(()=>{window.IntersectionObserver=undefined;});
@@ -0,0 +1,546 @@
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
// Saved views (#249 D16, maintainer ruling 2026-10-21): the toolbar's Save view (concept app.js a359, a1330–1346),
// the sidebar SAVED VIEWS rows (a331–335) and `#/saved/<id>` (a1158–1162), on Images and the Prompt library.
const workspace = '/prompt_manager/workspace';
const API = '/prompt_manager/saved-views';
const main = page => page.locator('#workspace-main');
const toolbar = page => main(page).locator('search.toolbar');
const search = page => page.getByRole('searchbox', { name: 'Search workspace', exact: true });
const sortBy = page => page.getByRole('combobox', { name: 'Sort results', exact: true });
const filtersButton = page => toolbar(page).getByRole('button', { name: /^Filters(?: · \d+)?$/ });
const imageCards = page => page.locator('article[data-image-key]');
const promptCards = page => page.locator('article[data-prompt-id]');
const savedNav = page => page.getByRole('navigation', { name: 'Saved views', exact: true });
// The saved views' own rows, after the Favorites row (D51).
const viewRows = page => savedNav(page).locator('a[data-route^="saved/"]');
const favoritesRow = page => savedNav(page).locator('a[data-route="favorites"]');
const favoritesCount = page => favoritesRow(page).locator('.nav-count');
const editDialog = page => page.getByRole('dialog', { name: 'Edit saved view', exact: true });
const deleteDialog = page => page.getByRole('dialog', { name: 'Delete saved view', exact: true });
const heading = page => main(page).getByRole('heading', { level: 1 });
const saveDialog = page => page.getByRole('dialog', { name: 'Save this view', exact: true });
const refineDialog = page => page.getByRole('dialog', { name: 'Refine this view', exact: true });
const toast = page => page.locator('#workspace-toast');
const IMAGE_DEFAULTS = { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false };
async function refine(page, values) {
await filtersButton(page).click();
for (const [label, value] of Object.entries(values)) {
const control = refineDialog(page).getByLabel(label, { exact: true });
if (label === 'Tags') await control.fill(value);
else await control.selectOption(value);
}
await refineDialog(page).getByRole('button', { name: 'Apply filters', exact: true }).click();
await expect(refineDialog(page)).toHaveCount(0);
}
async function listed(request) {
const response = await request.get(API);
await expect(response).toBeOK();
return (await response.json()).views;
}
async function create(request, data) {
const response = await request.post(API, { data });
expect(response.status()).toBe(201);
return (await response.json()).view;
}
test.beforeEach(async ({ page, request, browserName }) => {
await startCoverage(page, browserName);
await expect(await request.post('/__test__/reset')).toBeOK();
page.savedErrors = [];
page.on('pageerror', error => page.savedErrors.push(error.message));
// D51: deleting asks inside the dialog; no browser dialog (window.confirm) may ever open.
page.on('dialog', dialog => { page.savedErrors.push(`browser ${dialog.type()}`); dialog.dismiss().catch(() => {}); });
// No listing read may fail on a saved view's values (an unknown Source would be a 400).
page.on('response', response => {
if (response.status() >= 400 && new URL(response.url()).pathname === '/prompt_manager/images/output') {
page.savedErrors.push(`${response.status()} ${response.url()}`);
}
});
});
test.afterEach(async ({ page, browserName }) => {
await saveCoverage(page, browserName);
expect(page.savedErrors).toEqual([]);
});
test('Save view saves the Images toolbar and Refine filters, and its sidebar row reopens them', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
await page.goto(`${workspace}#/images`);
await expect(imageCards(page)).toHaveCount(2);
// The concept's sidebar group: SAVED VIEWS without its PROPOSED badge (D49), empty until a view is saved.
await expect(page.locator('aside.sidebar .sidebar-label')).toHaveText(['WORKSPACE', 'SAVED VIEWS']);
await expect(viewRows(page)).toHaveCount(0);
await search(page).fill('dogs');
await expect(imageCards(page)).toHaveCount(1);
await sortBy(page).selectOption('name');
await refine(page, { Source: '1', Media: 'image' });
await expect(filtersButton(page)).toHaveText('Filters · 2');
const saveButton = toolbar(page).getByRole('button', { name: 'Save view', exact: true });
await saveButton.click();
// The concept's simpleInput: "View name", the search's default name, Cancel and the primary action.
const dialog = saveDialog(page);
await expect(dialog.getByRole('textbox', { name: 'View name', exact: true })).toHaveValue('Search: dogs');
await expect(dialog.getByRole('textbox', { name: 'View name', exact: true })).toBeFocused();
await expect(dialog.locator('.dialog-actions button')).toHaveText(['Cancel', 'Save']);
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(dialog).toHaveCount(0);
await expect(saveButton).toBeFocused();
expect(await listed(request)).toEqual([]);
await saveButton.click();
await dialog.getByRole('textbox', { name: 'View name', exact: true }).fill('Dog shots');
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
await expect(toast(page)).toHaveText('Saved view added to the sidebar.');
const [view] = await listed(request);
expect(view).toMatchObject({ page: 'images', name: 'Dog shots',
state: { ...IMAGE_DEFAULTS, q: 'dogs', sort: 'name', source: '1', media: 'image' } });
// The concept's links() row: the search icon, then the name, and no count.
const row = savedNav(page).getByRole('link', { name: 'Dog shots', exact: true });
await expect(row).toHaveAttribute('href', `#/saved/${view.id}`);
expect(await row.evaluate(link => [...link.childNodes].map(node => node.nodeType === Node.TEXT_NODE ? `text:${node.textContent}`
: `${node.localName}.${node.getAttribute('class')}`))).toEqual(['svg.icon', 'text:Dog shots']);
await expect(row).not.toHaveAttribute('aria-current', 'page');
// Another page, then the saved row: its page opens with every saved value and the Filters count (D44).
await page.getByRole('navigation', { name: 'Workspace', exact: true }).getByRole('link', { name: /^Prompt library/ }).click();
await expect(promptCards(page).first()).toBeVisible();
await row.click();
await expect(page).toHaveURL(new RegExp(`#/saved/${view.id}$`));
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Dog shots');
await expect(main(page).getByRole('heading', { level: 1 })).toBeFocused();
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Dog shots');
await expect(page).toHaveTitle('Dog shots · Prompt Manager');
await expect(row).toHaveAttribute('aria-current', 'page');
await expect(page.locator('aside.sidebar nav a[aria-current]')).toHaveCount(1);
await expect(search(page)).toHaveValue('dogs');
await expect(sortBy(page)).toHaveValue('name');
await expect(filtersButton(page)).toHaveText('Filters · 2');
await expect(imageCards(page)).toHaveCount(1);
await expect(imageCards(page)).toHaveAttribute('data-image-key', '1:shared/same.png');
await filtersButton(page).click();
await expect(refineDialog(page).getByLabel('Source', { exact: true })).toHaveValue('1');
await expect(refineDialog(page).getByLabel('Media', { exact: true })).toHaveValue('image');
await page.keyboard.press('Escape');
// A reload opens the same view from its link.
await page.reload();
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Dog shots');
await expect(imageCards(page)).toHaveCount(1);
await expect(savedNav(page).getByRole('link', { name: 'Dog shots', exact: true })).toHaveAttribute('aria-current', 'page');
});
test('an empty name is Saved view, and a Library view reopens the shelf filters', async ({ page, request }) => {
await page.goto(`${workspace}#/images`);
await expect(imageCards(page)).toHaveCount(2);
await toolbar(page).getByRole('button', { name: 'Save view', exact: true }).click();
const name = saveDialog(page).getByRole('textbox', { name: 'View name', exact: true });
await expect(name).toHaveValue('My workspace view');
await name.fill(' ');
await saveDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(viewRows(page)).toHaveText(['Saved view']);
expect((await listed(request))[0]).toMatchObject({ page: 'images', name: 'Saved view', state: IMAGE_DEFAULTS });
await page.goto(`${workspace}#/library`);
await expect(promptCards(page)).toHaveCount(2);
await search(page).fill('dog');
await expect(promptCards(page)).toHaveCount(1);
await refine(page, { Tags: 'dogs' });
await expect(filtersButton(page)).toHaveText('Filters · 1');
await toolbar(page).getByRole('button', { name: 'Save view', exact: true }).click();
await expect(name).toHaveValue('Search: dog');
await saveDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(viewRows(page)).toHaveText(['Saved view', 'Search: dog']);
const library = (await listed(request))[1];
expect(library).toMatchObject({ page: 'library', name: 'Search: dog', state: { q: 'dog', category: '', sort: 'newest', tags: ['dogs'] } });
await page.goto(`${workspace}#/images`);
await expect(imageCards(page)).toHaveCount(2);
await savedNav(page).getByRole('link', { name: 'Search: dog', exact: true }).click();
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Search: dog');
await expect(search(page)).toHaveValue('dog');
await expect(filtersButton(page)).toHaveText('Filters · 1');
await expect(promptCards(page)).toHaveCount(1);
// Saved views open the page; one saved view to another re-renders with the other's state.
await savedNav(page).getByRole('link', { name: 'Saved view', exact: true }).click();
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Saved view');
await expect(search(page)).toHaveValue('');
await expect(imageCards(page)).toHaveCount(2);
});
test('a saved value the page no longer accepts stays default while the rest applies', async ({ page, request }) => {
const view = await create(request, { page: 'images', name: 'Gone source',
state: { ...IMAGE_DEFAULTS, q: 'cat', sort: 'name-desc', source: '7', folder: 'missing/folder' } });
await page.goto(`${workspace}#/saved/${view.id}`);
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Gone source');
await expect(imageCards(page)).toHaveCount(1);
await expect(imageCards(page)).toHaveAttribute('data-image-key', '0:shared/same.png');
await expect(search(page)).toHaveValue('cat');
await expect(sortBy(page)).toHaveValue('name-desc');
// The missing Source and Folder are dropped, so no refinement is active.
await expect(filtersButton(page)).toHaveText('Filters');
await expect(main(page).locator('.filter-row > span.chip')).toHaveText('All output folders');
// A Source that does exist, with a folder it lists, applies.
const kept = await create(request, { page: 'images', name: 'Input shared',
state: { ...IMAGE_DEFAULTS, source: '1', folder: 'shared' } });
await page.goto(`${workspace}#/saved/${kept.id}`);
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Input shared');
await expect(filtersButton(page)).toHaveText('Filters · 2');
await expect(imageCards(page)).toHaveCount(1);
await expect(imageCards(page)).toHaveAttribute('data-image-key', '1:shared/same.png');
});
test('a view stored under older rules still lists and opens with its usable values (#249 I1)', async ({ page, request }) => {
// No favorites key, a retired key and a sort that is no longer offered, as an older build stored it.
const raw = async data => {
const response = await request.post('/__test__/saved-views/raw', { data });
await expect(response).toBeOK();
return (await response.json()).id;
};
const imagesId = await raw({ page: 'images', name: 'Old cats', state: JSON.stringify({ q: 'cat', sort: 'shuffle', layout: 'grid' }) });
await raw({ page: 'library', name: '', state: JSON.stringify({ q: 'dog', sort: 'name-desc' }) });
expect(await listed(request)).toEqual([
{ id: imagesId, page: 'images', name: 'Old cats', state: { ...IMAGE_DEFAULTS, q: 'cat' } },
expect.objectContaining({ page: 'library', name: 'Saved view', state: { q: 'dog', category: '', sort: 'newest', tags: [] } }),
]);
// A state that is no JSON object is never listed, and its link behaves like a missing view.
const corruptId = await raw({ page: 'images', name: 'Corrupt', state: '{not json' });
expect((await listed(request)).map(view => view.id)).not.toContain(corruptId);
await page.goto(`${workspace}#/saved/${corruptId}`);
await expect(page).toHaveURL(/#\/images$/);
await expect(heading(page)).toHaveText('Images');
await expect(viewRows(page)).toHaveText(['Old cats', 'Saved view']);
await page.goto(`${workspace}#/saved/${imagesId}`);
await expect(heading(page)).toHaveText('Old cats');
await expect(viewRows(page)).toHaveText(['Old cats', 'Saved view']);
await expect(search(page)).toHaveValue('cat');
await expect(sortBy(page)).toHaveValue('newest');
await expect(imageCards(page)).toHaveCount(1);
await savedNav(page).getByRole('link', { name: 'Saved view', exact: true }).click();
await expect(heading(page)).toHaveText('Saved view');
await expect(search(page)).toHaveValue('dog');
await expect(promptCards(page)).toHaveCount(1);
});
test('leaving a saved view returns Images and the Library to their own last state (#249 M2)', async ({ page, request }) => {
const dogs = await create(request, { page: 'images', name: 'Dogs', state: { ...IMAGE_DEFAULTS, q: 'dogs' } });
const cats = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [] } });
await page.goto(`${workspace}#/images`);
await search(page).fill('cat');
await expect(imageCards(page)).toHaveCount(1);
await viewRows(page).filter({ hasText: 'Dogs' }).click();
await expect(heading(page)).toHaveText('Dogs');
await expect(search(page)).toHaveValue('dogs');
await page.locator('#primary-nav a[data-route="images"]').click();
await expect(heading(page)).toHaveText('Images');
await expect(search(page)).toHaveValue('cat');
await expect(imageCards(page)).toHaveCount(1);
await page.locator('#primary-nav a[data-route="library"]').click();
await expect(promptCards(page)).toHaveCount(2);
await search(page).fill('dog');
await expect(promptCards(page)).toHaveCount(1);
await page.goto(`${workspace}#/saved/${cats.id}`);
await expect(heading(page)).toHaveText('Cats');
await expect(search(page)).toHaveValue('cat');
await page.locator('#primary-nav a[data-route="library"]').click();
await expect(heading(page)).toHaveText('Prompt library');
await expect(search(page)).toHaveValue('dog');
await expect(promptCards(page)).toHaveCount(1);
expect(dogs.id).not.toBe(cats.id);
});
test('a toolbar change while sources load keeps the saved Source and Folder (#249 M3)', async ({ page, request }) => {
const view = await create(request, { page: 'images', name: 'Input shared', state: { ...IMAGE_DEFAULTS, source: '1', folder: 'shared' } });
let release;
const held = new Promise(resolve => { release = resolve; });
await page.route('**/prompt_manager/gallery/subfolders?**', async route => { await held; await route.fallback(); });
await page.goto(`${workspace}#/saved/${view.id}`);
await expect(heading(page)).toHaveText('Input shared');
await sortBy(page).selectOption('name');
await expect(imageCards(page)).toHaveCount(0);
release();
await expect(filtersButton(page)).toHaveText('Filters · 2');
await expect(sortBy(page)).toHaveValue('name');
await expect(imageCards(page)).toHaveCount(1);
await expect(imageCards(page)).toHaveAttribute('data-image-key', '1:shared/same.png');
});
test('a saved view left while its list or rename is pending leaves the next page alone', async ({ page, request }) => {
const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [] } });
let releaseList = () => {};
const holdList = async () => {
const held = new Promise(resolve => { releaseList = resolve; });
await page.unroute(`**${API}`);
await page.route(`**${API}`, async route => { if (route.request().method() === 'GET') await held; await route.fallback(); });
};
// Leaving while the shell's first read is pending: the view never mounts over the page left to.
await holdList();
await page.goto(`${workspace}#/saved/${view.id}`);
await page.evaluate(() => { window.location.hash = '#/tags'; });
await expect(heading(page)).toHaveText('Tags & categories');
releaseList();
await expect(viewRows(page)).toHaveText(['Cats']);
await expect(heading(page)).toHaveText('Tags & categories');
// An unknown id re-reads the list once; leaving during that read keeps the page left to.
await holdList();
await page.evaluate(() => { window.location.hash = '#/saved/999'; });
await page.evaluate(() => { window.location.hash = '#/library'; });
await expect(heading(page)).toHaveText('Prompt library');
releaseList();
await expect(viewRows(page)).toHaveText(['Cats']);
await expect(page).toHaveURL(/#\/library$/);
await expect(heading(page)).toHaveText('Prompt library');
await page.unroute(`**${API}`);
// A rename that lands after the view was left does not retitle the next page.
let releaseRename;
const renameHeld = new Promise(resolve => { releaseRename = resolve; });
await page.route(`**${API}/${view.id}`, async route => { await renameHeld; await route.fallback(); });
await page.goto(`${workspace}#/saved/${view.id}`);
await expect(heading(page)).toHaveText('Cats');
await main(page).locator('.heading-actions > button').first().click();
await editDialog(page).getByRole('textbox', { name: 'View name', exact: true }).fill('Kittens');
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await page.locator('#primary-nav a[data-route="images"]').click();
await expect(heading(page)).toHaveText('Images');
releaseRename();
await expect(viewRows(page)).toHaveText(['Kittens']);
await expect(heading(page)).toHaveText('Images');
await expect(page).toHaveTitle('Images · Prompt Manager');
});
test('an unknown saved view opens Images, and a failed listing offers Retry', async ({ page, request }) => {
await page.goto(`${workspace}#/saved/999`);
await expect(page).toHaveURL(/#\/images$/);
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Images');
const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [] } });
let failing = true;
await page.route(`**${API}`, route => failing && route.request().method() === 'GET'
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"down"}' })
: route.fallback());
await page.goto(`${workspace}#/saved/${view.id}`);
await expect(main(page).getByRole('alert')).toHaveText('Saved views could not be loaded. Your saved data has not changed.');
await expect(viewRows(page)).toHaveCount(0);
failing = false;
await main(page).getByRole('button', { name: 'Retry', exact: true }).click();
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Cats');
await expect(search(page)).toHaveValue('cat');
await expect(promptCards(page)).toHaveCount(1);
await expect(viewRows(page)).toHaveText(['Cats']);
});
test('a failed save says so and adds nothing', async ({ page, request }) => {
await page.goto(`${workspace}#/images`);
await expect(imageCards(page)).toHaveCount(2);
await page.route(`**${API}`, route => route.request().method() === 'POST'
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"Saved view operation failed"}' })
: route.fallback());
await toolbar(page).getByRole('button', { name: 'Save view', exact: true }).click();
await saveDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(toast(page)).toHaveText('The view could not be saved. Try again.');
await expect(viewRows(page)).toHaveCount(0);
expect(await listed(request)).toEqual([]);
});
async function star(page, key) {
await page.locator(`article[data-image-key="${key}"] .star-button`).click();
}
test('Favorites is the first SAVED VIEWS row, with a real count that follows every star', async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
let release;
const held = new Promise(resolve => { release = resolve; });
// Hidden until its read settles: never a placeholder "0".
await page.route(url => url.pathname.endsWith('/prompt_manager/images/output') && url.searchParams.get('favorites') === '1'
&& url.searchParams.get('limit') === '1', async route => { await held; await route.fallback(); }, { times: 1 });
await page.goto(`${workspace}#/images`);
await expect(imageCards(page)).toHaveCount(2);
await expect(favoritesRow(page)).toHaveAttribute('href', '#/favorites');
await expect(favoritesCount(page)).toHaveCount(0);
release();
// The concept's row (app.js a332): the star icon, "Favorites", then the count.
await expect(favoritesCount(page)).toHaveText('0');
expect(await favoritesRow(page).evaluate(link => [...link.childNodes].map(node => node.nodeType === Node.TEXT_NODE
? `text:${node.textContent}` : `${node.localName}.${node.getAttribute('class')}`)))
.toEqual(['svg.icon', 'text:Favorites', 'span.nav-count']);
expect(await savedNav(page).locator('a').evaluateAll(links => links.map(link => link.dataset.route))).toEqual(['favorites']);
await star(page, '1:shared/same.png');
await expect(toast(page)).toHaveText('Added to favorites.');
await expect(favoritesCount(page)).toHaveText('1');
// #/favorites: Images filtered to favorites, named Favorites (concept renderImages a417).
const listings = [];
page.on('request', item => {
const url = new URL(item.url());
if (url.pathname.endsWith('/prompt_manager/images/output') && url.searchParams.get('limit') !== '1') listings.push(url.searchParams);
});
await favoritesRow(page).click();
await expect(page).toHaveURL(/#\/favorites$/);
await expect(heading(page)).toHaveText('Favorites');
await expect(heading(page)).toBeFocused();
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Favorites');
await expect(page).toHaveTitle('Favorites · Prompt Manager');
await expect(favoritesRow(page)).toHaveAttribute('aria-current', 'page');
await expect(page.locator('aside.sidebar nav a[aria-current]')).toHaveCount(1);
await expect(imageCards(page)).toHaveCount(1);
await expect(imageCards(page)).toHaveAttribute('data-image-key', '1:shared/same.png');
await expect(main(page).locator('#result-count')).toHaveText(/^1 of 1 image/);
expect(listings.at(-1).get('favorites')).toBe('1');
// Unstarring on Favorites keeps the card (with ☆) so it can be undone in place; the count follows.
await star(page, '1:shared/same.png');
await expect(toast(page)).toHaveText('Removed from favorites.');
await expect(imageCards(page).locator('.star-button')).toHaveAttribute('aria-pressed', 'false');
await expect(favoritesCount(page)).toHaveText('0');
await star(page, '1:shared/same.png');
await expect(favoritesCount(page)).toHaveText('1');
// Favorites never replaces the Images return state: Images still lists everything.
await page.getByRole('navigation', { name: 'Workspace', exact: true }).getByRole('link', { name: /^Images/ }).click();
await expect(heading(page)).toHaveText('Images');
await expect(imageCards(page)).toHaveCount(2);
await page.goto(`${workspace}#/favorites`);
await expect(heading(page)).toHaveText('Favorites');
await expect(imageCards(page)).toHaveCount(1);
});
test('a failed Favorites count shows no badge', async ({ page }) => {
await page.route(url => url.pathname.endsWith('/prompt_manager/images/output') && url.searchParams.get('favorites') === '1'
&& url.searchParams.get('limit') === '1', route => route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } }));
await page.goto(`${workspace}#/images`);
await expect(page.locator('#primary-nav a[data-route="images"] .nav-count')).toHaveText('2');
await expect(favoritesRow(page)).toBeVisible();
await expect(favoritesCount(page)).toHaveCount(0);
// The refused count read is this test's own; nothing else failed.
page.savedErrors = page.savedErrors.filter(error => !/^503 .*favorites=1/.test(error));
});
test('Save view on Favorites reopens the favorites', async ({ page, request }) => {
await page.goto(`${workspace}#/images`);
await expect(imageCards(page)).toHaveCount(2);
await star(page, '0:shared/same.png');
await expect(favoritesCount(page)).toHaveText('1');
await page.goto(`${workspace}#/favorites`);
await expect(imageCards(page)).toHaveCount(1);
await toolbar(page).getByRole('button', { name: 'Save view', exact: true }).click();
await saveDialog(page).getByRole('textbox', { name: 'View name', exact: true }).fill('Best');
await saveDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(toast(page)).toHaveText('Saved view added to the sidebar.');
const [view] = await listed(request);
expect(view).toMatchObject({ page: 'images', name: 'Best', state: { ...IMAGE_DEFAULTS, favorites: true } });
await expect(viewRows(page)).toHaveText(['Best']);
await page.goto(`${workspace}#/images`);
await expect(imageCards(page)).toHaveCount(2);
await viewRows(page).click();
await expect(heading(page)).toHaveText('Best');
await expect(imageCards(page)).toHaveCount(1);
await expect(imageCards(page)).toHaveAttribute('data-image-key', '0:shared/same.png');
});
test('Edit renames an open view, and deletes it only after confirming inside the dialog', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const view = await create(request, { page: 'images', name: 'Dogs', state: { ...IMAGE_DEFAULTS, q: 'dogs' } });
await page.goto(`${workspace}#/images`);
await expect(imageCards(page)).toHaveCount(2);
// Plain pages have no Edit.
await expect(main(page).locator('.heading-actions').getByRole('button', { name: 'Edit', exact: true })).toHaveCount(0);
await viewRows(page).click();
await expect(heading(page)).toHaveText('Dogs');
// D51: Edit leads the heading's actions of an open view.
const edit = main(page).locator('.heading-actions > button').first();
await expect(edit).toHaveText('Edit');
await edit.click();
const dialog = editDialog(page);
const name = dialog.getByRole('textbox', { name: 'View name', exact: true });
await expect(name).toHaveValue('Dogs');
await expect(name).toBeFocused();
await expect(dialog.locator('.dialog-actions button')).toHaveText(['Delete…', 'Cancel', 'Save']);
await expect(dialog.getByRole('button', { name: 'Delete…', exact: true })).toHaveClass(/\bdanger\b/);
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(dialog).toHaveCount(0);
await expect(edit).toBeFocused();
// An empty name keeps the dialog open on its field.
await edit.click();
await name.fill(' ');
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
await expect(name).toBeFocused();
await name.fill('Good dogs');
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
await expect(toast(page)).toHaveText('Saved view renamed.');
await expect(viewRows(page)).toHaveText(['Good dogs']);
await expect(heading(page)).toHaveText('Good dogs');
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Good dogs');
await expect(page).toHaveTitle('Good dogs · Prompt Manager');
await expect(edit).toBeFocused();
expect(await listed(request)).toEqual([{ ...view, name: 'Good dogs' }]);
// The rename kept the view's page state.
await expect(search(page)).toHaveValue('dogs');
await expect(imageCards(page)).toHaveCount(1);
// Delete… asks first; its Cancel deletes nothing.
await edit.click();
await dialog.getByRole('button', { name: 'Delete…', exact: true }).click();
const confirm = deleteDialog(page);
await expect(confirm).toContainText('Delete “Good dogs”? Only the saved view is removed; its images and prompts stay.');
await expect(confirm.locator('.dialog-actions button')).toHaveText(['Cancel', 'Delete']);
await expect(confirm.getByRole('button', { name: 'Cancel', exact: true })).toBeFocused();
await confirm.getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(confirm).toHaveCount(0);
expect(await listed(request)).toHaveLength(1);
await edit.click();
await dialog.getByRole('button', { name: 'Delete…', exact: true }).click();
await confirm.getByRole('button', { name: 'Delete', exact: true }).click();
await expect(toast(page)).toHaveText('Saved view deleted.');
await expect(page).toHaveURL(/#\/images$/);
await expect(heading(page)).toHaveText('Images');
await expect(viewRows(page)).toHaveCount(0);
expect(await listed(request)).toEqual([]);
// The dead link left no history entry.
await page.goBack();
await expect(page).not.toHaveURL(new RegExp(`#/saved/${view.id}$`));
});
test('a deleted Library view returns to the Library, and failed edits say so', async ({ page, request }) => {
const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [] } });
await page.goto(`${workspace}#/saved/${view.id}`);
await expect(heading(page)).toHaveText('Cats');
const edit = main(page).locator('.heading-actions > button').first();
await expect(edit).toHaveText('Edit');
let failing = true;
await page.route(`**${API}/${view.id}`, route => failing
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"Saved view operation failed"}' })
: route.fallback());
await edit.click();
await editDialog(page).getByRole('textbox', { name: 'View name', exact: true }).fill('Kittens');
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(toast(page)).toHaveText('The view could not be renamed. Try again.');
await expect(heading(page)).toHaveText('Cats');
await expect(viewRows(page)).toHaveText(['Cats']);
await edit.click();
await editDialog(page).getByRole('button', { name: 'Delete…', exact: true }).click();
await deleteDialog(page).getByRole('button', { name: 'Delete', exact: true }).click();
await expect(toast(page)).toHaveText('The view could not be deleted. Try again.');
await expect(page).toHaveURL(new RegExp(`#/saved/${view.id}$`));
expect(await listed(request)).toHaveLength(1);
failing = false;
await edit.click();
await editDialog(page).getByRole('button', { name: 'Delete…', exact: true }).click();
await deleteDialog(page).getByRole('button', { name: 'Delete', exact: true }).click();
await expect(toast(page)).toHaveText('Saved view deleted.');
await expect(page).toHaveURL(/#\/library$/);
await expect(heading(page)).toHaveText('Prompt library');
await expect(viewRows(page)).toHaveCount(0);
});
+23 -18
View File
@@ -3,12 +3,12 @@ import { readFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { captureCanonicalState, historyGeometry, compareCanonicalHistory, nativeDialogState, exerciseCurrentPairSend, assertRecordedHistory } from './history-canonical-comparison.mjs';
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import { test, expect } from './fixtures.mjs';
test.use({ locale: 'en-US', timezoneId: 'UTC', reducedMotion: 'reduce' });
const workspace = '/prompt_manager/workspace';
const primary = ['images', 'library', 'tags', 'inspect'];
const tools = ['system', 'overview'];
const tools = ['activity', 'system', 'overview'];
async function noOverflow(page) {
expect(await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
@@ -94,23 +94,24 @@ for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of ['
await page.goto(`${workspace}#/library/${fixture.canonical_id}`);
await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption(theme);
const details = await (await request.get(`/prompt_manager/library/${fixture.canonical_id}`)).json();
const index = details.variants.findIndex(variant => variant.id === fixture.canonical_pair);
const choice = page.getByRole('button', { name: `Select negative variant ${index + 1}`, exact: true });
const pairTitle = details.variants.find(variant => variant.id === fixture.canonical_pair).title;
const choice = page.getByRole('button', { name: `Select negative ${pairTitle}`, exact: true });
await choice.focus();await page.keyboard.press('Enter');await expect(choice).toHaveAttribute('aria-pressed', 'true');
const actual = await historyGeometry(page, true);
const roles = page.getByRole('region', { name: 'Saved prompt texts', exact: true });
const positive = roles.getByRole('region', { name: 'Positive prompt', exact: true });
expect(await positive.evaluate(node => node.nextElementSibling?.getAttribute('aria-label'))).toBe('Negative prompt');
for (const name of ['Copy positive prompt', 'Send positive prompt to ComfyUI', 'Copy negative prompt', 'Send negative prompt to ComfyUI']) await expect(roles.getByRole('button', { name, exact: true })).toBeVisible();
const title = await page.locator('#workspace-main > .heading h1').textContent();
for (const name of ['Copy positive prompt', 'Send positive prompt to ComfyUI', 'Copy negative prompt', 'Send negative prompt to ComfyUI']) await expect(roles.getByRole('button', { name: `${name}: ${title}`, exact: true })).toBeVisible();
// As in the concept, the selected pair's send sits in the history heading, not among the saved texts.
await expect(roles.getByRole('button', { name: 'Send prompt pair', exact: true })).toHaveCount(0);
await expect(page.locator('#workspace-main > .heading button.send')).toBeEnabled();
await expect(page.locator('.library-shared-gallery article[data-image-key]')).toHaveCount(2);
await page.locator('.library-shared-gallery .image-card-actions button').first().click();
await expect(page.locator('.library-history .history-gallery article[data-image-key]')).toHaveCount(2);
await page.locator('.library-history .history-gallery .image-card-actions button').first().click();
await expect(page.getByRole('complementary', { name: 'Selected image details', exact: true })).toContainText('canonical-alt.png');
await page.locator('.library-shared-gallery .media-card.selected .image-open').click();
await page.locator('.library-history .history-gallery .media-card.selected .image-open').click();
await expect(page.locator('dialog#viewer-dialog')).toBeVisible();await page.keyboard.press('Escape');
for (const [name, selector] of [['saved', '.library-surface'], ['branches', '.library-variants'], ['gallery', '.library-shared-gallery'], ['chronology', '.recorded-history']]) {
for (const [name, selector] of [['saved', '.library-history article.surface'], ['branches', '.library-history .timeline-panel'], ['gallery', '.library-history .history-gallery'], ['chronology', '.recorded-history']]) {
await captureCanonicalState(page, testInfo, `production-${name}-${width}-${theme}`, selector);
}
const run = page.locator(`button[data-run-id="${fixture.canonical_run}"][data-anchor-id="10"]`);
@@ -125,11 +126,11 @@ for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of ['
const viewer = page.locator('dialog#viewer-dialog');await expect(viewer).toBeVisible();
await expect(viewer.getByRole('region', { name: 'Positive prompt', exact: true })).toContainText(fixture.canonical_positive);
await expect(viewer.getByRole('region', { name: 'Negative prompt', exact: true })).toContainText(fixture.canonical_negative);
for (const name of ['Copy positive prompt', 'Send positive prompt to ComfyUI', 'Copy negative prompt', 'Send negative prompt to ComfyUI']) await expect(viewer.getByRole('button', { name, exact: true })).toBeVisible();
for (const name of ['Copy positive prompt', 'Send positive prompt to ComfyUI', 'Copy negative prompt', 'Send negative prompt to ComfyUI']) await expect(viewer.getByRole('button', { name: new RegExp(`^${name}: `) })).toBeVisible();
await expect(viewer.getByRole('button', { name: /^Send current file prompt pair to ComfyUI: |^Send prompt pair to ComfyUI: / })).toBeVisible();
await expect(viewer).toContainText('42001');await expect(viewer).toContainText('720');await expect(viewer).toContainText('480');
const currentPositive = viewer.getByRole('region', { name: 'Positive prompt', exact: true });
await currentPositive.getByRole('button', { name: 'Copy positive prompt', exact: true }).click();
await currentPositive.getByRole('button', { name: /^Copy positive prompt: / }).click();
// The concept's copy outcome: the shared toast, or its "Copy prompt" dialog when the clipboard is refused.
const copyFallback = page.getByRole('dialog', { name: 'Copy prompt', exact: true });
await expect(page.locator('#workspace-toast').filter({ hasText: 'Prompt copied.' }).or(copyFallback)).toHaveCount(1);
@@ -541,7 +542,7 @@ test('Library visual evidence keeps natural cards and returns from real history
await noOverflow(page);
await page.getByRole('button',{name:'Explore history',exact:true}).first().click();
await expect(page.getByRole('region',{name:'Prompt history',exact:true})).toBeVisible();
await expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('One positive prompt. Reusable negative variants. One shared gallery.');
await expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('Prompt history · one positive, one shared gallery');
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
await expect(page.locator('article[data-prompt-id]')).toHaveCount(6);
await expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('One positive. Reusable negatives. Every experiment stays connected.');
@@ -555,7 +556,7 @@ test('approved icons are decorative safe SVGs and reject unknown vocabulary', as
await page.goto(`${workspace}#/images`);
const icons = await page.evaluate(async () => {
const { createWorkspaceIcon } = await import('/prompt_manager/js/workspace/icons.mjs');
const names = ['images', 'library', 'tags', 'upload', 'overview', 'settings', 'grid', 'list', 'masonry', 'search', 'info', 'copy', 'branch', 'star', 'tagImage', 'folder', 'history', 'review'];
const names = ['images', 'library', 'tags', 'upload', 'overview', 'activity', 'settings', 'grid', 'list', 'masonry', 'search', 'info', 'copy', 'branch', 'star', 'tagImage', 'folder', 'history', 'review'];
const otherDocument = document.implementation.createHTMLDocument();
const rendered = names.map(name => {
const icon = createWorkspaceIcon(name, otherDocument);
@@ -574,7 +575,8 @@ test('approved icons are decorative safe SVGs and reject unknown vocabulary', as
expect(icon).toMatchObject({ svg: true, className: 'icon', owner: true, viewBox: '0 0 24 24', hidden: 'true', focusable: 'false', fill: 'none', stroke: 'currentColor', width: '1.65', children: 1 });
expect(icon.path.length).toBeGreaterThan(10);
}
// D28 ruling: Overview's own outline home glyph, not the concept's `activity` pulse ('M3 12h4l3-8 4 16 3-8h4').
// D28 ruling: Overview's own outline home glyph; the concept's `activity` pulse is the Activity row's.
expect(icons.rendered.find(icon => icon.name === 'activity').path).toBe('M3 12h4l3-8 4 16 3-8h4');
expect(icons.rendered.find(icon => icon.name === 'overview').path).toBe('M3 11 12 4l9 7M5 10v10h14V10M10 20v-6h4v6');
expect(new Set(icons.rendered.map(icon => icon.path)).size).toBe(icons.rendered.length);
});
@@ -637,15 +639,18 @@ test('embedded history gallery keeps the history heading and disposes its layout
await expect(page.getByRole('region',{name:'Prompt history',exact:true})).toBeVisible();
await expect(page.locator('h1')).not.toHaveText('Prompt library');
const title=await page.locator('h1').textContent();expect(title).not.toBe('Images');
// History opens on its first negative variant (concept navigate: state.variant = 0); the layout contract uses the whole gallery.
// History opens on its first negative variant (concept navigate: state.variant = 0); the fixture's images are linked
// to the positive only, so the layout contract uses the All images dialog's embedded gallery (#249 D26).
await page.getByRole('button',{name:'All images for this positive',exact:true}).click();
await expect(page.locator('article[data-image-key]').first()).toBeVisible();
const listing=page.getByRole('dialog',{name:'All images for this positive',exact:true});
await expect(listing.locator('article[data-image-key]').first()).toBeVisible();
for (const layout of ['List','Masonry','Grid']) {
await page.getByRole('button',{name:layout,exact:true}).click();await orderedMedia(page);
await listing.getByRole('button',{name:layout,exact:true}).click();await orderedMedia(page);
await expect(page.locator('h1')).toHaveText(title);
await expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('One positive prompt. Reusable negative variants. One shared gallery.');
await expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('Prompt history · one positive, one shared gallery');
}
await attachEvidence(page,testInfo,`after-images-embedded-history-${width}`,false);
await page.keyboard.press('Escape');await expect(listing).toHaveCount(0);
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
await expect(page.locator('.media-card,.detail-panel,dialog[open]')).toHaveCount(0);
}
+5 -5
View File
@@ -1,6 +1,6 @@
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
import {writeFile} from 'node:fs/promises';
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import {test,expect} from './fixtures.mjs';
import {captureCanonicalState} from './history-canonical-comparison.mjs';
import {pageInventoryOf,inventoryOf,diffInventory,mapVocabularyConcept,mapVocabularyDialog} from './vocabulary-canonical-comparison.mjs';
@@ -95,11 +95,11 @@ test('a tag Browse shows Filters · 1 until its filter is cleared',async({page})
await page.goto('/prompt_manager/workspace#/library');
const shelf=page.locator('article[data-prompt-id]');await expect(shelf.first()).toBeVisible();const total=await shelf.count();
const toolbar=page.locator('#workspace-main search.toolbar');
await expect(toolbar.getByRole('button')).toHaveText(['Filters']);
await expect(toolbar.getByRole('button')).toHaveText(['Save view','Filters']);
await page.goto(route);
const row=tagRow(page,'cinematic');await row.getByRole('button',{name:'Browse',exact:true}).click();
await expectLibrary(page,{tags:'cinematic',count:2});
await expect(toolbar.getByRole('button')).toHaveText(['Filters · 1']);
await expect(toolbar.getByRole('button')).toHaveText(['Save view','Filters · 1']);
await toolbar.getByRole('button',{name:'Filters · 1',exact:true}).click();
const refine=page.getByRole('dialog',{name:'Refine this view',exact:true});
await refine.getByRole('button',{name:'Clear filters',exact:true}).click();await expect(refine).toHaveCount(0);
@@ -109,7 +109,7 @@ test('a tag Browse shows Filters · 1 until its filter is cleared',async({page})
const nav=page.getByRole('navigation',{name:'Workspace',exact:true});
await nav.getByRole('link',{name:/^Tags & categories(?:\s*[\d,]+)?$/}).click();await expect(main(page).getByRole('heading',{level:1})).toHaveText('Tags & categories');
await nav.getByRole('link',{name:/^Prompt library(?:\s*[\d,]+)?$/}).click();
await expect(shelf).toHaveCount(total);await expect(toolbar.getByRole('button')).toHaveText(['Filters']);
await expect(shelf).toHaveCount(total);await expect(toolbar.getByRole('button')).toHaveText(['Save view','Filters']);
});
test('category Browse sets only the category constraint',async({page})=>{
@@ -825,7 +825,7 @@ test('mutation check: the comparison reports a changed surface padding and a cha
// Task A2: the workspace loads the verbatim concept stylesheet as its base and one overlay last; the screen and its
// dialogs must stay equal to the concept with the retired vocabulary and editor sheets gone.
const CONCEPT_BASE=['js/shared/toolkit/controls.css','lib/viewerjs/viewer.min.css','js/workspace/concept.css',
'js/workspace/library.css','js/workspace/workspace.css'];
'js/workspace/workspace.css'];
const loadedSheets=page=>page.evaluate(()=>[...document.styleSheets].filter(sheet=>sheet.href).map(sheet=>new URL(sheet.href).pathname.replace(/^\/prompt_manager\//,'')));
for(const [width,height,theme] of [[1600,1100,'dark'],[390,844,'light']])test(`concept base keeps the tags screen and editor dialogs equal to the concept ${width} ${theme}`,async({page})=>{
await page.setViewportSize({width,height});
+39 -17
View File
@@ -1,5 +1,5 @@
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import { test, expect } from './fixtures.mjs';
const workspace = '/prompt_manager/workspace';
const statistic = (page, label) => page.locator('#workspace-main dt')
@@ -18,6 +18,10 @@ const routes = [
['tags', 'Tags & categories', null],
['inspect', 'Inspect a file', '/prompt_manager/gallery.html'],
['system', 'System', '/prompt_manager/admin'],
// #249 D51: SAVED VIEWS' Favorites, Images filtered to favorites.
['favorites', 'Favorites', null],
// #249 Activity-1: the concept's Activity page in the tools-and-system group.
['activity', 'Activity', null],
];
async function countThemeWrites(page) {
@@ -44,6 +48,8 @@ test.beforeEach(async ({ page, request, browserName }) => {
});
test.afterEach(async ({ page, browserName }) => {
// A route handler still running at teardown must not fail the test it outlived.
await page.unrouteAll({ behavior: 'ignoreErrors' });
await saveCoverage(page, browserName);
expect(page.workspaceErrors).toEqual([]);
});
@@ -174,7 +180,8 @@ test('theme System follows ComfyUI and persists an explicit light or dark prefer
await expect(theme).toHaveAttribute('data-ca-theme', 'light');
});
// Task A4 (D4, D5): the concept topbar (index.html 45-63, style.css 225-277) without the prototype badge or Activity.
// Task A4 (D4, D29): the concept topbar (index.html 45-63, style.css 225-277) without the prototype badge; Activity
// (D52, D53) shows its button, and its pill only while a job runs.
test('topbar matches the concept actions', async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
await page.goto(`${workspace}#/images`);
@@ -202,7 +209,7 @@ test('topbar matches the concept actions', async ({ page }) => {
expect(shape).toEqual({
children: ['div.breadcrumbs', 'div.top-actions'],
crumbs: ['ComfyAssets', 'span:/', 'span#workspace-breadcrumb:Images'],
actions: ['label.theme-label', 'a.canvas-link'],
actions: ['a.activity-pill', 'button.', 'label.theme-label', 'a.canvas-link'],
label: 'Theme', labelFor: 'theme',
options: [['dark', 'Dark'], ['light', 'Light'], ['system', 'System']],
arrow: [['↗', 'true']],
@@ -216,11 +223,12 @@ test('topbar matches the concept actions', async ({ page }) => {
await expect(link).toHaveAttribute('href', '/');
expect(await link.evaluate(node => node.href === `${location.origin}/`)).toBe(true);
await expect(page.getByRole('combobox', { name: 'Appearance' })).toHaveCount(0);
await expect(page.getByRole('button', { name: /^Activity/ })).toHaveCount(0);
await expect(page.locator('.concept-badge, #activity-open')).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Activity', exact: true })).toHaveCount(1);
await expect(page.locator('#activity-pill')).toBeHidden();
await expect(page.locator('.concept-badge')).toHaveCount(0);
await page.locator('nav [data-route="library"]').click();
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Prompt library');
await expect(topbar.locator('.top-actions > *')).toHaveCount(2);
await expect(topbar.locator('.top-actions > *')).toHaveCount(4);
});
// Concept style.css 1375-1391: at <=700px the breadcrumbs hide, the actions spread across the bar and the Theme
@@ -240,13 +248,18 @@ test('mobile topbar hides breadcrumbs and spreads actions', async ({ page }) =>
expected: bar.width - 28,
label: getComputedStyle(node.querySelector('.theme-label')).fontSize,
select: getComputedStyle(node.querySelector('#theme')).fontSize,
labelLeft: box(node.querySelector('.theme-label')).left - bar.left,
activityLeft: box(node.querySelector('#activity-open')).left - bar.left,
actionsGap: [box(node.querySelector('#activity-open')).right, box(node.querySelector('.theme-label')).left,
box(node.querySelector('.theme-label')).right, box(node.querySelector('.canvas-link')).left],
linkRight: bar.right - box(node.querySelector('.canvas-link')).right,
};
});
expect(layout).toMatchObject({ padding: '14px', actions: { justify: 'space-between', gap: '8px' }, label: '0px', select: '11px' });
expect(Math.abs(layout.actions.width - layout.expected)).toBeLessThanOrEqual(1);
expect(Math.abs(layout.labelLeft - 14)).toBeLessThanOrEqual(1);
// The concept's three items spread with space-between: Activity first, Theme between equal gaps, ComfyUI last.
expect(Math.abs(layout.activityLeft - 14)).toBeLessThanOrEqual(1);
const [activityRight, themeLeft, themeRight, linkLeft] = layout.actionsGap;
expect(Math.abs((themeLeft - activityRight) - (linkLeft - themeRight))).toBeLessThanOrEqual(1);
expect(Math.abs(layout.linkRight - 14)).toBeLessThanOrEqual(1);
await expect(page.getByRole('link', { name: 'ComfyUI', exact: true })).toBeVisible();
await theme.selectOption('light');
@@ -427,10 +440,13 @@ test('sidebar matches the concept groups and shows real counts', async ({ page,
await expect(page.locator('body > .app-shell > .workspace > #workspace-main')).toHaveCount(1);
await expect(page.locator('.workspace-shell, .workspace-body, .sidebar-footer, .connection-dot, .workspace-icon')).toHaveCount(0);
const sidebar = page.locator('aside.sidebar');
expect(await childShape(sidebar)).toEqual(['a.brand', 'div.sidebar-label', 'nav#primary-nav', 'div.sidebar-bottom']);
expect(await childShape(sidebar)).toEqual(['a.brand', 'div.sidebar-label', 'nav#primary-nav', 'div.sidebar-label',
'nav#saved-nav', 'div.sidebar-bottom']);
await expect(sidebar.locator('.brand')).toHaveAttribute('href', '#/images');
await expect(sidebar.locator('.sidebar-label')).toHaveText(['WORKSPACE']);
await expect(page.locator('#saved-nav, #collections-nav, .new-label')).toHaveCount(0);
// D49: SAVED VIEWS without the concept's PROPOSED badge; D28: still no COLLECTIONS.
await expect(sidebar.locator('.sidebar-label')).toHaveText(['WORKSPACE', 'SAVED VIEWS']);
await expect(page.locator('nav#saved-nav')).toHaveAttribute('aria-label', 'Saved views');
await expect(page.locator('#collections-nav, .new-label')).toHaveCount(0);
const primary = page.locator('nav#primary-nav');
await expect(primary).toHaveAttribute('aria-label', 'Workspace');
for (const [id] of NAV_TOTALS) await expect(navCount(page, id)).toHaveText(totals[id].toLocaleString('en-US'));
@@ -457,7 +473,7 @@ test('sidebar matches the concept groups and shows real counts', async ({ page,
// At phone width the concept wraps the links and hides counts and the connection (concept 950px block).
await page.setViewportSize({ width: 390, height: 844 });
await expect(navCount(page, 'library')).toBeHidden();
await expect(page.locator('.sidebar-label, .connection')).toHaveCount(2);
await expect(page.locator('.sidebar-label, .connection')).toHaveCount(3);
for (const hidden of await page.locator('.sidebar-label, .connection').all()) await expect(hidden).toBeHidden();
await expect(primary.locator('a[href="#/inspect"]')).toBeVisible();
});
@@ -501,18 +517,18 @@ test('a slow Images count does not delay the Library and Tags badges', async ({
await expect(navCount(page, 'images')).toHaveText(String(totals.images));
});
test('sidebar bottom holds System and Overview with a neutral connection', async ({ page }) => {
test('sidebar bottom holds Activity, System and Overview with a neutral connection', async ({ page }) => {
await page.goto(`${workspace}#/system`);
const bottom = page.locator('aside.sidebar > .sidebar-bottom');
expect(await childShape(bottom)).toEqual(['nav#system-nav', 'div.connection']);
const system = page.locator('nav#system-nav');
await expect(system).toHaveAttribute('aria-label', 'Tools and system');
expect(await system.locator(':scope > a').evaluateAll(nodes => nodes.map(node => [node.getAttribute('href'), node.textContent]))).toEqual([
['#/system', 'System'], ['#/overview', 'Overview'],
['#/activity', 'Activity'], ['#/system', 'System'], ['#/overview', 'Overview'],
]);
await expect(system.locator('.nav-count')).toHaveCount(0);
await expect(system.locator('a[href="#/system"]')).toHaveAttribute('aria-current', 'page');
await expect(system.locator('svg.icon')).toHaveCount(2);
await expect(system.locator('svg.icon')).toHaveCount(3);
const connection = bottom.locator('.connection');
expect(await childShape(connection)).toEqual(['i', 'div']);
await expect(connection.locator(':scope > div')).toHaveText('ComfyUI workspaceYour library, on your server');
@@ -674,8 +690,14 @@ async function serveUnderProxyMount(page) {
await page.route(url => url.pathname.startsWith(`${PROXY_MOUNT}/`), async route => {
const url = new URL(route.request().url());
url.pathname = url.pathname.slice(PROXY_MOUNT.length);
const response = await route.fetch({ url: url.href });
await route.fulfill({ response });
// A request still in flight when the test ends (a thumbnail the last route started) meets a page that is
// closing: its fetched response is disposed under it. Only that teardown error is ignored.
try {
const response = await route.fetch({ url: url.href });
await route.fulfill({ response });
} catch (error) {
if (!/has been disposed|has been closed|Target page, context or browser has been closed/.test(String(error?.message))) throw error;
}
});
return escaped;
}
+194
View File
@@ -0,0 +1,194 @@
"""Fake Activity jobs for the browser tests (#249 Activity-1).
A fake job is a real registry job whose runner waits for commands instead of
reading files, so a test sets its progress and counters exactly:
- ``POST /__test__/jobs/start`` starts one (``kind``, ``title``, ``total``,
``output_folders``) and returns its summary, or 409 while one runs;
- ``POST /__test__/jobs/{id}/step`` records ``records`` (``{group, path,
root, root_index, detail}`` file outcomes, counted, listed and logged as the scan
records them) and ``bulk`` (``{group, count, root}``: ``count`` numbered
rows), sets ``processed`` (and ``total``) and ``counters``, then publishes
the progress to every subscriber;
- ``POST /__test__/jobs/{id}/finish`` ends it as ``finished``, ``cancelled``
or ``failed``; a cancel through the real route ends it ``cancelled``;
- ``POST /__test__/jobs/restart`` acts out a ComfyUI restart: the open job
streams drop without a final event and the registry starts empty.
"""
import asyncio
import re
from aiohttp import web
EVENTS_PATH = re.compile(r"^/prompt_manager/jobs/[0-9a-f]{32}/events$")
FINAL_STATES = {"finished", "cancelled", "failed"}
POLL_SECONDS = 0.02
PREFIXES = {"added": "+", "known": "=", "review": "?", "errors": "!"}
PREFIXES["no_metadata"] = "-"
ROOT_INDEXES = {"output": 0, "input": 1}
def outcome_records(data):
"""The step's file outcomes: its ``records``, then its ``bulk`` rows."""
records = data.get("records", [])
bulk = data.get("bulk")
if not isinstance(records, list):
raise web.HTTPBadRequest()
rows = []
for record in records:
if not isinstance(record, dict) or record.get("group") not in PREFIXES:
raise web.HTTPBadRequest()
root = str(record.get("root", "output"))
rows.append(
{
"group": record["group"],
"path": str(record.get("path", "")),
"root": root,
# The fixture's listing order: output is root 0, input root 1.
"root_index": record.get("root_index", ROOT_INDEXES.get(root)),
"detail": str(record.get("detail", "")),
}
)
if bulk is not None:
if not isinstance(bulk, dict) or bulk.get("group") not in PREFIXES:
raise web.HTTPBadRequest()
total = bulk.get("count")
if isinstance(total, bool) or not isinstance(total, int) or total < 0:
raise web.HTTPBadRequest()
rows.extend(
{
"group": bulk["group"],
"path": f"bulk/file_{index:05d}.png",
"root": str(bulk.get("root", "output")),
"root_index": 0,
"detail": f"Bulk row {index}",
}
for index in range(total)
)
return rows
def register_activity_commands(app, routes, api):
from py.api.jobs import SCAN_GROUP_NAMES, JobBusy, JobRegistry
streams = set()
endings = {}
@web.middleware
async def track_streams(request, handler):
if not EVENTS_PATH.match(request.path):
return await handler(request)
transport = request.transport
streams.add(transport)
try:
return await handler(request)
finally:
streams.discard(transport)
app.middlewares.append(track_streams)
def fake_runner(ending):
async def run(job):
while not ending.done() and not job.cancelled:
await asyncio.sleep(POLL_SECONDS)
state = ending.result() if ending.done() else "cancelled"
job.publish({"type": "complete", "progress": job.progress})
job.finish(state)
return run
def job_or_404(request):
job = api._jobs.get(request.match_info["job_id"])
if job is None or job.id not in endings:
raise web.HTTPNotFound()
return job
async def body(request):
data = await request.json() if request.can_read_body else {}
if not isinstance(data, dict):
raise web.HTTPBadRequest()
return data
def count(value, default=0):
if value is None:
return default
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
raise web.HTTPBadRequest()
return value
@routes.post("/__test__/jobs/start")
async def start_job(request):
data = await body(request)
ending = asyncio.get_running_loop().create_future()
try:
job = api._jobs.start(
str(data.get("kind", "scan")),
str(data.get("title", "Scan for metadata")),
record_changing=True,
scope={
"files": str(data.get("files", "new")),
"output_folders": count(data.get("output_folders"), 2),
},
runner=fake_runner(ending),
groups=SCAN_GROUP_NAMES,
)
except JobBusy:
raise web.HTTPConflict()
job.update(total=count(data.get("total")))
endings[job.id] = ending
return web.json_response({"success": True, "job": job.summary()})
@routes.post("/__test__/jobs/{job_id}/step")
async def step_job(request):
job = job_or_404(request)
data = await body(request)
total = count(data.get("total"), job.total)
processed = count(data.get("processed"), job.processed)
counters = data.get("counters", {})
if not isinstance(counters, dict) or set(counters) - set(job.counters):
raise web.HTTPBadRequest()
for record in outcome_records(data):
group = record["group"]
path = record["path"]
detail = record["detail"]
job.record(group, record, f"{PREFIXES[group]} {path} · {detail}")
for key, value in counters.items():
job.counters[key] = count(value)
progress = processed * 100 // total if total else 0
job.update(
progress=progress,
processed=processed,
total=total,
status_line=f"Processing file {processed:,} of {total:,}",
)
job.publish({"type": "progress", "progress": progress})
return web.json_response({"success": True, "job": job.summary()})
@routes.post("/__test__/jobs/{job_id}/finish")
async def finish_job(request):
job = job_or_404(request)
state = (await body(request)).get("state", "finished")
if state not in FINAL_STATES:
raise web.HTTPBadRequest()
ending = endings[job.id]
if not ending.done():
ending.set_result(state)
while job.running:
await asyncio.sleep(POLL_SECONDS)
return web.json_response({"success": True, "job": job.summary()})
@routes.post("/__test__/jobs/restart")
async def restart(request):
old = api._jobs
api._jobs = JobRegistry(api.logger)
endings.clear()
for transport in list(streams):
transport.close()
tasks = [job.task for job in old.list() if job.task and not job.task.done()]
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
return web.json_response({"success": True})
+59 -47
View File
@@ -168,6 +168,7 @@ _TABLES = (
"execution_contexts",
"execution_prompt_snapshots",
"execution_context_texts",
"saved_views",
"image_favorites",
"image_prompt_pairs",
"prompt_pair_tags",
@@ -391,55 +392,66 @@ def seed_workspace_concept(
rounds = math.ceil(total / len(CONCEPT_PROMPTS))
now = time.time()
images, runs = [], []
for round_index in range(rounds):
for prompt_index, prompt in enumerate(CONCEPT_PROMPTS):
index = round_index * len(CONCEPT_PROMPTS) + prompt_index
if index >= total:
break
name = concept_filename(prompt, round_index)
path = folder / name
facts = write_concept_png(path, prompt_index, round_index)
variant, negative, seed = facts["variant"], facts["negative"], facts["seed"]
# The concept lists images newest first in this order.
stamp = now - 60 - index * 60
os.utime(path, (stamp, stamp))
owner = prompts[prompt_index]
image_id = db.link_image_to_prompt(owner["positive_id"], str(path))
pair_id = owner["pair_ids"][variant]
db.link_image_to_pair(pair_id, image_id, {"source": "concept fixture"})
if concept_starred(prompt_index, round_index):
db.set_image_favorite(
favorite_root, _favorite_relative_key(f"concepts/{name}")
)
context = RecipeContext(
"3", _snapshot(prompt.positive), _snapshot(negative), "inferred"
slots = [
(round_index, prompt_index, prompt)
for round_index in range(rounds)
for prompt_index, prompt in enumerate(CONCEPT_PROMPTS)
if round_index * len(CONCEPT_PROMPTS) + prompt_index < total
]
# Linked and recorded oldest first, as real runs arrive, so link and run ids
# grow with time and every newest-first read (by id or by mtime) lists the
# concept's order (app.js:180-239). The manifest keeps the concept's order.
for round_index, prompt_index, prompt in reversed(slots):
index = round_index * len(CONCEPT_PROMPTS) + prompt_index
name = concept_filename(prompt, round_index)
path = folder / name
facts = write_concept_png(path, prompt_index, round_index)
variant, negative, seed = facts["variant"], facts["negative"], facts["seed"]
# The concept lists images newest first in this order.
stamp = now - 60 - index * 60
os.utime(path, (stamp, stamp))
owner = prompts[prompt_index]
image_id = db.link_image_to_prompt(owner["positive_id"], str(path))
pair_id = owner["pair_ids"][variant]
db.link_image_to_pair(pair_id, image_id, {"source": "concept fixture"})
# Real pair counting: each image is one run of its pair, as the
# concept's "<name> · N runs" counts its images (app.js a598, D26).
db.record_prompt_pair_use(pair_id)
if concept_starred(prompt_index, round_index):
db.set_image_favorite(
favorite_root, _favorite_relative_key(f"concepts/{name}")
)
reference = OutputReference(
"4", 0, "output", "concepts", name, "posix", roots.output_key
)
runs.append(
_record_run(
api,
epoch=epoch,
item=index,
context=context,
output=reference,
observed_at=int(stamp * 1000),
)
)
images.append(
{
"filename": name,
"image_id": image_id,
"pair_id": pair_id,
"prompt": prompt.id,
"variant": variant,
"seed": seed,
"path": f"concepts/{name}",
"width": facts["width"],
"height": facts["height"],
}
context = RecipeContext(
"3", _snapshot(prompt.positive), _snapshot(negative), "inferred"
)
reference = OutputReference(
"4", 0, "output", "concepts", name, "posix", roots.output_key
)
runs.append(
_record_run(
api,
epoch=epoch,
item=index,
context=context,
output=reference,
observed_at=int(stamp * 1000),
)
)
images.append(
{
"filename": name,
"image_id": image_id,
"pair_id": pair_id,
"prompt": prompt.id,
"variant": variant,
"seed": seed,
"path": f"concepts/{name}",
"width": facts["width"],
"height": facts["height"],
}
)
images.reverse()
runs.reverse()
seedless = []
for number in range(seedless_images):
name = f"seedless_{number + 1:05}.png"
+10 -1
View File
@@ -479,8 +479,14 @@ def seed_workspace_history(
canonical_positive, canonical_negative, title="Quality baseline"
)
canonical_id = api.db.get_prompt_pair(canonical_pair)["positive_prompt_id"]
api.db.save_prompt_pair(canonical_positive, "flat light", title="Clean geometry")
clean_pair = api.db.save_prompt_pair(
canonical_positive, "flat light", title="Clean geometry"
)
api.db.save_prompt_pair(canonical_positive, "", title="No negative")
# Queue-hook pair counts (#249 D26): both lanes show the repeat-run dot; only
# the baseline has a recorded execution, so Clean geometry opens Repeat run.
api.db.record_prompt_pair_use(canonical_pair, 2)
api.db.record_prompt_pair_use(clean_pair, 2)
for number, name in enumerate(("canonical.png", "canonical-alt.png")):
path = folder / name
_write_art(path, number, canonical_positive, canonical_negative)
@@ -500,6 +506,8 @@ def seed_workspace_history(
os.utime(path, (stamp, stamp))
image_id = api.db.link_image_to_prompt(canonical_id, str(path))
api.db.link_image_to_pair(canonical_pair, image_id, {"source": "fixture"})
if number:
api.db.link_image_to_pair(clean_pair, image_id, {"source": "fixture"})
canonical_run = run(
(
RecipeContext(
@@ -529,6 +537,7 @@ def seed_workspace_history(
"detail_image_id": current,
"canonical_id": canonical_id,
"canonical_pair": canonical_pair,
"clean_pair": clean_pair,
"canonical_run": canonical_run,
"canonical_positive": canonical_positive,
"canonical_negative": canonical_negative,
@@ -26,6 +26,7 @@ EDITOR_METADATA = {
"notes": "Keep the soft light.",
}
_TABLES = (
"saved_views",
"image_favorites",
"image_prompt_pairs",
"prompt_pair_tags",
@@ -112,6 +112,7 @@ def seed_workspace_visual(api: PromptManagerAPI, root: Path) -> None:
"""Replace only the owned browser harness records and media roots."""
with api.db.model.get_connection() as conn:
for table in (
"saved_views",
"image_favorites",
"image_prompt_pairs",
"prompt_pair_tags",
@@ -79,6 +79,7 @@ UNUSED_TAG = "unused_vocabulary"
MAX_EXTRA_TAGS = 300
MAX_EXTRA_CATEGORIES = 200
_TABLES = (
"saved_views",
"image_favorites",
"image_prompt_pairs",
"prompt_pair_tags",
+1 -1
View File
@@ -25,7 +25,7 @@ test('native library sends the selected saved pair into a real ComfyUI destinati
await workspace.getByRole('searchbox',{name:'Search workspace',exact:true}).fill(positive);
await workspace.locator('article[data-prompt-id]').filter({has:workspace.getByText(positive,{exact:true})}).getByRole('button',{name:'Explore history',exact:true}).click();
const history=workspace.getByRole('region',{name:'Prompt history',exact:true});
await history.getByRole('button',{name:'Select negative variant 1',exact:true}).click();
await history.getByRole('button',{name:'Select negative Host saved pair',exact:true}).click();
await expect(history.getByRole('region',{name:'Saved prompt texts',exact:true}).getByRole('region',{name:'Negative prompt',exact:true})).toContainText(negative);
// The pair send lives in the history heading, as in the approved concept.
await workspace.locator('#workspace-main > .heading').getByRole('button',{name:`Send prompt pair to ComfyUI: ${positive}`,exact:true}).click();
+16
View File
@@ -224,6 +224,22 @@ test("facts use resolution.sampler_id for detailer-stage nodes", () => {
assert.ok(Object.values(factsFor(wide, { resolution: { state: "ambiguous", sampler_id: "d" } })).every(fact => fact.status === "unresolved"), "the stage walk is bounded by MAX_VISITS");
});
test("a shared pair resolved across sampler branches merges facts per field (#249)", () => {
// The server resolves identical branches with no sampler named: settings the
// branches agree on are known, those they differ on (a LoRA per epoch) are ambiguous.
const shared = { resolution: { state: "resolved", sampler_id: null, reason: "Every sampler branch uses the same prompt pair" } };
const grid = factGraph();
grid.lora = { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "epoch_10.safetensors" } };
grid.other = structuredClone(grid.s); grid.other.inputs.model = ["lora", 0];
assert.deepEqual(statusValues(factsFor(grid, shared)), {
seed: ["known", "9007199254740993"], steps: ["known", "28"], cfg: ["known", "6.5000000000000000001"],
sampler: ["known", "dpmpp_2m"], scheduler: ["known", "karras"], model: ["ambiguous", null],
});
assert.ok(Object.values(factsFor(grid, shared)).every(fact => fact.sampler_id === null));
// One listed sampler keeps reading that sampler alone.
assert.equal(factsFor(factGraph(), shared).model.value, "model.safetensors");
});
test("ambiguous results without a sampler_id merge facts across every listed stage", () => {
const ambiguous = { resolution: { state: "ambiguous", sampler_id: null, reason: "Multiple sampler branches" } };
// f01b's shape: an upstream sampler and a detailer whose settings partly differ.
+1 -1
View File
@@ -30,7 +30,7 @@ test("facts guider providers are guiders in the semantics table", () => {
});
test("the facts tables are not part of the legacy page contract", () => {
assert.deepStrictEqual(Object.keys(metadata).filter(key => !key.startsWith("_")).sort(), ["PLACEHOLDERS", "extractGenerationFacts", "extractGenerationParams"]);
assert.deepStrictEqual(Object.keys(metadata).filter(key => !key.startsWith("_")).sort(), ["PLACEHOLDERS", "extractGenerationFacts", "extractGenerationParams", "legacyNegativeSource"]);
assert.ok(Object.isFrozen(metadata._factsTables));
});
+29
View File
@@ -144,6 +144,20 @@ test('a context label names nested prompt regions and actions apart from the fro
}finally{scope.dispose();f.restore();}
});
test('a title names each role action after its prompt as the concept does',()=>{
const f=dom(),scope=createScope(),container=new f.Element('div');
try{
module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},title:'Alpine observatory',prompts:{positive:record('p'),negative:record('n')}});
const actions=f.walk(container,node=>node.tagName==='button').map(node=>node.attributes['aria-label']);
assert.deepEqual(actions,['Copy positive prompt: Alpine observatory','Send positive prompt to ComfyUI: Alpine observatory',
'Copy negative prompt: Alpine observatory','Send negative prompt to ComfyUI: Alpine observatory','Send prompt pair']);
const nested=new f.Element('div');
module.mountPromptBlocks(nested,{parentScope:scope,sender:{open:()=>{}},contextLabel:'Current file',title:'Alpine observatory',includePairAction:false,prompts:{positive:record('p')}});
assert.deepEqual(f.walk(nested,node=>node.tagName==='button').map(node=>node.attributes['aria-label']),
['Copy current file positive prompt: Alpine observatory','Send current file positive prompt to ComfyUI: Alpine observatory']);
}finally{scope.dispose();f.restore();}
});
test('a title names each action after its prompt (concept "Copy positive prompt: <title>") while regions keep their names',()=>{
const f=dom(),scope=createScope(),container=new f.Element('div');
try{
@@ -152,6 +166,21 @@ test('a title names each action after its prompt (concept "Copy positive prompt:
assert.deepEqual(labels,['Positive prompt','Copy positive prompt: Alpine observatory','Send positive prompt to ComfyUI: Alpine observatory']);
}finally{scope.dispose();f.restore();}
});
test('setTitle renames the actions in place and keeps the last prompts and state',()=>{
const f=dom(),scope=createScope(),container=new f.Element('div');
try{
const view=module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},title:'Old name',roles:['positive'],prompts:{positive:record('p')}});
view.setState({prompts:{positive:record('kept')},actionsEnabled:false});
const labels=()=>f.walk(container,node=>node.attributes['aria-label']).map(node=>node.attributes['aria-label']);
const rendered=container.children[0];view.setTitle('Old name');assert.equal(container.children[0],rendered,'an unchanged title does not re-render');
view.setTitle('New name');
assert.deepEqual(labels(),['Positive prompt','Copy positive prompt: New name','Send positive prompt to ComfyUI: New name']);
assert.ok(f.walk(container,node=>node.textContent==='kept').length);
assert.ok(f.walk(container,node=>node.dataset.copyRole==='positive').every(node=>node.disabled===true));
view.setTitle('');assert.deepEqual(labels(),['Positive prompt','Copy positive prompt','Send positive prompt to ComfyUI']);
scope.dispose();view.setTitle('After dispose');
}finally{scope.dispose();f.restore();}
});
const shape=node=>({tag:node.tagName,className:node.className,...(node.attributes['aria-label']?{label:node.attributes['aria-label']}:{}),
...(node.children.length?{children:node.children.filter(child=>child.tagName!=='svg').map(shape)}:{text:node.textContent})});
test('prompt block emits concept markup without a source line',()=>{
+371
View File
@@ -0,0 +1,371 @@
// Activity (#249 Activity-1): the session job store behind the top-bar button and pill and the Activity page.
const test = require('node:test');
const assert = require('node:assert/strict');
const source = import('../../web/js/workspace/activity-store.mjs');
const ID_A = 'a'.repeat(32);
const ID_B = 'b'.repeat(32);
const ID_C = 'c'.repeat(32);
function summary(overrides = {}) {
return { id: ID_A, kind: 'scan', title: 'Scan for metadata', state: 'running', started_at: 1000, finished_at: null,
progress: 0, status_line: '', processed: 0, total: 0,
counters: { added: 0, known: 0, review: 0, no_metadata: 0, errors: 0 }, scope: { files: 'new', output_folders: 2 },
...overrides };
}
function apiError(status, message, code) {
return Object.assign(new Error(message), { status, code });
}
/** A fake world: the API's answers, every opened stream, the pending timers and the toasts. */
function harness() {
const world = { list: [], listError: null, posts: [], postResult: null, postError: null, streams: [], timers: [],
toasts: [], gets: 0 };
const cleanups = [];
const controller = new AbortController();
const scope = { signal: controller.signal, cleanup: fn => cleanups.push(fn) };
const api = {
async get(url, options) {
assert.equal(url, '/prompt_manager/jobs');
assert.equal(options.cache, 'no-store');
world.gets += 1;
if (world.listError) throw world.listError;
return { success: true, jobs: world.list };
},
async post(url, body) {
world.posts.push([url, body]);
if (world.postError) throw world.postError;
return world.postResult;
},
};
const connect = url => {
const stream = { url, closed: false, onmessage: null, onerror: null, close() { this.closed = true; } };
world.streams.push(stream);
return stream;
};
const timers = {
setTimeout(fn, delay) { const timer = { fn, delay, cleared: false }; world.timers.push(timer); return timer; },
clearTimeout(timer) { timer.cleared = true; },
};
world.dispose = () => { controller.abort(); cleanups.forEach(fn => fn()); };
world.fire = async () => {
const timer = world.timers.find(item => !item.cleared && !item.fired);
timer.fired = true;
timer.fn();
await new Promise(resolve => setImmediate(resolve));
return timer.delay;
};
world.open = () => world.streams.filter(stream => !stream.closed);
return { world, api, scope, connect, timers, pollMs: 0, notify: message => world.toasts.push(message) };
}
const send = (stream, job) => stream.onmessage({ data: JSON.stringify(job) });
test('store merges list and SSE updates and caps backoff', async () => {
const { createActivityStore, BACKOFF_MAX_MS, LOST_DETAIL } = await source;
const { world, ...deps } = harness();
world.list = [summary(), summary({ id: ID_B, state: 'finished', finished_at: 900, counters: { added: 3, errors: 0 } })];
const store = createActivityStore(deps);
let changes = 0;
store.subscribe(() => { changes += 1; });
await store.load();
assert.equal(store.status, 'ready');
assert.deepEqual(store.jobs().map(job => [job.id, job.state]), [[ID_A, 'running'], [ID_B, 'finished']]);
assert.deepEqual(store.running().map(job => job.id), [ID_A]);
// One stream per running job only; a finished job is not followed.
assert.deepEqual(world.streams.map(stream => stream.url), [`/prompt_manager/jobs/${ID_A}/events`]);
assert.ok(changes >= 1);
// An SSE summary replaces the listed one; garbage and another job's summary are ignored.
const [stream] = world.streams;
send(stream, summary({ progress: 42, processed: 8240, total: 19622 }));
stream.onmessage({ data: 'not json' });
send(stream, summary({ id: ID_B, progress: 99 }));
assert.equal(store.find(ID_A).progress, 42);
assert.equal(store.find(ID_B).state, 'finished');
assert.deepEqual(world.toasts, []);
// The end of the job: the finish toast, and the stream is closed.
send(stream, summary({ state: 'finished', progress: 100, finished_at: 2000, counters: { added: 1420, errors: 18 } }));
assert.deepEqual(world.toasts, ['Scan finished · 1,420 added · 18 errors']);
assert.equal(stream.closed, true);
assert.deepEqual(store.running(), []);
// A new running job from another tab, then the server goes away: every re-read waits longer, up to 30 s.
world.list = [summary({ id: ID_C, state: 'running' })];
await store.load();
const second = world.open()[0];
assert.equal(second.url, `/prompt_manager/jobs/${ID_C}/events`);
world.listError = new Error('connection refused');
second.onerror();
assert.equal(second.closed, true);
const delays = [];
for (let index = 0; index < 8; index += 1) delays.push(await world.fire());
assert.deepEqual(delays, [1000, 2000, 4000, 8000, 16000, 30000, 30000, 30000]);
assert.ok(delays.every(delay => delay <= BACKOFF_MAX_MS));
// While the server cannot be read the running job shows as ended, so no pill stays.
assert.equal(store.status, 'error');
assert.equal(store.find(ID_C).state, 'lost');
assert.deepEqual(store.running(), []);
assert.equal(typeof LOST_DETAIL, 'string');
// The server is back without the job (a restart): it stays ended; finished jobs the server no longer lists are
// dropped, so the list never outgrows the server's (review M3).
world.listError = null;
world.list = [];
await world.fire();
assert.equal(store.status, 'ready');
assert.deepEqual(store.jobs().map(job => [job.id, job.state]), [[ID_C, 'lost']]);
assert.equal(world.timers.filter(timer => !timer.cleared && !timer.fired).length, 0, 'no retry after a good read');
assert.equal(world.toasts.length, 1, 'an ended job has no finish toast');
world.dispose();
});
test('a dropped stream of a still running job reattaches after the backoff, then resets it', async () => {
const { createActivityStore } = await source;
const { world, ...deps } = harness();
world.list = [summary()];
const store = createActivityStore(deps);
await store.load();
world.streams[0].onerror();
assert.equal(await world.fire(), 1000);
const again = world.open();
assert.equal(again.length, 1);
assert.equal(store.find(ID_A).state, 'running');
again[0].onerror();
assert.equal(await world.fire(), 2000);
send(world.open()[0], summary({ progress: 5 }));
world.open()[0].onerror();
assert.equal(await world.fire(), 1000, 'a received message resets the backoff');
// A second drop while a re-read is already scheduled does not schedule another.
world.open()[0].onerror();
store.load();
assert.equal(world.timers.filter(timer => !timer.cleared && !timer.fired).length, 1);
await new Promise(resolve => setImmediate(resolve));
world.dispose();
assert.ok(world.streams.every(stream => stream.closed), 'dispose closes every stream');
assert.ok(world.timers.every(timer => timer.cleared || timer.fired), 'dispose clears the pending re-read');
});
test('start returns busy with the running job', async () => {
const { createActivityStore } = await source;
const { world, ...deps } = harness();
const store = createActivityStore(deps);
world.postResult = { success: true, job: summary() };
const started = await store.start('scan');
assert.equal(started.job.id, ID_A);
assert.deepEqual(world.posts, [['/prompt_manager/jobs/scan', {}]]);
assert.deepEqual(world.toasts, ['Scan started.']);
assert.equal(world.open().length, 1, 'the started job is followed');
world.postError = apiError(409, 'Scan for metadata is already running', 'busy');
world.list = [summary()];
const busy = await store.start('scan');
assert.equal(busy.busy.id, ID_A);
assert.equal(busy.busy.title, 'Scan for metadata');
assert.equal(busy.message, 'Scan for metadata is already running');
assert.equal(world.toasts.length, 1, 'a refused start has no start toast');
world.postError = apiError(500, 'boom', null);
await assert.rejects(store.start('scan'), /boom/);
world.postError = null;
world.postResult = { success: true, job: { id: 'nope' } };
await assert.rejects(store.start('scan'), /Invalid job/);
world.postResult = { success: true, cancellation_requested: true };
assert.equal(await store.cancel(ID_A), true);
assert.deepEqual(world.posts.at(-1), [`/prompt_manager/jobs/${ID_A}/cancel`, {}]);
world.postResult = { success: true, cancellation_requested: false };
assert.equal(await store.cancel(ID_A), false);
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();
const store = createActivityStore({ ...deps, api: { ...api, get: async () => ({ success: true }) } });
await store.load();
assert.equal(store.status, 'error');
assert.equal(world.timers.length, 1);
world.dispose();
await store.load();
assert.equal(store.status, 'error', 'a disposed store reads nothing');
});
test('a visible tab reads the list again every 15 s; a hidden one skips its turn', async () => {
const { createActivityStore, POLL_MS } = await source;
const { world, ...deps } = harness();
let visible = true;
const store = createActivityStore({ ...deps, pollMs: POLL_MS, isVisible: () => visible });
await store.load();
assert.equal(world.gets, 1);
// A job started in another tab appears on the next read.
world.list = [summary()];
assert.equal(await world.fire(), 15000);
assert.equal(world.gets, 2);
assert.deepEqual(store.running().map(job => job.id), [ID_A]);
visible = false;
assert.equal(await world.fire(), 15000);
assert.equal(world.gets, 2, 'a hidden tab does not read');
visible = true;
await world.fire();
assert.equal(world.gets, 3);
// A manual read replaces the pending poll rather than adding one.
await store.load();
assert.equal(world.timers.filter(timer => !timer.cleared && !timer.fired).length, 1);
world.dispose();
assert.ok(world.timers.every(timer => timer.cleared || timer.fired));
});
test('a late or older answer never brings an ended job back, so it is announced once', async () => {
const { createActivityStore } = await source;
const { world, api, ...deps } = harness();
const answers = [];
const slowApi = { ...api, get: () => new Promise(resolve => answers.push(resolve)) };
const store = createActivityStore({ ...deps, api: slowApi });
const first = store.load();
const second = store.load();
// The newer read answers first; the older one, answering later with stale data, is dropped.
answers[1]({ success: true, jobs: [summary()] });
await second;
answers[0]({ success: true, jobs: [] });
await first;
assert.deepEqual(store.jobs().map(job => job.state), ['running']);
send(world.open()[0], summary({ state: 'finished', counters: { added: 2, errors: 0 } }));
assert.deepEqual(world.toasts, ['Scan finished · 2 added · 0 errors']);
// A list read sent before the end still says running: the job stays finished and no stream reopens.
const third = store.load();
answers[2]({ success: true, jobs: [summary()] });
await third;
assert.equal(store.find(ID_A).state, 'finished');
assert.equal(world.open().length, 0);
assert.equal(world.toasts.length, 1);
// A stale failure is dropped too.
const failing = store.load();
const newest = store.load();
answers[4]({ success: true, jobs: [] });
await newest;
answers[3](Promise.reject(new Error('late failure')));
await failing;
assert.equal(store.status, 'ready');
world.dispose();
});
test('summaries are validated and the copy follows the spec', async () => {
const { parseJob, finishToast, startToast, pillParts, outcomeLine, backoffDelay } = await source;
for (const raw of [null, [], { id: 'x', state: 'running' }, summary({ state: 'lost' }), summary({ id: ID_A.toUpperCase() })]) {
assert.equal(parseJob(raw), null);
}
const loose = parseJob({ id: ID_A, state: 'running', progress: 140.4, processed: -1, total: 1.5, counters: { added: 'x', errors: 2 },
title: ' ', started_at: 'x', scope: [] });
assert.deepEqual(loose, { id: ID_A, kind: '', title: 'Background job', state: 'running', started_at: null, finished_at: null,
progress: 100, status_line: '', processed: 0, total: 0, counters: { added: 0, errors: 2 }, scope: {}, listed: {}, log_length: 0 });
assert.equal(parseJob(summary({ progress: -3 })).progress, 0);
const scan = parseJob(summary({ progress: 42, processed: 8240, total: 19622, counters: { added: 1, errors: 1 } }));
assert.deepEqual(pillParts(scan), { label: 'Scanning · 42%', files: ' · 8,240 / 19,622', text: 'Scanning · 42% · 8,240 / 19,622' });
assert.equal(pillParts({ ...scan, total: 0 }).text, 'Scanning · 42%');
assert.equal(pillParts({ ...scan, kind: 'thumbnails', title: 'Thumbnails' }).text, 'Thumbnails · 42% · 8,240 / 19,622');
assert.equal(finishToast({ ...scan, state: 'finished' }), 'Scan finished · 1 added · 1 error');
assert.equal(finishToast({ ...scan, state: 'cancelled' }), 'Scan cancelled · 1 added');
assert.equal(finishToast({ ...scan, state: 'failed' }), 'Scan failed · 1 added · 1 error');
assert.equal(finishToast({ ...scan, counters: {}, state: 'finished' }), 'Scan finished · 0 added · 0 errors');
const other = { ...scan, kind: 'backup', title: 'Backup' };
assert.equal(finishToast({ ...other, state: 'finished' }), 'Backup finished.');
assert.equal(finishToast({ ...other, state: 'cancelled' }), 'Backup was cancelled.');
assert.equal(finishToast({ ...other, state: 'failed' }), 'Backup failed.');
assert.equal(startToast(scan), 'Scan started.');
assert.equal(startToast(other), 'Backup started.');
assert.equal(outcomeLine(scan), '1 added · 1 error');
assert.equal(outcomeLine(other), '');
assert.deepEqual([-1, 0, 1, 4, 5, 6, 1e9, 0.5].map(backoffDelay), [1000, 1000, 2000, 16000, 30000, 30000, 30000, 1000]);
});
test('pagehide stops polling, closes streams and aborts the in-flight read; pageshow resumes', async () => {
const { createActivityStore } = await source;
const { world, api, ...deps } = harness();
const lifecycle = new EventTarget();
const signals = [];
let answer = null;
const pendingApi = { ...api, get: (url, options) => {
signals.push(options.signal);
world.gets += 1;
if (world.gets === 1) return Promise.resolve({ success: true, jobs: [summary()] });
return new Promise((resolve, reject) => {
answer = resolve;
options.signal.addEventListener('abort', () => reject(Object.assign(new Error('aborted'), { name: 'AbortError' })));
});
} };
const store = createActivityStore({ ...deps, api: pendingApi, pollMs: 15000, lifecycle });
await store.load();
assert.equal(world.open().length, 1);
const poll = world.timers.find(timer => !timer.cleared && timer.delay === 15000);
assert.ok(poll, 'a visible tab polls');
// A read in flight when the page goes away (a closed tab, a test teardown) is aborted, nothing is scheduled again.
const read = store.load();
lifecycle.dispatchEvent(new Event('pagehide'));
await read;
assert.equal(signals.at(-1).aborted, true);
assert.equal(world.open().length, 0);
assert.ok(world.timers.every(timer => timer.cleared || timer.fired));
assert.equal(store.find(ID_A).state, 'running', 'a hidden page marks nothing lost');
await store.load();
assert.equal(world.gets, 2, 'a suspended store reads nothing');
// Back from the cache, it reads the list again and follows the job.
const resumed = Object.assign(new Event('pageshow'), { persisted: true });
lifecycle.dispatchEvent(resumed);
await new Promise(resolve => setImmediate(resolve));
assert.equal(world.gets, 3);
answer({ success: true, jobs: [summary()] });
await new Promise(resolve => setImmediate(resolve));
assert.equal(world.open().length, 1);
world.dispose();
assert.equal(signals.at(-1).aborted, true, 'disposing aborts the store reads');
assert.equal(world.open().length, 0);
});
test('one failed read is not a restart; two in a row are, and a good read resets the backoff', async () => {
const { createActivityStore } = await source;
const { world, ...deps } = harness();
world.list = [summary(), summary({ id: ID_B, state: 'finished', finished_at: 900 })];
const store = createActivityStore(deps);
await store.load();
// A single failed read (a proxy's 502) keeps the job running and its stream open (review M1).
world.listError = apiError(502, 'Bad gateway', null);
await store.load();
assert.equal(store.status, 'error');
assert.equal(store.find(ID_A).state, 'running');
assert.equal(world.open().length, 1);
assert.equal(await world.fire(), 1000);
// The second failure in a row: the server is gone, so the running job ended with it.
assert.equal(store.find(ID_A).state, 'lost');
assert.equal(world.open().length, 0);
for (let index = 0; index < 3; index += 1) await world.fire();
// The server is back with a new running job: the backoff starts again at 1 s (review M2).
world.listError = null;
world.list = [summary({ id: ID_C })];
await world.fire();
assert.equal(store.status, 'ready');
assert.deepEqual(store.jobs().map(job => [job.id, job.state]), [[ID_C, 'running'], [ID_A, 'lost']]);
world.open()[0].onerror();
assert.equal(await world.fire(), 1000);
// A summary carries what its detail lists, for the job view's reads (review M12).
send(world.open()[0], summary({ id: ID_C, listed: { added: 3, errors: -1 }, log_length: 7 }));
assert.deepEqual([store.find(ID_C).listed, store.find(ID_C).log_length], [{ added: 3, errors: 0 }, 7]);
world.dispose();
});
test('numbers group the way the server writes them, whatever the browser locale', async () => {
const { pillParts, finishToast, parseJob } = await source;
const original = Number.prototype.toLocaleString;
Number.prototype.toLocaleString = function (locale, ...rest) { return original.call(this, locale ?? 'de-DE', ...rest); };
try {
const job = parseJob(summary({ progress: 41, processed: 8240, total: 19622, counters: { added: 1420, errors: 18 } }));
assert.equal(pillParts(job).text, 'Scanning · 41% · 8,240 / 19,622');
assert.equal(finishToast({ ...job, state: 'finished' }), 'Scan finished · 1,420 added · 18 errors');
} finally {
Number.prototype.toLocaleString = original;
}
});
+22 -1
View File
@@ -5,7 +5,7 @@ const modulePath='../../web/js/workspace/file-inspector.mjs';
const load=()=>import(modulePath);
const rawWorkflow='{ "nodes": [], "seed": 9007199254740993, "cfg": 1.0000000000000000001 }';
function record() {
return {identity:{kind:'rooted-file',key:'2:a.png',root_index:2,path:'a.png',root_scope:'a'.repeat(64)},file:{format:'PNG'},prompts:{positive:{status:'resolved',text:'a\n猫'}},generation:{seed:{status:'known',value:'9007199254740993',source:'workflow',evidence:'configuration'}},library:{metadata_scope:'pair',tags:['saved']},download:{filename:'a.png',url:'/prompt_manager/images/serve/a.png?root=2'},metadata:{status:'available',omitted_reason:null,raw:{workflow:rawWorkflow,prompt:'{broken',parameters:'literal <svg> </pre>'},record_formats:{workflow:'original-text',prompt:'original-text',parameters:'original-text'},workflow_graph:{status:'parsed',value:{seed:'9007199254740993'}},prompt_graph:{status:'malformed',value:null},parameters:'literal <svg> </pre>'}};
return {identity:{kind:'rooted-file',key:'2:a.png',root_index:2,path:'a.png',root_scope:'a'.repeat(64)},file:{format:'PNG'},prompts:{positive:{status:'resolved',text:'a\n猫'}},generation:{seed:{status:'known',value:'9007199254740993',source:'workflow',evidence:'configuration'}},library:{category_scope:'pair',tags_scope:'pair',tags:['saved']},download:{filename:'a.png',url:'/prompt_manager/images/serve/a.png?root=2'},metadata:{status:'available',omitted_reason:null,raw:{workflow:rawWorkflow,prompt:'{broken',parameters:'literal <svg> </pre>'},record_formats:{workflow:'original-text',prompt:'original-text',parameters:'original-text'},workflow_graph:{status:'parsed',value:{seed:'9007199254740993'}},prompt_graph:{status:'malformed',value:null},parameters:'literal <svg> </pre>'}};
}
test('metadata export retains literal raw and scoped evidence',async()=>{
@@ -65,3 +65,24 @@ test('limited workflow record exports its representation and disables workflow d
assert.equal(downloads.workflow.available,false);assert.equal(downloads.workflow.bodyText,null);
assert.equal(downloads.workflow.reason,'Workflow exceeds inspection limits. Download the original file to inspect it externally.');
});
test('Show in folder needs the full listing identity (D38)',async()=>{
const {revealTarget}=await load(),scope='a'.repeat(64);
assert.deepEqual(revealTarget(record().identity),{root_index:2,root_scope:scope,path:'a.png'});
for(const identity of [
{kind:'rooted-file',root_index:2,path:'a.png',root_scope:null},
{kind:'rooted-file',root_index:'2',path:'a.png',root_scope:scope},
{kind:'rooted-file',root_index:2,path:'',root_scope:scope},
{kind:'reported-location',path:'folder/a.png',current_scope:scope},
null,
])assert.equal(revealTarget(identity),null);
});
test('Show in folder outcomes use the D38 toasts',async()=>{
const {revealOutcome}=await load();
assert.equal(revealOutcome(null),'Opened the folder on this computer.');
assert.equal(revealOutcome({status:403,code:'not-local'}),'Show in folder works only in a browser on the computer running ComfyUI.');
assert.equal(revealOutcome({status:501,code:'unavailable'}),'No file manager is available on this computer.');
assert.equal(revealOutcome({status:429,code:'busy'}),'The folder is already opening.');
for(const error of [{status:403,code:null},{status:409,code:'source-changed'},{status:415,code:'unsupported-media-type'},{status:429,code:null},{status:500},new TypeError('offline')])
assert.equal(revealOutcome(error),'The folder could not be opened. Try again.');
});
+1 -1
View File
@@ -8,7 +8,7 @@ const m=await import('../../web/js/workspace/history-media.mjs').catch(e=>{if(e.
const epoch='11111111-1111-1111-1111-111111111111',run='22222222-2222-2222-2222-222222222222',scope='a'.repeat(64),location='b'.repeat(64);
function media(node='1',ordinal=0,key=location) {const occurrence={run_id:run,output_node_id:node,image_ordinal:ordinal},reference={folder_type:'output',subfolder:'folder',filename:'a #%.png',path_style:'posix',root_key:'c'.repeat(64)},base=`/prompt_manager/history/runs/${run}/output/current`,query=new URLSearchParams({node,ordinal:String(ordinal),epoch,current_scope:scope});return {kind:'reported-location',occurrence,reference,history_epoch:epoch,current_scope:scope,location_key:key,filename:reference.filename,is_video:false,url:`${base}/media?${query}`,inspection_url:`${base}/inspection?${query}`,thumbnail_url:`${base}/thumbnail?${query}`};}
const receipt=value=>({occurrence:value.occurrence,reference:value.reference,file_identity:'unverified',newly_generated:'unknown',context_membership:'unknown',transient:false,current_candidate:{status:'available',reason:null,evidence_class:'locator_candidate',current_scope:value.current_scope,location_key:value.location_key,relative_path:'folder/a #%.png',source_label:'output',gallery_match:'none',gallery_image_id:null,media:value}});
function envelope(target=media()) {return {success:true,inspection_version:1,history_version:1,history_epoch:epoch,current_output:{occurrence:target.occurrence,reference:target.reference,history_epoch:epoch,current_scope:scope,location_key:target.location_key,evidence_class:'locator_candidate',observation:'current-file',file_identity:'unverified',newly_generated:'unknown',context_membership:'unknown',transient:false},prompts:{positive:{status:'resolved',text:'current metadata only',source:'execution'},negative:{status:'unresolved',text:null,source:null}},file:{observation:'current-file',source_label:'output',media_type:'image',extension:'.png',format:'PNG',size:10,width:2,height:3,modified_time:1},metadata:{status:'absent',omitted_reason:null,raw:{},record_formats:{},prompt_graph:{status:'absent',value:null},workflow_graph:{status:'absent',value:null},parameters:null},resolution:{state:'absent',sampler_id:null,reason:'no graph'},library:{status:'none',scope:'saved-library',tags:[],metadata_scope:null,positive_id:null,pair_id:null,title:null,category:null}};}
function envelope(target=media()) {return {success:true,inspection_version:1,history_version:1,history_epoch:epoch,current_output:{occurrence:target.occurrence,reference:target.reference,history_epoch:epoch,current_scope:scope,location_key:target.location_key,evidence_class:'locator_candidate',observation:'current-file',file_identity:'unverified',newly_generated:'unknown',context_membership:'unknown',transient:false},prompts:{positive:{status:'resolved',text:'current metadata only',source:'execution'},negative:{status:'unresolved',text:null,source:null}},file:{observation:'current-file',source_label:'output',media_type:'image',extension:'.png',format:'PNG',size:10,width:2,height:3,modified_time:1},metadata:{status:'absent',omitted_reason:null,raw:{},record_formats:{},prompt_graph:{status:'absent',value:null},workflow_graph:{status:'absent',value:null},parameters:null},resolution:{state:'absent',sampler_id:null,reason:'no graph'},library:{status:'none',scope:'saved-library',tags:[],category_scope:null,tags_scope:null,positive_id:null,pair_id:null,title:null,category:null}};}
function adapter(){assert.ok(m.reportedLocationAdapter,'reported location adapter is exported');return m.reportedLocationAdapter;}
test('reported location uses explicit occurrence routes and never widens rooted defaults',()=>{const target=media(),a=adapter();assert.doesNotThrow(()=>a.validate(target));assert.equal(a.key(target),location);assert.equal(a.scope(target),`${epoch}:${scope}`);assert.equal(a.inspectionPath(target),target.inspection_url);assert.deepEqual(a.originalDownload(target),{url:target.url,filename:'a #%.png'});assert.throws(()=>originalMediaDownload(target),TypeError);assert.throws(()=>createViewerSession({items:[target],initialKey:location}),TypeError);});
+14
View File
@@ -276,3 +276,17 @@ test('a resumed page success or error cannot replace a later run choice',async()
await p;assert.equal(c.state.selection.run_id,id(41));assert.equal(c.state.window.items.length,40);assert.equal(c.state.refreshRequired,null);assert.equal(c.state.actionsEnabled,true);c.dispose();
}
});
test('added terms split, trim and exact-diff like the concept',()=>{
// The concept's rule (app.js a600-614): split on commas, trim, drop empty terms, keep what the baseline lacks exactly.
assert.equal(typeof m.addedTerms,'function','addedTerms is exported');
assert.deepEqual(m.addedTerms('blurry, lowres','lowres, watermark , text'),['watermark','text']);
assert.deepEqual(m.addedTerms('',''),[]);assert.deepEqual(m.addedTerms('blurry',''),[]);assert.deepEqual(m.addedTerms('','blurry'),['blurry']);
assert.deepEqual(m.addedTerms('a','a, ,\n, b\t'),['b']);
// Duplicates in the selected negative stay, as the concept lists every added term in order.
assert.deepEqual(m.addedTerms('a','c, c, a'),['c','c']);
// Case and inner whitespace are exact.
assert.deepEqual(m.addedTerms('Blurry, low res','blurry, Blurry, low res'),['blurry','low res']);
assert.deepEqual(m.addedTerms('same, text','text, same'),[]);
assert.throws(()=>m.addedTerms(null,'a'),TypeError);assert.throws(()=>m.addedTerms('a',undefined),TypeError);
});
+1 -1
View File
@@ -33,7 +33,7 @@ test('the outcome toast counts the prompts and the skipped images', async () =>
test('the dialog and failure copy is the plan\'s', async () => {
const {CATEGORY_DIALOG, CATEGORY_NONE_LINKED, CATEGORY_FAILED} = await source;
assert.deepEqual(CATEGORY_DIALOG, {title:'Categorize selected images', label:'Category', action:'Apply'});
assert.equal(CATEGORY_NONE_LINKED, 'None of the selected images has a linked prompt.');
assert.equal(CATEGORY_NONE_LINKED, "None of the selected images is linked to a saved prompt, so there is no prompt category to set.");
assert.equal(CATEGORY_FAILED, 'Category could not be set. Try again.');
assert.ok(Object.isFrozen(CATEGORY_DIALOG));
});
+11
View File
@@ -128,6 +128,17 @@ test('all-source defaults are omitted and descending name sort remains supported
await stream.reset({root:'all',media:'all',sort:'name-desc'});
assert.equal(query.has('root'),false);assert.equal(query.has('media'),false);
assert.equal(query.get('sort'),'name-desc');assert.equal(query.get('limit'),'40');
assert.equal(query.has('favorites'),false);
});
test('the Favorites page asks the listing for favorites only (#249 D51)', async () => {
const {createImageStream}=await source;let query;
const stream=createImageStream({api:{get:async url=>{query=new URL(url,'http://local').searchParams;return page([]);}}});
await stream.reset({favorites:true,q:'fog'});
assert.equal(query.get('favorites'),'1');assert.equal(query.get('q'),'fog');
await stream.reset({favorites:false});
assert.equal(query.has('favorites'),false);
for(const favorites of ['1',1,'true'])await assert.rejects(stream.reset({favorites}),/Invalid favorites filter/);
});
test('restored browsing state resumes its cursor and filters without an initial request', async () => {
+135 -1
View File
@@ -27,7 +27,7 @@ function envelope(entry = image()) {
workflow_graph: { status: 'absent', value: null }, parameters: null,
},
resolution: { state: 'ambiguous', sampler_id: null, reason: 'unknown negative' },
library: { status: 'linked', scope: 'saved-library', positive_id: 7, pair_id: 8, title: 'Recipe', category: 'Subject', tags: ['cat'], metadata_scope: 'pair' },
library: { status: 'linked', scope: 'saved-library', positive_id: 7, pair_id: 8, title: 'Recipe', category: 'Subject', tags: ['cat'], category_scope: 'pair', tags_scope: 'pair' },
};
}
function pendingApi() {
@@ -276,6 +276,10 @@ test('record rejects root path media and invalid versioned fields with retryable
data => { data.file.size = -1; }, data => { data.library.positive_id = 9007199254740992; },
data => { data.metadata.prompt_graph = { status: 'limited', value: {} }; },
data => { data.resolution.sampler_id = {}; },
data => { data.library.tags_scope = 'recipe'; },
data => { data.library.category_scope = 'recipe'; },
data => { delete data.library.category_scope; },
data => { delete data.library.tags_scope; },
]) {
const data = envelope(); mutate(data);
assert.throws(() => toRecord(data, image(), extractGenerationFacts), error => error instanceof TypeError && !error.refreshRequired);
@@ -534,6 +538,136 @@ test('a definite server prompt is kept even when the legacy parser reads it diff
assert.equal(record.prompts.positive.text, 'a lighthouse at dusk, oil painting');
});
// #249: a LoRA compare grid. Every sampler's negative is a ConditioningZeroOut of the
// positive encode; the legacy parser follows the zero-out back to the positive text.
function zeroedGridGraph(samplers = 3) {
const graph = {
1: { class_type: 'UNETLoader', inputs: { unet_name: 'base.safetensors', weight_dtype: 'default' } },
2: { class_type: 'CLIPLoader', inputs: { clip_name: 'te.safetensors', type: 'stable_diffusion' } },
4: { class_type: 'CLIPTextEncode', inputs: { text: 'a woman holding a coffee cup', clip: ['2', 0] } },
5: { class_type: 'ConditioningZeroOut', inputs: { conditioning: ['4', 0] } },
6: { class_type: 'EmptyLatentImage', inputs: { width: 512, height: 512, batch_size: 1 } },
};
for (let index = 0; index < samplers; index++) {
graph[`1${index}`] = { class_type: 'LoraLoaderModelOnly', inputs: { lora_name: `epoch_${index}.safetensors`, strength_model: 1, model: ['1', 0] } };
graph[`2${index}`] = { class_type: 'KSampler', inputs: { seed: 42, steps: 8, cfg: 1, sampler_name: 'euler', scheduler: 'simple', denoise: 1,
model: [`1${index}`, 0], positive: ['4', 0], negative: ['5', 0], latent_image: ['6', 0] } };
}
return graph;
}
function undecided(graph) {
const entry = image(), data = envelope(entry);
data.prompts = { positive: { status: 'ambiguous', text: null, source: null }, negative: { status: 'ambiguous', text: null, source: null } };
data.metadata.prompt_graph = { status: 'parsed', value: graph };
data.resolution = { state: 'ambiguous', sampler_id: null, reason: 'Multiple sampler branches' };
return contract('toInspectionRecord')(data, entry, extractGenerationFacts);
}
test('a zeroed negative is never filled with the positive text from the legacy parse (#249)', t => {
const parser = require('../../web/js/comfy-metadata.js');
globalThis.ComfyMetadata = parser;
t.after(() => { delete globalThis.ComfyMetadata; });
// The legacy pages keep their reading: the zero-out passes the positive through.
assert.equal(parser.extractGenerationParams({ prompt: zeroedGridGraph() }).negativePrompt, 'a woman holding a coffee cup');
const record = undecided(zeroedGridGraph());
assert.deepEqual(record.prompts.positive, { status: 'resolved', text: 'a woman holding a coffee cup', source: 'graph-inferred' });
assert.deepEqual(record.prompts.negative, { status: 'resolved', text: '', source: 'zeroed-conditioning' });
// Behind an API-graph Reroute the legacy parse reads no negative, so there is nothing
// to correct: the server's answer stays.
const rerouted = zeroedGridGraph(1);
rerouted[7] = { class_type: 'Reroute', inputs: { value: ['5', 0] } };
rerouted[20].inputs.negative = ['7', 0];
assert.equal(parser.legacyNegativeSource({ prompt: rerouted }), null);
assert.deepEqual(undecided(rerouted).prompts.negative, { status: 'ambiguous', text: null, source: null });
});
test('the zero-out check follows the sampler the legacy parse read (#249)', t => {
const parser = require('../../web/js/comfy-metadata.js');
globalThis.ComfyMetadata = parser;
t.after(() => { delete globalThis.ComfyMetadata; });
const zeroed = { status: 'resolved', text: '', source: 'zeroed-conditioning' };
// The legacy parse reads the first sampler (20): its negative is the zero-out.
const mixed = zeroedGridGraph(2);
mixed[8] = { class_type: 'CLIPTextEncode', inputs: { text: 'blurry', clip: ['2', 0] } };
mixed[21].inputs.negative = ['8', 0];
assert.deepEqual(undecided(mixed).prompts.negative, zeroed);
// Reversed: the sampler it reads encodes "blurry", which it showed before and still does.
const reversed = zeroedGridGraph(2);
reversed[8] = { class_type: 'CLIPTextEncode', inputs: { text: 'blurry', clip: ['2', 0] } };
reversed[20].inputs.negative = ['8', 0];
assert.equal(parser.extractGenerationParams({ prompt: reversed }).negativePrompt, 'blurry');
assert.deepEqual(undecided(reversed).prompts.negative, { status: 'resolved', text: 'blurry', source: 'graph-inferred' });
// The positive fills from the legacy parse in both cases.
assert.equal(undecided(mixed).prompts.positive.text, 'a woman holding a coffee cup');
assert.equal(undecided(reversed).prompts.positive.text, 'a woman holding a coffee cup');
});
test('a zero-out mixed into the read negative leaves it as the server left it (#249)', t => {
globalThis.ComfyMetadata = require('../../web/js/comfy-metadata.js');
t.after(() => { delete globalThis.ComfyMetadata; });
// A zero-out combined with another conditioning is not an empty negative.
const combined = zeroedGridGraph(1);
combined[8] = { class_type: 'CLIPTextEncode', inputs: { text: 'blurry', clip: ['2', 0] } };
combined[9] = { class_type: 'ConditioningCombine', inputs: { conditioning_1: ['5', 0], conditioning_2: ['8', 0] } };
combined[20].inputs.negative = ['9', 0];
assert.deepEqual(undecided(combined).prompts.negative, { status: 'ambiguous', text: null, source: null });
});
function workflowOnly(workflow) {
const entry = image(), data = envelope(entry);
data.prompts = { positive: { status: 'unresolved', text: null, source: null }, negative: { status: 'unresolved', text: null, source: null } };
Object.assign(data.metadata, { raw: { workflow: JSON.stringify(workflow) }, record_formats: { workflow: 'reserialized-json' },
prompt_graph: { status: 'absent', value: null }, workflow_graph: { status: 'parsed', value: workflow } });
return contract('toInspectionRecord')(data, entry, extractGenerationFacts);
}
// A UI workflow: encode 4 -> zero-out 5 -> sampler 3's negative, encode 4 -> positive.
function zeroedWorkflow({ zeroMode = 0, extraNodes = [], extraLinks = [], negativeLink = 3 } = {}) {
return { nodes: [
{ id: 4, type: 'CLIPTextEncode', inputs: [], outputs: [{ links: [1, 2] }], widgets_values: ['a woman holding a coffee cup'] },
{ id: 5, type: 'ConditioningZeroOut', mode: zeroMode, inputs: [{ name: 'conditioning', type: 'CONDITIONING', link: 1 }], outputs: [{ links: [3] }] },
{ id: 1, type: 'UNETLoader', inputs: [], outputs: [{ links: [4] }], widgets_values: ['base.safetensors', 'default'] },
{ id: 3, type: 'KSampler', inputs: [{ name: 'model', link: 4 }, { name: 'positive', link: 2 }, { name: 'negative', link: negativeLink }], widgets_values: [42, 'fixed', 8, 1, 'euler', 'simple', 1] },
...extraNodes,
], links: [[1, 4, 0, 5, 0, 'CONDITIONING'], [2, 4, 0, 3, 1, 'CONDITIONING'], [3, 5, 0, 3, 2, 'CONDITIONING'], [4, 1, 0, 3, 0, 'MODEL'], ...extraLinks] };
}
test('workflow-only files follow the legacy reading of their zero-out (#249)', t => {
const parser = require('../../web/js/comfy-metadata.js');
globalThis.ComfyMetadata = parser;
t.after(() => { delete globalThis.ComfyMetadata; });
// A connected zero-out on the read sampler's negative is the zeroed empty negative.
const direct = zeroedWorkflow();
assert.equal(parser.extractGenerationParams({ workflow: direct }).negativePrompt, 'a woman holding a coffee cup', 'the legacy parse would fill it');
assert.deepEqual(workflowOnly(direct).prompts.negative, { status: 'resolved', text: '', source: 'zeroed-conditioning' });
// A bypassed zero-out (mode 4) passes its conditioning through: the legacy reading is right.
const bypassed = workflowOnly(zeroedWorkflow({ zeroMode: 4 }));
assert.deepEqual(bypassed.prompts.negative, { status: 'resolved', text: 'a woman holding a coffee cup', source: 'graph-inferred' });
// An unconnected zero-out beside a text negative changes nothing.
const unconnected = workflowOnly(zeroedWorkflow({ negativeLink: 6,
extraNodes: [{ id: 6, type: 'CLIPTextEncode', inputs: [], outputs: [{ links: [6] }], widgets_values: ['blurry'] }],
extraLinks: [[6, 6, 0, 3, 2, 'CONDITIONING']] }));
assert.deepEqual(unconnected.prompts.negative, { status: 'resolved', text: 'blurry', source: 'graph-inferred' });
});
test('a negative read through a subgraph holding a zero-out is not filled (#249)', t => {
const parser = require('../../web/js/comfy-metadata.js');
globalThis.ComfyMetadata = parser;
t.after(() => { delete globalThis.ComfyMetadata; });
// The sampler's negative comes from a subgraph instance whose definition zeroes it.
const definition = 'aaaaaaaa-0000-4000-8000-000000000001';
const workflow = zeroedWorkflow({ negativeLink: 7,
extraNodes: [{ id: 9, type: definition, inputs: [{ name: 'conditioning', type: 'CONDITIONING', link: 8 }], outputs: [{ links: [7] }] }],
extraLinks: [[7, 9, 0, 3, 2, 'CONDITIONING'], [8, 4, 0, 9, 0, 'CONDITIONING']] });
workflow.definitions = { subgraphs: [{ id: definition, nodes: [{ id: 1, type: 'ConditioningZeroOut', inputs: [], outputs: [] }], links: [] }] };
assert.equal(parser.extractGenerationParams({ workflow }).negativePrompt, 'a woman holding a coffee cup', 'the legacy parse would fill it');
assert.deepEqual(workflowOnly(workflow).prompts.negative, { status: 'unresolved', text: null, source: null });
// The same instance of a definition without a zero-out keeps the legacy fill.
workflow.definitions.subgraphs[0].nodes[0].type = 'Reroute';
assert.equal(workflowOnly(workflow).prompts.negative.text, 'a woman holding a coffee cup');
});
test('callers without the browser global pass the legacy parser explicitly', () => {
const parser = require('../../web/js/comfy-metadata.js');
const entry = image(), data = envelope(entry);
+1 -1
View File
@@ -119,7 +119,7 @@ function currentInspectionEnvelope(target) {
file:{observation:'current-file',source_label:'output',media_type:'image',extension:'.png',format:'PNG',size:10,width:2,height:3,modified_time:1},
prompts:{positive:{status:'resolved',text:'current positive',source:'execution'},negative:{status:'resolved',text:'',source:'execution'}},
metadata:{status:'absent',omitted_reason:null,raw:{},record_formats:{},prompt_graph:{status:'absent',value:null},workflow_graph:{status:'absent',value:null},parameters:null},
resolution:{state:'absent',sampler_id:null,reason:'no graph'},library:{status:'none',scope:'saved-library',positive_id:null,pair_id:null,title:null,category:null,tags:[],metadata_scope:null}};
resolution:{state:'absent',sampler_id:null,reason:'no graph'},library:{status:'none',scope:'saved-library',positive_id:null,pair_id:null,title:null,category:null,tags:[],category_scope:null,tags_scope:null}};
}
test('real inspection navigation preserves slideshow across two advances and deferred metadata loads',async()=>{
+202
View File
@@ -0,0 +1,202 @@
// Saved views (#249 D16): the state whitelist, the concept's default name, the listing parser and the shell's store.
const test = require('node:test');
const assert = require('node:assert/strict');
const source = import('../../web/js/workspace/saved-views.mjs');
const IMAGE_DEFAULTS = { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false };
const LIBRARY_DEFAULTS = { q: '', category: '', sort: 'newest', tags: [] };
test('a saved state always holds every page key and keeps only values its page accepts', async () => {
const { normalizeViewState, SAVED_VIEW_PAGES } = await source;
assert.deepEqual(SAVED_VIEW_PAGES, ['images', 'library']);
assert.deepEqual(normalizeViewState('images', {}), IMAGE_DEFAULTS);
assert.deepEqual(normalizeViewState('library', null), LIBRARY_DEFAULTS);
const full = { q: 'castle', category: 'Portraits', sort: 'name-desc', source: '2', folder: 'a/b', media: 'video', tags: ['moody', 'fog'], favorites: true };
assert.deepEqual(normalizeViewState('images', full), full);
// D51: favorites is a boolean; anything else is the whole listing.
for (const favorites of ['true', 1, null]) assert.equal(normalizeViewState('images', { ...full, favorites }).favorites, false);
// Each rejected value falls back on its own; the rest of the view still applies.
assert.deepEqual(normalizeViewState('images', { ...full, sort: 'bogus', source: '-1', media: 'audio', tags: ['a,b'], extra: 1 }),
{ ...full, sort: 'newest', source: 'all', media: 'all', tags: [] });
assert.deepEqual(normalizeViewState('images', { ...full, q: 'x'.repeat(501), folder: 7, tags: Array(21).fill('t') }),
{ ...full, q: '', folder: '', tags: [] });
assert.deepEqual(normalizeViewState('images', { ...full, category: 'bad\u0007', tags: [' '] }), { ...full, category: '', tags: [] });
// Library has no source, folder or media, and no Name Z–A.
assert.deepEqual(normalizeViewState('library', { ...full, sort: 'name-desc' }), { q: 'castle', category: 'Portraits', sort: 'newest', tags: ['moody', 'fog'] });
assert.deepEqual(normalizeViewState('library', { sort: 'oldest' }), { ...LIBRARY_DEFAULTS, sort: 'oldest' });
// The returned tags are a copy.
const tags = ['one'];
const state = normalizeViewState('library', { tags });
state.tags.push('two');
assert.deepEqual(tags, ['one']);
assert.throws(() => normalizeViewState('tags', {}), TypeError);
});
test('the default name is the concept search name, else My workspace view, within the name limit', async () => {
const { defaultViewName } = await source;
assert.equal(defaultViewName('neon'), 'Search: neon');
assert.equal(defaultViewName(' neon city '), 'Search: neon city');
assert.equal(defaultViewName(''), 'My workspace view');
assert.equal(defaultViewName(' '), 'My workspace view');
assert.equal(defaultViewName(undefined), 'My workspace view');
assert.equal(defaultViewName('x'.repeat(500)).length, 100);
});
test('the listing keeps usable views in order and normalizes their state', async () => {
const { parseSavedViews, parseSavedView, savedViewHash } = await source;
const views = parseSavedViews({ success: true, views: [
{ id: 3, page: 'library', name: 'Neon', state: { q: 'neon', category: '', sort: 'name', tags: [] } },
{ id: 0, page: 'images', name: 'Bad id', state: {} },
{ id: 4, page: 'tags', name: 'Bad page', state: {} },
{ id: 5, page: 'images', name: ' ', state: {} },
{ id: 1, page: 'images', name: 'Videos', state: { media: 'video', source: '9' } },
] });
assert.deepEqual(views, [
{ id: 3, page: 'library', name: 'Neon', state: { q: 'neon', category: '', sort: 'name', tags: [] } },
{ id: 1, page: 'images', name: 'Videos', state: { ...IMAGE_DEFAULTS, media: 'video', source: '9' } },
]);
assert.equal(parseSavedView(null), null);
assert.throws(() => parseSavedViews({ success: true }), TypeError);
assert.equal(savedViewHash(12), '#/saved/12');
});
test('page filters and saved states convert both ways', async () => {
const { imagesFilters, imagesViewState, libraryFilters, libraryViewState } = await source;
const filters = { q: 'castle', category: 'Portraits', sort: 'rating', root: '1', subfolder: 'x', media: 'image', tags: ['fog'], favorites: true };
const state = imagesViewState(filters);
assert.deepEqual(state, { q: 'castle', category: 'Portraits', sort: 'rating', source: '1', folder: 'x', media: 'image', tags: ['fog'], favorites: true });
assert.deepEqual(imagesFilters(state), filters);
assert.deepEqual(imagesFilters({}), { q: '', category: '', sort: 'newest', root: 'all', subfolder: '', media: 'all', tags: [], favorites: false });
assert.equal(imagesViewState({ ...filters, favorites: undefined }).favorites, false);
const shelf = { text: 'neon', category: 'Cities', sort: 'oldest', tags: ['night'] };
assert.deepEqual(libraryViewState(shelf), { q: 'neon', category: 'Cities', sort: 'oldest', tags: ['night'] });
assert.deepEqual(libraryFilters(libraryViewState(shelf)), shelf);
});
function fakeApi() {
const calls = [];
const pending = [];
const api = {
calls,
get(url, options) {
calls.push(['GET', url, options.cache]);
return new Promise((resolve, reject) => pending.push({ resolve, reject, signal: options.signal }));
},
post(url, body) {
calls.push(['POST', url, body]);
return Promise.resolve({ success: true, view: { id: 9, ...body } });
},
patch(url, body) {
calls.push(['PATCH', url, body]);
return api.patchResult ?? Promise.resolve({ success: true, view: { id: 9, page: 'images', name: body.name, state: {} } });
},
del(url) {
calls.push(['DELETE', url]);
return api.deleteResult ?? Promise.resolve({ success: true, id: 9 });
},
};
return { api, pending };
}
const flush = () => new Promise(resolve => setImmediate(resolve));
test('the store reports loads, keeps the last views after a failed read, and a newer read supersedes', async () => {
const { createSavedViews, SAVED_VIEWS_URL } = await source;
const { api, pending } = fakeApi();
const controller = new AbortController();
const store = createSavedViews(api, controller.signal);
const seen = [];
store.subscribe(next => seen.push([next.status, next.views.map(view => view.id)]));
store.load();
const settled = store.settled();
pending[0].resolve({ success: true, views: [{ id: 2, page: 'images', name: 'A', state: {} }] });
assert.equal((await settled).status, 'ready');
assert.deepEqual(store.find(2).state, IMAGE_DEFAULTS);
assert.equal(store.find(3), null);
assert.deepEqual(api.calls[0], ['GET', SAVED_VIEWS_URL, 'no-store']);
store.load();
pending[1].reject(new Error('offline'));
await flush();
assert.equal(store.status, 'error');
assert.deepEqual(store.views.map(view => view.id), [2]);
// A second read aborts the first; only the newer one lands.
store.load();
store.load();
assert.equal(pending[2].signal.aborted, true);
pending[2].resolve({ success: true, views: [] });
pending[3].resolve({ success: true, views: [{ id: 5, page: 'library', name: 'B', state: {} }] });
await flush();
assert.deepEqual(store.views.map(view => view.id), [5]);
assert.deepEqual(seen, [['loading', []], ['ready', [2]], ['loading', [2]], ['error', [2]], ['loading', [2]], ['loading', [2]], ['ready', [5]]]);
controller.abort();
});
test('creating a view posts the full state and re-reads the list', async () => {
const { createSavedViews, SAVED_VIEWS_URL } = await source;
const { api, pending } = fakeApi();
const store = createSavedViews(api);
const created = store.create('library', 'Neon', { q: 'neon' });
await flush();
assert.deepEqual(api.calls[0], ['POST', SAVED_VIEWS_URL, { page: 'library', name: 'Neon', state: { ...LIBRARY_DEFAULTS, q: 'neon' } }]);
pending[0].resolve({ success: true, views: [{ id: 9, page: 'library', name: 'Neon', state: { q: 'neon' } }] });
assert.deepEqual(await created, { id: 9, page: 'library', name: 'Neon', state: { ...LIBRARY_DEFAULTS, q: 'neon' } });
assert.equal(store.find(9).name, 'Neon');
const broken = createSavedViews({ ...api, post: () => Promise.resolve({ success: true, view: null }) });
await assert.rejects(broken.create('images', 'X', {}), TypeError);
});
test('renaming and deleting a view re-read the list, whatever the outcome', async () => {
const { createSavedViews, SAVED_VIEWS_URL } = await source;
const { api, pending } = fakeApi();
const store = createSavedViews(api);
const renamed = store.rename(9, 'Dusk');
await flush();
assert.deepEqual(api.calls[0], ['PATCH', `${SAVED_VIEWS_URL}/9`, { name: 'Dusk' }]);
assert.deepEqual(api.calls[1], ['GET', SAVED_VIEWS_URL, 'no-store']);
pending[0].resolve({ success: true, views: [{ id: 9, page: 'images', name: 'Dusk', state: {} }] });
assert.deepEqual(await renamed, { id: 9, page: 'images', name: 'Dusk', state: IMAGE_DEFAULTS });
assert.equal(store.find(9).name, 'Dusk');
const removed = store.remove(9);
await flush();
assert.deepEqual(api.calls[2], ['DELETE', `${SAVED_VIEWS_URL}/9`]);
pending[1].resolve({ success: true, views: [] });
assert.equal(await removed, 9);
assert.equal(store.find(9), null);
// A view another tab already deleted is the outcome asked for; any other failure rejects, after a re-read.
api.deleteResult = Promise.reject(Object.assign(new Error('Not found'), { status: 404 }));
const gone = store.remove(4);
await flush();
pending[2].resolve({ success: true, views: [] });
assert.equal(await gone, 4);
api.deleteResult = Promise.reject(Object.assign(new Error('Locked'), { status: 500 }));
const failed = store.remove(4);
await flush();
pending[3].resolve({ success: true, views: [] });
await assert.rejects(failed, /Locked/);
api.patchResult = Promise.resolve({ success: true, view: null });
const invalid = store.rename(4, 'X');
await flush();
pending[4].resolve({ success: true, views: [] });
await assert.rejects(invalid, TypeError);
assert.equal(pending.length, 5);
assert.throws(() => store.rename(0, 'X'), TypeError);
assert.throws(() => store.remove('9'), TypeError);
});
test('a state over the API 4 KB cap is refused before anything is sent (#249 M5)', async () => {
const { createSavedViews, stateFits, SavedViewTooLarge, MAX_STATE_BYTES } = await source;
assert.equal(MAX_STATE_BYTES, 4096);
assert.equal(stateFits(IMAGE_DEFAULTS), true);
// Each value is within its own limit; their multibyte UTF-8 together passes 4 KB.
const big = { ...IMAGE_DEFAULTS, q: 'é'.repeat(500), folder: 'é'.repeat(1024), tags: Array.from({ length: 20 }, () => 'é'.repeat(50)) };
assert.equal(stateFits(big), false);
const { api } = fakeApi();
const store = createSavedViews(api);
await assert.rejects(store.create('images', 'Big', big), error => error instanceof SavedViewTooLarge && error instanceof RangeError);
assert.deepEqual(api.calls, []);
});
+85 -12
View File
@@ -4,10 +4,10 @@ const source = import('../../web/js/workspace/state.mjs');
test('workspace routes resolve only supported deep links', async () => {
const { resolveRoute } = await source;
for (const route of ['overview', 'images', 'library', 'tags', 'inspect', 'system']) {
for (const route of ['overview', 'images', 'library', 'tags', 'inspect', 'system', 'activity']) {
assert.equal(resolveRoute(`#/${route}`).id, route);
}
for (const hash of ['', '#', '#/unknown', '#/images/other', '#/<script>']) {
for (const hash of ['', '#', '#/unknown', '#/images/other', '#/<script>', '#/activity/abc', `#/activity/${'A'.repeat(32)}`]) {
assert.equal(resolveRoute(hash).id, 'images');
}
});
@@ -23,10 +23,33 @@ 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');
for (const id of ['images', 'library', 'tags', 'overview']) assert.equal(isHandoff(resolveRoute(`#/${id}`)), false, id);
for (const id of ['images', 'library', 'tags', 'overview', 'activity']) assert.equal(isHandoff(resolveRoute(`#/${id}`)), false, id);
for (const id of ['inspect', 'system']) assert.equal(isHandoff(resolveRoute(`#/${id}`)), true, id);
});
test('a job of the Activity page is its own deep link', async () => {
const { resolveRoute, routeTitle, isHandoff } = await source;
const id = '0123456789abcdef'.repeat(2);
const route = resolveRoute(`#/activity/${id}`);
assert.deepEqual([route.id, route.jobId, route.hash, routeTitle(route), isHandoff(route)],
['activity', id, `#/activity/${id}`, 'Activity', false]);
assert.equal(resolveRoute('#/activity').jobId, undefined);
assert.equal(route.href, undefined, 'Activity has no legacy card on Overview');
// The router renders again when only the job changes, and not for the same job.
const { mountRouter } = await source;
const other = 'f'.repeat(32);
const win = new EventTarget();
win.location = { hash: `#/activity/${id}` };
win.history = { state: null, replaceState(state, unused, hash) { win.location.hash = hash; } };
const seen = [];
const dispose = mountRouter(win, next => seen.push([next.id, next.jobId]));
win.dispatchEvent(new Event('hashchange'));
win.location.hash = `#/activity/${other}`; win.dispatchEvent(new Event('hashchange'));
win.location.hash = '#/activity'; win.dispatchEvent(new Event('hashchange'));
assert.deepEqual(seen, [['activity', id], ['activity', other], ['activity', undefined]]);
dispose();
});
test('statistics preserve zero and reject incomplete or invalid counts', async () => {
const { summarizeStats } = await source;
assert.deepEqual(summarizeStats({total_prompts:0, total_images:12, total_tags:3}), [
@@ -79,18 +102,19 @@ test('positive library deep links preserve the selected identity without duplica
test('nav counts reject negative, non-integer and missing totals', async () => {
const { summarizeNavCounts } = await source;
const counts = summarizeNavCounts({images: {total: 24}, library: {total: 0}, tags: {total: 7}});
const counts = summarizeNavCounts({images: {total: 24}, favorites: {total: 3}, library: {total: 0}, tags: {total: 7}});
assert.ok(counts instanceof Map);
assert.deepEqual([...counts], [['images', 24], ['library', 0], ['tags', 7]]);
assert.deepEqual([...counts], [['images', 24], ['favorites', 3], ['library', 0], ['tags', 7]]);
assert.equal(counts.has('inspect'), false, 'Inspect a file has no count');
for (const invalid of [undefined, null, {}, {total: -1}, {total: 1.5}, {total: '3'}, {total: NaN},
{total: Infinity}, {total: Number.MAX_SAFE_INTEGER + 1}, {total: null}, [], 'x']) {
const result = summarizeNavCounts({images: invalid, library: {total: 2}, tags: invalid});
const result = summarizeNavCounts({images: invalid, favorites: invalid, library: {total: 2}, tags: invalid});
assert.equal(result.get('images'), null, JSON.stringify(invalid));
assert.equal(result.get('favorites'), null);
assert.equal(result.get('library'), 2);
assert.equal(result.get('tags'), null);
}
assert.deepEqual([...summarizeNavCounts()], [['images', null], ['library', null], ['tags', null]]);
assert.deepEqual([...summarizeNavCounts()], [['images', null], ['favorites', null], ['library', null], ['tags', null]]);
});
test('nav counts settle independently so a slow images read never delays the others', async () => {
@@ -98,6 +122,7 @@ test('nav counts settle independently so a slow images read never delays the oth
let releaseImages;
const images = new Promise(resolve => { releaseImages = resolve; });
const api = {get: async url => {
if (url.includes('favorites=1')) return {success: true, total: 2};
if (url.startsWith('/prompt_manager/images')) return images;
if (url.startsWith('/prompt_manager/library')) return {success: true, total: 3};
throw new Error('Unavailable');
@@ -105,14 +130,14 @@ test('nav counts settle independently so a slow images read never delays the oth
const settled = [];
const done = loadNavCounts(api, new AbortController().signal, (id, count) => settled.push([id, count]));
await new Promise(resolve => setTimeout(resolve, 0));
assert.deepEqual(settled.sort(), [['library', 3], ['tags', null]]);
assert.deepEqual(settled.sort(), [['favorites', 2], ['library', 3], ['tags', null]]);
releaseImages({success: true, total: 40});
const counts = await done;
assert.deepEqual(settled.at(-1), ['images', 40]);
assert.deepEqual([...counts], [['images', 40], ['library', 3], ['tags', null]]);
assert.deepEqual([...counts], [['images', 40], ['favorites', 2], ['library', 3], ['tags', null]]);
});
test('nav counts issue three bounded requests and drop only the failed count', async () => {
test('nav counts issue four bounded requests and drop only the failed count', async () => {
const { loadNavCounts } = await source;
const calls = [];
const signal = new AbortController().signal;
@@ -123,15 +148,42 @@ test('nav counts issue three bounded requests and drop only the failed count', a
return {success: true, total: 12};
}};
const counts = await loadNavCounts(api, signal);
assert.deepEqual([...counts], [['images', 12], ['library', null], ['tags', 5]]);
assert.deepEqual([...counts], [['images', 12], ['favorites', 12], ['library', null], ['tags', 5]]);
const urls = calls.map(([url]) => new URL(url, 'http://host'));
assert.deepEqual(urls.map(url => url.pathname),
['/prompt_manager/images/output', '/prompt_manager/library', '/prompt_manager/vocabulary/tags']);
['/prompt_manager/images/output', '/prompt_manager/images/output', '/prompt_manager/library', '/prompt_manager/vocabulary/tags']);
for (const url of urls) assert.equal(url.searchParams.get('limit'), '1');
assert.equal(urls[0].searchParams.get('mode'), 'workspace');
assert.equal(urls[0].searchParams.has('favorites'), false);
// D51: the Favorites count is the Favorites page's own listing.
assert.equal(urls[1].searchParams.get('mode'), 'workspace');
assert.equal(urls[1].searchParams.get('favorites'), '1');
assert.ok(calls.every(([, used]) => used === signal));
});
test('a favorite change re-reads only the Favorites count', async () => {
const { loadNavCounts, FAVORITE_WRITE_COUNTS } = await source;
assert.deepEqual([...FAVORITE_WRITE_COUNTS], ['favorites']);
assert.ok(Object.isFrozen(FAVORITE_WRITE_COUNTS));
const calls = [];
const api = {get: async url => { calls.push(url); return {success: true, total: 0}; }};
const counts = await loadNavCounts(api, new AbortController().signal, () => {}, FAVORITE_WRITE_COUNTS);
assert.deepEqual(calls, ['/prompt_manager/images/output?mode=workspace&favorites=1&limit=1']);
// A real zero is a count, unlike an outstanding read.
assert.deepEqual([...counts], [['favorites', 0]]);
});
test('the Favorites route is native and resolves by its hash', async () => {
const { resolveRoute, isHandoff, routeTitle, routes } = await source;
const route = resolveRoute('#/favorites');
assert.equal(route.id, 'favorites');
assert.equal(route.label, 'Favorites');
assert.equal(routeTitle(route), 'Favorites');
assert.equal(isHandoff(route), false);
// No href: Overview lists no legacy card for it.
assert.equal(routes.find(item => item.id === 'favorites').href, undefined);
});
test('a Library or vocabulary write re-reads only the Library and Tags counts', async () => {
const { loadNavCounts, LIBRARY_WRITE_COUNTS } = await source;
assert.deepEqual([...LIBRARY_WRITE_COUNTS], ['library', 'tags']);
@@ -160,3 +212,24 @@ test('navigating to the current route re-renders it, as the concept go() does',
win.dispatchEvent(new Event('hashchange'));assert.deepEqual(seen.at(-1),['library',13]);
dispose();navigateTo('#/library/13',win);assert.equal(seen.length,3);
});
// Saved views (#249 D16): `#/saved/<id>` opens one persisted view; the shell resolves it to its page.
test('saved-view links resolve to their id and re-render only when the id changes', async () => {
const {resolveRoute,mountRouter,isHandoff,routes}=await source;
const route=resolveRoute('#/saved/7');
assert.deepEqual([route.id,route.savedId,route.hash],['saved',7,'#/saved/7']);
assert.equal(isHandoff(route),false);
assert.equal(routes.some(item=>item.id==='saved'),false,'saved views are not an Overview card or a WORKSPACE link');
for(const hash of ['#/saved','#/saved/0','#/saved/-2','#/saved/1.5','#/saved/9007199254740992','#/saved/3/x','#/saved/abc']) {
assert.equal(resolveRoute(hash).id,'images',hash);
}
const win=new EventTarget();win.location={hash:'#/saved/3'};
win.history={state:null,replaceState(state,unused,hash){win.location.hash=hash;}};
const seen=[];const dispose=mountRouter(win,next=>seen.push([next.id,next.savedId]));
assert.equal(win.location.hash,'#/saved/3');
win.dispatchEvent(new Event('hashchange'));
win.location.hash='#/saved/4';win.dispatchEvent(new Event('hashchange'));
win.location.hash='#/images';win.dispatchEvent(new Event('hashchange'));
assert.deepEqual(seen,[['saved',3],['saved',4],['images',undefined]]);
dispose();
});
+96
View File
@@ -0,0 +1,96 @@
// The shell's shared toast: plain messages clear after 6.5 s; a message with a link stays 20 s (D57), and any toast
// stays while the pointer or keyboard focus rests on it.
const test = require('node:test');
const assert = require('node:assert/strict');
const source = import('../../web/js/workspace/toast.mjs');
class FakeToast extends EventTarget {
constructor() { super(); this.held = false; this.nodes = []; this.text = ''; }
get textContent() { return this.text; }
set textContent(value) { this.text = value; this.nodes = []; }
replaceChildren(...nodes) { this.nodes = nodes; this.text = nodes.map(node => node.textContent).join(''); }
matches(selector) { assert.equal(selector, ':hover, :focus-within'); return this.held; }
}
function install() {
const toast = new FakeToast();
const previous = global.document;
global.document = {
getElementById: id => id === 'workspace-toast' ? toast : null,
createTextNode: text => ({ textContent: text }),
createElement: tag => ({ tag, href: '', textContent: '' }),
};
return { toast, restore: () => { global.document = previous; } };
}
test('a plain toast clears after 6.5 s and a linked one after 20 s', async t => {
const { showWorkspaceToast, TOAST_MS, LINK_TOAST_MS } = await source;
t.mock.timers.enable({ apis: ['setTimeout'] });
const { toast, restore } = install();
try {
showWorkspaceToast('Scan started.');
assert.equal(toast.textContent, 'Scan started.');
t.mock.timers.tick(TOAST_MS);
assert.equal(toast.textContent, '');
showWorkspaceToast(['Another job is running: ', { text: 'Scan', href: '#/activity/x' }, '.']);
assert.equal(toast.textContent, 'Another job is running: Scan.');
assert.deepEqual(toast.nodes[1], { tag: 'a', href: '#/activity/x', textContent: 'Scan' });
t.mock.timers.tick(TOAST_MS);
assert.equal(toast.textContent, 'Another job is running: Scan.');
t.mock.timers.tick(LINK_TOAST_MS - TOAST_MS);
assert.equal(toast.textContent, '');
} finally {
restore();
}
});
test('a toast under the pointer or focus waits until they leave', async t => {
const { showWorkspaceToast, TOAST_MS } = await source;
t.mock.timers.enable({ apis: ['setTimeout'] });
const { toast, restore } = install();
try {
showWorkspaceToast('Prompt copied.');
toast.held = true;
t.mock.timers.tick(TOAST_MS);
assert.equal(toast.textContent, 'Prompt copied.', 'held: it stays');
// Focus moving inside the toast keeps it; leaving it starts a fresh 6.5 s.
toast.dispatchEvent(new Event('focusout'));
t.mock.timers.tick(0);
toast.dispatchEvent(new Event('pointerleave'));
assert.equal(toast.textContent, 'Prompt copied.');
toast.held = false;
toast.dispatchEvent(new Event('focusout'));
t.mock.timers.tick(0);
t.mock.timers.tick(TOAST_MS - 1);
assert.equal(toast.textContent, 'Prompt copied.');
t.mock.timers.tick(1);
assert.equal(toast.textContent, '');
// A newer toast replaces a held one and stops waiting for the old one's pointer.
showWorkspaceToast('First.');
toast.held = true;
t.mock.timers.tick(TOAST_MS);
showWorkspaceToast('Second.');
toast.held = false;
toast.dispatchEvent(new Event('pointerleave'));
t.mock.timers.tick(TOAST_MS - 1);
assert.equal(toast.textContent, 'Second.');
t.mock.timers.tick(1);
assert.equal(toast.textContent, '');
} finally {
restore();
}
});
test('a page without the toast shows nothing and does not fail', async () => {
const { showWorkspaceToast } = await source;
const previous = global.document;
global.document = { getElementById: () => null };
try {
assert.doesNotThrow(() => showWorkspaceToast('Nothing.'));
} finally {
global.document = previous;
}
});
+143 -9
View File
@@ -116,19 +116,21 @@ test('toolbar emits concept markup and order', async () => {
dom.body.append(container);
const changes = [];
const filters = [];
const saves = [];
const toolbar = mountToolbar(container, {
scope, categories: ['Portraits', 'Landscapes'],
sorts: [['newest', 'Recently added'], ['name', 'Name A–Z'], ['rating', 'Highest rated']],
value: { q: 'castle', category: 'Landscapes', sort: 'name' },
onChange: value => changes.push(value), onFilters: trigger => filters.push(trigger),
onSaveView: trigger => saves.push(trigger),
});
const root = container.children[0];
assert.equal(toolbar.element, root);
assert.equal(root.tagName, 'SEARCH');
assert.equal(root.className, 'toolbar');
assert.deepEqual(tags(root), ['label', 'select', 'select', 'button']);
assert.deepEqual(tags(root), ['label', 'select', 'select', 'button', 'button']);
const [label, category, sort, filtersButton] = root.children;
const [label, category, sort, saveButton, filtersButton] = root.children;
assert.equal(label.className, 'search-field');
assert.equal(label.htmlFor, 'search');
assert.deepEqual(tags(label), ['svg', 'input']);
@@ -151,8 +153,12 @@ test('toolbar emits concept markup and order', async () => {
assert.equal(sort.getAttribute('aria-label'), 'Sort results');
assert.equal(sort.value, 'name');
// D16: no Save view until saved views are persisted.
assert.equal(button(root, 'Save view'), undefined);
// The concept's Save view, between sort and Filters (a359), reports its own button as the trigger.
assert.equal(button(root, 'Save view'), saveButton);
assert.equal(saveButton.type, 'button');
saveButton.click();
assert.deepEqual(saves, [saveButton]);
assert.deepEqual(filters, []);
assert.equal(filtersButton.textContent, 'Filters');
assert.equal(filtersButton.type, 'button');
filtersButton.click();
@@ -184,6 +190,9 @@ test('toolbar emits concept markup and order', async () => {
toolbar.setValue({ sort: 'bogus' });
assert.equal(sort.value, 'newest');
assert.throws(() => mountToolbar(container, { categories: [], sorts: [['newest', 'Recently added']],
onChange() {}, onFilters() {}, onSaveView() {} }), TypeError);
// Every toolbar page offers Save view, so the callback is required.
assert.throws(() => mountToolbar(container, { scope, categories: [], sorts: [['newest', 'Recently added']],
onChange() {}, onFilters() {} }), TypeError);
} finally {
scope.dispose();
@@ -200,7 +209,7 @@ test('toolbar search uses a debounced onChange that stops with its scope', async
const container = new dom.Element('div');
const changes = [];
mountToolbar(container, { scope, categories: [], sorts: [['newest', 'Recently added']],
value: { q: '', category: '', sort: 'newest' }, onChange: value => changes.push(value), onFilters() {} });
value: { q: '', category: '', sort: 'newest' }, onChange: value => changes.push(value), onFilters() {}, onSaveView() {} });
const input = container.children[0].children[0].children[1];
assert.equal(input.id, 'search');
for (const text of ['c', 'ca', 'cat']) {
@@ -244,7 +253,7 @@ test('sort lists concept options first', async () => {
try {
const container = new dom.Element('div');
mountToolbar(container, { scope, categories: [], sorts: given, value: { q: '', category: '', sort: 'oldest' },
onChange() {}, onFilters() {} });
onChange() {}, onFilters() {}, onSaveView() {} });
const sort = container.children[0].children[2];
assert.deepEqual(optionPairs(sort).map(([, label]) => label),
['Recently added', 'Name A–Z', 'Highest rated', 'Oldest first', 'Filename Z–A']);
@@ -333,6 +342,56 @@ test('refine dialog round-trips values and clear resets them', async () => {
}
});
test('Refine lists options discovered after it opened and keeps the wanted values', async () => {
const { openRefineDialog, openImportDialog, closeWorkspaceDialog } = await dialogsModule;
const dom = fakeDOM();
const scope = (await coreModule).createScope();
try {
let bySource = {};
const optionsFor = source => [['', 'All output folders'], ...(bySource[source] ?? []).map(path => [path, path])];
const fields = [{ key: 'source', options: [['all', 'All sources']] }, { key: 'folder', options: optionsFor('all'), optionsFor },
REFINE_FIELDS[2]];
const applied = [];
const refine = openRefineDialog({ scope, fields, values: { source: '1', folder: 'temp-only', media: 'video' },
onApply: value => applied.push(value), onClear() {} });
const [source, folder, media] = byId(dom, 'modal-body').children.filter(child => child.className === 'field')
.map(field => field.children[1]);
// Opened before discovery: only the cleared values.
assert.deepEqual(optionPairs(source), [['all', 'All sources']]);
assert.equal(source.value, 'all');
assert.equal(folder.value, '');
bySource = { all: ['portraits', 'temp-only'], 0: ['portraits'], 1: ['temp-only'] };
refine.setOptions('source', [['all', 'All sources'], ['0', 'output'], ['1', 'archive']]);
// The values it opened with, now offered, and the chosen Source's folders.
assert.deepEqual(optionPairs(source).map(([value]) => value), ['all', '0', '1']);
assert.equal(source.value, '1');
assert.deepEqual(optionPairs(folder), [['', 'All output folders'], ['temp-only', 'temp-only']]);
assert.equal(folder.value, 'temp-only');
// A choice made since wins over the opening value; tags and unknown keys are ignored.
source.value = '0';
source.dispatchEvent(new Event('change'));
refine.setOptions('source', [['all', 'All sources'], ['0', 'output'], ['1', 'archive']]);
refine.setOptions('tags', [['x', 'x']]);
assert.equal(source.value, '0');
assert.deepEqual(optionPairs(folder).map(([value]) => value), ['', 'portraits']);
assert.equal(media.value, 'video');
button(byId(dom, 'modal-body'), 'Apply filters').click();
assert.deepEqual(applied, [{ source: '0', folder: '', media: 'video' }]);
// Closed: a late discovery leaves the shell alone.
refine.setOptions('source', [['all', 'All sources']]);
assert.equal(source.options.length, 3);
// Replaced by another dialog: the same.
const again = openRefineDialog({ scope, fields, values: {}, onApply() {}, onClear() {} });
openImportDialog({ scope, onInspect() {}, onRescan() {} });
again.setOptions('source', [['all', 'All sources'], ['9', 'late']]);
assert.equal(byId(dom, 'modal-title').textContent, 'Import & inspect');
} finally {
closeWorkspaceDialog();
scope.dispose();
dom.restore();
}
});
test('the Folder list follows the chosen Source', async () => {
const { openRefineDialog, closeWorkspaceDialog } = await dialogsModule;
const dom = fakeDOM();
@@ -469,6 +528,40 @@ test('copy fallback selects the text', async () => {
}
});
// The concept's Save view dialog (a1330–1346 via simpleInput a1138): an empty name falls back to "Saved view".
test('an input dialog with a fallback applies it for an empty value', async () => {
const { openInputDialog, closeWorkspaceDialog } = await dialogsModule;
const dom = fakeDOM();
const scope = (await coreModule).createScope();
try {
const applied = [];
const open = value => openInputDialog({ scope, title: 'Save this view', label: 'View name', action: 'Save',
value, maxLength: 100, fallback: 'Saved view', onApply: name => applied.push(name) });
open('My workspace view');
assert.equal(byId(dom, 'modal-title').textContent, 'Save this view');
const body = byId(dom, 'modal-body');
const [field, actions] = body.children;
const [label, input] = field.children;
assert.equal(label.textContent, 'View name');
assert.equal(input.id, 'action-input');
assert.equal(input.value, 'My workspace view');
assert.equal(input.maxLength, 100);
assert.deepEqual(actions.children.map(node => node.textContent), ['Cancel', 'Save']);
input.value = ' ';
actions.children[1].click();
assert.deepEqual(applied, ['Saved view']);
open('');
byId(dom, 'action-input').value = ' Castles ';
byId(dom, 'modal-body').children[1].children[1].click();
assert.deepEqual(applied, ['Saved view', 'Castles']);
assert.throws(() => openInputDialog({ scope, title: 'T', label: 'L', action: 'A', fallback: '', onApply() {} }), TypeError);
} finally {
closeWorkspaceDialog();
scope.dispose();
dom.restore();
}
});
test('dialogs restore focus to the trigger on Escape', async () => {
const { openRefineDialog, openImportDialog, openCopyFallback, closeWorkspaceDialog } = await dialogsModule;
const dom = fakeDOM();
@@ -565,6 +658,47 @@ test('an action may open the next dialog without the late close event closing it
}
});
test('a content dialog shows page nodes and releases them once on close or replacement', async () => {
const { openContentDialog, openCopyFallback, closeWorkspaceDialog } = await dialogsModule;
const dom = fakeDOM();
const scope = (await coreModule).createScope();
try {
const trigger = new dom.Element('button');
dom.body.append(trigger);
let closed = 0;
const grid = new dom.Element('div');
openContentDialog({ scope, title: 'All images for this positive', nodes: [grid], trigger, onClose: () => closed++ });
assert.equal(byId(dom, 'modal-title').textContent, 'All images for this positive');
assert.deepEqual(byId(dom, 'modal-body').children, [grid]);
assert.equal(modal(dom).open, true);
modal(dom).dispatchEvent(new Event('cancel', { cancelable: true }));
assert.equal(closed, 1);
assert.equal(document.activeElement, trigger);
await tick();
assert.equal(closed, 1);
// Another dialog replacing the body releases it too, exactly once.
openContentDialog({ scope, title: 'Repeat run', nodes: [new dom.Element('p')], trigger, onClose: () => closed++ });
openCopyFallback('swap', { scope });
assert.equal(closed, 2);
closeWorkspaceDialog();
assert.equal(closed, 2);
// Disposing the page scope closes it.
openContentDialog({ scope, title: 'Repeat run', nodes: [], onClose: () => closed++ });
scope.dispose();
assert.equal(closed, 3);
assert.throws(() => openContentDialog({ title: 'x', nodes: [] }), /page scope/);
const other = (await coreModule).createScope();
assert.throws(() => openContentDialog({ scope: other, title: '', nodes: [] }), TypeError);
assert.throws(() => openContentDialog({ scope: other, title: 'x', nodes: [null] }), TypeError);
assert.throws(() => openContentDialog({ scope: other, title: 'x', nodes: [], onClose: 1 }), TypeError);
other.dispose();
} finally {
closeWorkspaceDialog();
scope.dispose();
dom.restore();
}
});
test('dialogs need the page scope and close with it', async () => {
const { openRefineDialog, openImportDialog, openCopyFallback, closeWorkspaceDialog } = await dialogsModule;
const { createScope } = await coreModule;
@@ -621,8 +755,8 @@ test('the Filters button shows the active refinement count', async () => {
try {
const container = new dom.Element('div');
const toolbar = mountToolbar(container, { scope, categories: [], sorts: [['newest', 'Recently added']],
activeFilters: 2, onChange() {}, onFilters() {} });
const filtersButton = container.children[0].children[3];
activeFilters: 2, onChange() {}, onFilters() {}, onSaveView() {} });
const filtersButton = container.children[0].children[4];
// The visible text is the accessible name, so the count is spoken too.
assert.equal(filtersButton.textContent, 'Filters · 2');
toolbar.setActiveFilters(1);
@@ -632,7 +766,7 @@ test('the Filters button shows the active refinement count', async () => {
for (const bad of [-1, 1.5, '1', null, Number.NaN]) assert.throws(() => toolbar.setActiveFilters(bad), TypeError);
assert.equal(filtersButton.textContent, 'Filters');
assert.throws(() => mountToolbar(new dom.Element('div'), { scope, categories: [], sorts: [['newest', 'Recently added']],
activeFilters: -1, onChange() {}, onFilters() {} }), TypeError);
activeFilters: -1, onChange() {}, onFilters() {}, onSaveView() {} }), TypeError);
} finally {
scope.dispose();
dom.restore();
+413
View File
@@ -1965,6 +1965,419 @@ class TestOutputScanIsABackgroundJob(AdminAPITestCase):
self.assertEqual(data["last_event"]["type"], "error")
class TestActivityJobs(AdminAPITestCase):
"""The scan runs as a registry job; legacy routes keep their payloads."""
def _gate_collect(self):
import threading
gate = threading.Event()
calls = []
original = self.api._collect_output_media_sync
def gated(output_dirs):
calls.append(output_dirs)
gate.wait(5)
return original(output_dirs)
self.api._collect_output_media_sync = gated
return gate, calls
async def _json(self, method, url):
resp = await self.client.request(method, url)
return resp.status, await resp.json()
async def test_legacy_scan_route_reuses_registry_job_and_payload_shape(self):
from py.api.admin import SCAN_COUNT_KEYS
_write_png(self.output_dir / "gen.png", prompt_text="a red square")
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.output_dir)]
gate, calls = self._gate_collect()
# The workspace starts the job; the legacy page then attaches to it.
status, started = await self._json("POST", "/prompt_manager/jobs/scan")
self.assertEqual(status, 202)
self.assertTrue(started["success"])
legacy = asyncio.ensure_future(
self.client.request("POST", "/prompt_manager/scan", data="{}")
)
await asyncio.sleep(0.05)
gate.set()
body = await (await legacy).text()
await asyncio.wait_for(self.api._scan_job.task, 10)
self.assertEqual(len(calls), 1)
payloads = _sse_payloads(body)
progress = [p for p in payloads if p["type"] == "progress"]
self.assertTrue(progress)
legacy_keys = set(SCAN_COUNT_KEYS) | {"type", "progress", "status", "processed"}
for payload in progress:
self.assertEqual(set(payload), legacy_keys)
final = payloads[-1]
self.assertEqual(final["type"], "complete")
self.assertEqual(set(final), set(SCAN_COUNT_KEYS) | {"type", "directories"})
self.assertEqual(final["added"], 1)
self.assertEqual(final["directories"], ["output"])
status, listed = await self._json("GET", "/prompt_manager/jobs")
self.assertEqual(status, 200)
self.assertEqual(len(listed["jobs"]), 1)
job = listed["jobs"][0]
self.assertEqual(job["id"], started["job"]["id"])
self.assertEqual(job["id"], self.api._scan_job.id)
self.assertEqual((job["kind"], job["state"]), ("scan", "finished"))
self.assertEqual(job["counters"]["added"], 1)
self.assertEqual(job["progress"], 100)
self.assertEqual(job["scope"]["output_folders"], 1)
async def test_scan_records_outcomes_by_group(self):
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.output_dir)]
(self.output_dir / "sub").mkdir()
names = {
"sub/added.png": {"status": "added", "title": "A red fox"},
"linked.png": {"status": "linked", "title": "A grey cat"},
"unchanged.png": {"status": "unchanged", "title": "A brown dog"},
"ambiguous.png": {"status": "ambiguous"},
"failed.png": RuntimeError(f"{self.tmpdir}/secret boom"),
"none.png": None,
}
for name in names:
(self.output_dir / name).write_bytes(b"not an image")
def fake_ingest(media_file, metadata, detailed=False):
key = Path(media_file).relative_to(self.output_dir).as_posix()
outcome = names[key]
if isinstance(outcome, Exception):
raise outcome
return outcome
self.api._ingest_scanned_file = fake_ingest
with self.assertLogs("prompt_manager.api", level="ERROR"):
status, started = await self._json("POST", "/prompt_manager/jobs/scan")
self.assertEqual(status, 202)
await asyncio.wait_for(self.api._scan_job.task, 10)
resp = await self.client.request(
"GET", f"/prompt_manager/jobs/{started['job']['id']}"
)
text = await resp.text()
self.assertEqual(resp.status, 200)
self.assertNotIn(self.tmpdir, text)
job = json.loads(text)["job"]
self.assertEqual(
job["counters"],
{"added": 2, "known": 1, "review": 1, "no_metadata": 1, "errors": 1},
)
outcomes = job["outcomes"]
self.assertEqual(
{k: len(v) for k, v in outcomes.items()},
{"added": 2, "known": 1, "review": 1, "no_metadata": 1, "errors": 1},
)
added = {row["path"]: row for row in outcomes["added"]}
self.assertEqual(set(added), {"sub/added.png", "linked.png"})
self.assertIn("A red fox", added["sub/added.png"]["detail"])
self.assertIn("A grey cat", added["linked.png"]["detail"])
self.assertEqual(added["linked.png"]["root"], "output")
self.assertIn("A brown dog", outcomes["known"][0]["detail"])
self.assertIn("review", outcomes["review"][0]["detail"].lower())
self.assertEqual(outcomes["errors"][0]["path"], "failed.png")
self.assertTrue(outcomes["errors"][0]["detail"])
self.assertEqual(outcomes["no_metadata"][0]["path"], "none.png")
self.assertTrue(outcomes["no_metadata"][0]["detail"])
prefixes = {line.split(" ", 2)[1] for line in job["log"][1:-1]}
self.assertEqual(prefixes, {"+", "=", "?", "!", "-"})
self.assertEqual(job["state"], "finished")
self.assertTrue(job["status_line"].startswith("Processing file 6 of 6"))
legacy = self.api._scan_job.last_event
self.assertEqual(
(legacy["processed"], legacy["found"], legacy["failed"]), (6, 4, 1)
)
async def test_report_is_attachment_without_absolute_paths(self):
"""Task 5: the report downloads as JSON, whole and without server paths."""
import re
from py.api.jobs import MAX_OUTCOME_ROWS
async def runner(job):
absolute = f"{self.tmpdir}/output/deep/abs.png"
job.record(
"added",
{"path": absolute, "root": "output", "root_index": 0, "detail": "ok"},
f"+ {absolute} · ok",
)
for index in range(MAX_OUTCOME_ROWS + 5):
job.record(
"known",
{"path": f"k/{index}.png", "root": "output", "detail": "x"},
f"= k/{index}.png",
)
job.update(processed=MAX_OUTCOME_ROWS + 6, total=MAX_OUTCOME_ROWS + 6)
job = self.api._jobs.start(
"scan",
"Scan for metadata",
record_changing=True,
scope={"files": "new", "output_folders": 1},
runner=runner,
groups=("added", "known", "review", "no_metadata", "errors"),
)
await asyncio.wait_for(job.task, 10)
resp = await self.client.request("GET", f"/prompt_manager/jobs/{job.id}/report")
text = await resp.text()
self.assertEqual(resp.status, 200)
self.assertIn("application/json", resp.headers["Content-Type"])
disposition = resp.headers["Content-Disposition"]
self.assertRegex(
disposition,
r'^attachment; filename="prompt-manager-scan-\d{8}-\d{4}\.json"$',
)
self.assertNotIn(self.tmpdir, text)
self.assertIsNone(
re.search(r"[A-Za-z]:[\\/]|(?<![\w.])/(?:tmp|home|var)/", text)
)
report = json.loads(text)
self.assertEqual(report["job"]["id"], job.id)
self.assertEqual(report["job"]["scope"]["files"], "new")
self.assertEqual(report["job"]["counters"]["known"], MAX_OUTCOME_ROWS + 5)
added = report["outcomes"]["added"]
self.assertEqual(
(added["count"], len(added["rows"]), added["truncated"]), (1, 1, False)
)
self.assertEqual(added["rows"][0]["path"], "abs.png")
known = report["outcomes"]["known"]
self.assertEqual(
(known["count"], len(known["rows"]), known["truncated"]),
(MAX_OUTCOME_ROWS + 5, MAX_OUTCOME_ROWS, True),
)
self.assertEqual(report["outcomes"]["errors"]["rows"], [])
self.assertFalse(report["outcomes"]["errors"]["truncated"])
self.assertEqual(report["log"], job.detail()["log"])
self.assertIn("generated_at", report)
resp = await self.client.request("GET", "/prompt_manager/jobs/nope/report")
self.assertEqual(resp.status, 404)
async def test_cancel_after_first_batch_keeps_partial_counts(self):
"""Review M7: the real scan stops at the batch boundary, keeping batch 1."""
import threading
from unittest import mock
from py.api import admin
_write_png(self.output_dir / "a.png", prompt_text="a red square")
_write_png(self.output_dir / "b.png", prompt_text="a blue circle")
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.output_dir)]
first_done, release = threading.Event(), threading.Event()
original = self.api._ingest_scan_batch_sync
def gated(batch_results, roots=(), outcomes=None):
counts = original(batch_results, roots=roots, outcomes=outcomes)
first_done.set()
release.wait(5)
return counts
self.api._ingest_scan_batch_sync = gated
with mock.patch.object(admin, "SCAN_BATCH_SIZE", 1):
status, started = await self._json("POST", "/prompt_manager/jobs/scan")
self.assertEqual(status, 202)
job_id = started["job"]["id"]
await asyncio.get_running_loop().run_in_executor(None, first_done.wait, 5)
status, cancelled = await self._json(
"POST", f"/prompt_manager/jobs/{job_id}/cancel"
)
self.assertEqual((status, cancelled["cancellation_requested"]), (200, True))
release.set()
await asyncio.wait_for(self.api._scan_job.task, 10)
status, body = await self._json("GET", f"/prompt_manager/jobs/{job_id}")
job = body["job"]
self.assertEqual(job["state"], "cancelled")
self.assertEqual((job["processed"], job["total"]), (1, 2))
self.assertEqual(sum(job["counters"].values()), job["processed"])
self.assertEqual(job["counters"]["added"], 1)
listed = [row["path"] for rows in job["outcomes"].values() for row in rows]
self.assertEqual(len(listed), 1)
self.assertIn(listed[0], {"a.png", "b.png"})
self.assertIn("Scan cancelled · 1 file processed", job["log"][-1])
# A cancel after the end is a no-op.
status, again = await self._json(
"POST", f"/prompt_manager/jobs/{job_id}/cancel"
)
self.assertEqual((status, again["cancellation_requested"]), (200, False))
async def test_scan_rows_carry_their_root_index(self):
"""Review M9: two roots labelled "output" stay apart by listing index."""
second = self.outside_dir / "output"
second.mkdir(parents=True)
os.environ[EXTRA_ROOTS_ENV] = str(second)
_write_png(self.output_dir / "same.png", prompt_text="a red square")
_write_png(second / "same.png", prompt_text="a blue circle")
GalleryConfig.MONITORING_DIRECTORIES = [
self._root(self.output_dir),
self._root(second),
]
output_dirs = [Path(d) for d in self.api._get_all_output_dirs()]
self.assertEqual(len(output_dirs), 2)
status, started = await self._json("POST", "/prompt_manager/jobs/scan")
await asyncio.wait_for(self.api._scan_job.task, 10)
status, body = await self._json(
"GET", f"/prompt_manager/jobs/{started['job']['id']}"
)
rows = body["job"]["outcomes"]["added"]
self.assertEqual(len(rows), 2)
self.assertEqual({row["root"] for row in rows}, {"output"})
by_index = {row["root_index"]: row for row in rows}
self.assertEqual(set(by_index), {0, 1})
for index, row in by_index.items():
self.assertEqual(row["path"], "same.png")
self.assertTrue((output_dirs[index] / row["path"]).is_file())
texts = {
index: (
"red"
if output_dirs[index] == Path(self._root(self.output_dir))
else "blue"
)
for index in by_index
}
for index, row in by_index.items():
self.assertIn(texts[index], row["detail"])
async def test_scan_row_names_the_prompt_by_its_stored_title(self):
"""Review M10: a row names the prompt as the Library does."""
_write_png(self.output_dir / "gen.png", prompt_text="a red square\nmore")
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.output_dir)]
summaries = self.api.db.get_image_link_summaries
def titled(paths, allow_filename_fallback=True):
found = summaries(paths, allow_filename_fallback)
return {
path: {**summary, "title": "Stored square title"}
for path, summary in found.items()
}
self.api.db.get_image_link_summaries = titled
status, started = await self._json("POST", "/prompt_manager/jobs/scan")
await asyncio.wait_for(self.api._scan_job.task, 10)
status, body = await self._json(
"GET", f"/prompt_manager/jobs/{started['job']['id']}"
)
(row,) = body["job"]["outcomes"]["added"]
self.assertIn("“Stored square title”", row["detail"])
# Without a stored title (or an unreadable one): the text's first line.
def broken(paths, allow_filename_fallback=True):
raise RuntimeError("unreadable")
self.api.db.get_image_link_summaries = broken
_write_png(self.output_dir / "next.png", prompt_text="a green cone\nmore")
status, started = await self._json("POST", "/prompt_manager/jobs/scan")
await asyncio.wait_for(self.api._scan_job.task, 10)
status, body = await self._json(
"GET", f"/prompt_manager/jobs/{started['job']['id']}"
)
details = {r["path"]: r["detail"] for r in body["job"]["outcomes"]["added"]}
self.assertIn("“a green cone”", details["next.png"])
async def test_jobs_routes_404_and_409(self):
_write_png(self.output_dir / "gen.png", prompt_text="a red square")
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.output_dir)]
for method, url in (
("GET", "/prompt_manager/jobs/nope"),
("GET", "/prompt_manager/jobs/nope/events"),
("POST", "/prompt_manager/jobs/nope/cancel"),
):
status, data = await self._json(method, url)
self.assertEqual(status, 404, url)
self.assertFalse(data["success"])
gate, calls = self._gate_collect()
status, started = await self._json("POST", "/prompt_manager/jobs/scan")
self.assertEqual(status, 202)
job_id = started["job"]["id"]
self.assertEqual(started["job"]["state"], "running")
status, busy = await self._json("POST", "/prompt_manager/jobs/scan")
self.assertEqual(status, 409)
self.assertEqual(
busy["running"], {"id": job_id, "title": started["job"]["title"]}
)
self.assertEqual((busy["success"], busy["code"]), (False, "busy"))
watcher = asyncio.ensure_future(
self.client.request("GET", f"/prompt_manager/jobs/{job_id}/events")
)
await asyncio.sleep(0.05)
status, cancel = await self._json(
"POST", f"/prompt_manager/jobs/{job_id}/cancel"
)
self.assertEqual((status, cancel["cancellation_requested"]), (200, True))
gate.set()
events = _sse_payloads(await (await watcher).text())
await asyncio.wait_for(self.api._scan_job.task, 10)
self.assertEqual(len(calls), 1)
self.assertEqual(events[0]["id"], job_id)
self.assertEqual(events[-1]["state"], "cancelled")
status, cancel = await self._json(
"POST", f"/prompt_manager/jobs/{job_id}/cancel"
)
self.assertEqual((status, cancel["cancellation_requested"]), (200, False))
status, legacy = await self._json("GET", "/prompt_manager/scan/status")
self.assertEqual(legacy["last_event"]["type"], "cancelled")
async def test_scan_without_directories_fails_the_job(self):
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.outside_dir)]
sys.modules.pop("folder_paths", None)
self.api._find_comfyui_output_dir = lambda: None
status, started = await self._json("POST", "/prompt_manager/jobs/scan")
await asyncio.wait_for(self.api._scan_job.task, 10)
status, data = await self._json(
"GET", f"/prompt_manager/jobs/{started['job']['id']}"
)
self.assertEqual(data["job"]["state"], "failed")
self.assertIn("No output directories", data["job"]["status_line"])
async def test_legacy_scan_refused_while_another_record_job_runs(self):
hold = asyncio.Event()
async def runner(job):
await hold.wait()
other = self.api._jobs.start(
"maintenance", "Maintenance", record_changing=True, scope={}, runner=runner
)
resp = await self.client.request("POST", "/prompt_manager/scan", data="{}")
self.assertEqual(resp.status, 409)
self.assertIn("Maintenance", (await resp.json())["error"])
hold.set()
await asyncio.wait_for(other.task, 5)
class TestScanStatusLine(unittest.TestCase):
def test_estimate_appears_after_two_batches(self):
from py.api.admin import _scan_status_line
self.assertEqual(
_scan_status_line(50, 200, 1, 10.0), "Processing file 50 of 200"
)
self.assertEqual(
_scan_status_line(100, 1000, 2, 30.0),
"Processing file 100 of 1,000 · about 5 min left",
)
self.assertEqual(
_scan_status_line(100, 120, 2, 1.0),
"Processing file 100 of 120 · about 1 min left",
)
self.assertEqual(
_scan_status_line(120, 120, 3, 9.0), "Processing file 120 of 120"
)
class TestWorkerThreadsSetting(AdminAPITestCase):
"""The worker-thread count is exposed, validated, persisted and used by the scan."""
+80
View File
@@ -279,6 +279,86 @@ class HistoryAPITests(ImageAPITestCase):
response, body, 409, "history-changed", {"action": "refresh-history"}
)
async def test_newest_pair_run_route_names_the_run_or_none(self):
pair = self.api.db.save_prompt_pair("positive", "negative")
quiet = self.api.db.save_prompt_pair("positive", "quiet")
foreign = self.api.db.save_prompt_pair("elsewhere", "negative")
positive = self.api.db.get_prompt_pair(pair)["positive_prompt_id"]
self.capture(0, 100)
newest = self.capture(1, 200)
base = f"/prompt_manager/library/{positive}/history/pairs"
response = await self.client.get(f"{base}/{pair}/newest")
body = await response.json()
self.assertEqual(response.status, 200, body)
self.assertEqual(response.headers.get("Cache-Control"), "no-store")
self.assertEqual(body["history_version"], 1)
self.assertEqual(body["history_epoch"], newest.storage_epoch)
self.assertEqual(body["run"]["run_id"], newest.run_id)
self.assertEqual(body["run"]["anchor_id"], "4")
response = await self.client.get(f"{base}/{quiet}/newest")
self.assertEqual((await response.json())["run"], None)
response = await self.client.get(f"{base}/{foreign}/newest")
self.assert_error(response, await response.json(), 404, "not-found")
response = await self.client.get(
f"/prompt_manager/library/999999/history/pairs/{pair}/newest"
)
self.assert_error(response, await response.json(), 404, "not-found")
for path in (f"{base}/0/newest", f"{base}/01/newest", f"{base}/true/newest"):
response = await self.client.get(path)
self.assert_error(response, await response.json(), 400, "invalid-request")
response = await self.client.get(f"{base}/{pair}/newest", params={"x": "1"})
self.assert_error(response, await response.json(), 400, "invalid-request")
async def test_newest_pair_run_route_reports_storage_and_epoch_failures(self):
pair = self.api.db.save_prompt_pair("positive", "negative")
saved = self.api.db.get_prompt_pair(pair)
self.capture(0, 100)
url = (
f"/prompt_manager/library/{saved['positive_prompt_id']}"
f"/history/pairs/{pair}/newest"
)
# A prompt saved only as a negative is not a Library positive.
response = await self.client.get(
f"/prompt_manager/library/{saved['negative_prompt_id']}"
f"/history/pairs/{pair}/newest"
)
self.assert_error(response, await response.json(), 404, "not-found")
private = "SECRET_PROMPT /private/data/secret.db"
for exc in (sqlite3.OperationalError("busy " + private), OSError(private)):
with patch.object(self.api.db, "get_newest_pair_run", side_effect=exc):
with self.assertLogs("prompt_manager.api", "WARNING") as logs:
response = await self.client.get(url)
body = await response.json()
self.assert_error(
response, body, 503, "storage-unavailable", {"action": "retry-read"}
)
self.assertNotIn(private, json.dumps(body) + " ".join(logs.output))
source = str(self.comfy_dir / "source.db")
self.assertTrue(self.api.db.model.backup_database(source))
writer = PromptDatabase(self.api.db.model.db_path)
original = self.api.db.get_newest_pair_run
def during_snapshot(*args, **kwargs):
result = original(*args, **kwargs)
with patch("database.execution_history.notify_history_restore"):
writer.model.restore_from_file(source)
return result
try:
with patch.object(
self.api.db, "get_newest_pair_run", side_effect=during_snapshot
):
response = await self.client.get(url)
self.assert_error(
response,
await response.json(),
409,
"history-changed",
{"action": "refresh-history"},
)
finally:
writer.close_all()
async def test_nullable_frozen_fields_and_every_recorded_time_remain_exact(self):
start = make_start(
SESSION,
+3 -1
View File
@@ -375,7 +375,9 @@ class TestLibraryAPI(ImageAPITestCase):
result["image"],
{"root_index": 0, "path": relative, "filename": "猫 % #.png"},
)
self.assertEqual(result["library"]["metadata_scope"], "pair")
# D39: the recipe has no category or tags, so both fall back.
self.assertEqual(result["library"]["category_scope"], "positive")
self.assertEqual(result["library"]["tags_scope"], "positive")
self.assertEqual(result["library"]["pair_id"], pair)
self.assertEqual(result["library"]["positive_id"], positive)
# D10/D20: the linked positive's title; "Pair title" names the variant.
+68 -3
View File
@@ -128,7 +128,12 @@ class TestPromptInspector(ImageAPITestCase):
for expected in ("ambiguous", "unresolved"):
source = graph()
if expected == "ambiguous":
source["4"] = source["3"]
# Branches with different prompts (#249: identical ones resolve).
source["5"] = {"class_type": "CLIPTextEncode", "inputs": {"text": "x"}}
source["4"] = {
"class_type": "KSampler",
"inputs": {"positive": ["5", 0], "negative": ["2", 0]},
}
else:
source["1"]["class_type"] = "UnknownEncoder"
png(
@@ -1302,6 +1307,64 @@ class TestPromptInspector(ImageAPITestCase):
)
roots.assert_called_once_with()
async def test_recipe_link_falls_back_to_positive_category_and_tags_per_field(
self,
):
# D39 ruling (2026-10-21): a recipe's own category and tags win when set;
# otherwise each field falls back to the linked positive's, separately, and
# the scopes say where each value came from (no merging of tag sets).
cases = (
("Recipe cat", ["recipe"], "Recipe cat", "pair", ["recipe"], "pair"),
(None, [], "Positive cat", "positive", ["positive"], "positive"),
(" ", [], "Positive cat", "positive", ["positive"], "positive"),
("Recipe cat", [], "Recipe cat", "pair", ["positive"], "positive"),
(None, ["recipe"], "Positive cat", "positive", ["recipe"], "pair"),
)
for index, case in enumerate(cases):
recipe_category, recipe_tags = case[:2]
category, category_scope, tags, tags_scope = case[2:]
with self.subTest(case=index):
text = f"Fallback owner {index}"
positive = self._save_prompt(
text, category="Positive cat", tags=["positive"]
)
pair = self.api.db.save_prompt_pair(
text,
f"Fallback noise {index}",
category=recipe_category,
tags=recipe_tags,
)
self.assertEqual(
self.api.db.get_prompt_pair(pair)["positive_prompt_id"], positive
)
filename = f"fallback-{index}.png"
path = png(self.output_dir / filename)
image = self.api.db.link_image_to_prompt(positive, str(path))
self.api.db.link_image_to_pair(pair, image, {"source": "fixture"})
response, result = await self.inspect(filename)
self.assertEqual(response.status, 200)
library = result["library"]
self.assertEqual(library["status"], "linked")
self.assertEqual(library["pair_id"], pair)
self.assertEqual(library["category"], category)
self.assertEqual(library["category_scope"], category_scope)
self.assertEqual(library["tags"], tags)
self.assertEqual(library["tags_scope"], tags_scope)
self.assertNotIn("metadata_scope", library)
async def test_positive_only_link_keeps_positive_scopes(self):
positive = self._save_prompt("Solo owner", tags=["solo"])
path = png(self.output_dir / "solo.png")
self.api.db.link_image_to_prompt(positive, str(path))
response, result = await self.inspect("solo.png")
self.assertEqual(response.status, 200)
library = result["library"]
self.assertEqual(library["pair_id"], None)
self.assertEqual(library["category"], None)
self.assertEqual(library["category_scope"], "positive")
self.assertEqual(library["tags"], ["solo"])
self.assertEqual(library["tags_scope"], "positive")
async def test_exact_saved_association_ambiguity_preserves_physical_file_evidence(
self,
):
@@ -1340,8 +1403,9 @@ class TestPromptInspector(ImageAPITestCase):
if count == 1:
self.assertEqual(result["library"]["positive_id"], positive)
self.assertEqual(
result["library"]["metadata_scope"], "positive"
result["library"]["category_scope"], "positive"
)
self.assertEqual(result["library"]["tags_scope"], "positive")
self.assertEqual(result["library"]["category"], "Category 1")
self.assertEqual(result["library"]["tags"], ["owner-1"])
if not readable:
@@ -1361,7 +1425,8 @@ class TestPromptInspector(ImageAPITestCase):
"title": None,
"category": None,
"tags": [],
"metadata_scope": None,
"category_scope": None,
"tags_scope": None,
},
)
for field in (
+20
View File
@@ -190,6 +190,26 @@ class TestWorkspaceImages(ImageAPITestCase):
self.assertEqual(data["images"], [])
self.assertIsNone(data["next_cursor"])
async def test_path_filter_lists_only_that_exact_file(self):
# Activity (D57): a scan row opens its file by root and exact relative path,
# never by a substring that also matches the same name in other folders.
self.media("ComfyUI_00001_.png")
for day in range(3):
self.media(f"2026-10-0{day + 1}/ComfyUI_00001_.png")
self.media("2026-10-01/ComfyUI_00001_.png.bak.png")
_, data = await self.get(path="ComfyUI_00001_.png")
self.assertEqual([i["path"] for i in data["images"]], ["ComfyUI_00001_.png"])
_, data = await self.get(path="2026-10-02/ComfyUI_00001_.png")
self.assertEqual(
[i["path"] for i in data["images"]], ["2026-10-02/ComfyUI_00001_.png"]
)
_, data = await self.get(path="missing.png")
self.assertEqual(data["images"], [])
for bad in ("../outside.png", "a" * 1025):
with self.subTest(bad=bad[:12]):
response, _ = await self.get(path=bad)
self.assertEqual(response.status, 400)
async def test_cursor_cannot_be_reused_with_changed_filters_or_tampered(self):
self.media("a.png")
self.media("b.png")
+83
View File
@@ -0,0 +1,83 @@
"""The page-workflow test server ends with its launcher (#274 review M2).
A Playwright worker that is SIGKILLed never runs its fixture teardown, so the
server it started must notice on its own: with ``--exit-on-stdin-eof`` it shuts
down when the launcher's end of its stdin pipe closes, and still removes its
temp root. This runs on every OS in CI, where Windows is the strict case.
"""
import os
import subprocess
import sys
import tempfile
import time
import unittest
import urllib.request
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
SERVER_TIMEOUT_S = 60
class TestBrowserServerLifetime(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
def start(self, *extra):
env = {**os.environ, "TMPDIR": self.tmp.name}
env.update(TEMP=self.tmp.name, TMP=self.tmp.name)
process = subprocess.Popen(
[sys.executable, "-m", "tests.browser.server", "--port", "0", *extra],
cwd=REPO_ROOT,
env=env,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
def stop():
if process.poll() is None:
process.kill()
process.wait(timeout=SERVER_TIMEOUT_S)
# communicate() would flush a closed stdin, which Python < 3.13 refuses.
for stream in (process.stdin, process.stdout, process.stderr):
stream.close()
self.addCleanup(stop)
line = process.stdout.readline().decode()
self.assertTrue(line.startswith("PM_TEST_SERVER_PORT="), line)
url = f"http://127.0.0.1:{int(line.split('=', 1)[1])}/prompt_manager/test"
deadline = time.monotonic() + SERVER_TIMEOUT_S
while True:
try:
with urllib.request.urlopen(url, timeout=1) as response: # nosec B310
self.assertEqual(response.status, 200)
return process
except OSError:
self.assertIsNone(process.poll(), "the server exited early")
self.assertLess(time.monotonic(), deadline, "never ready")
time.sleep(0.1)
def roots(self):
return sorted(path.name for path in Path(self.tmp.name).glob("pm-browser-*"))
def test_server_exits_and_removes_its_temp_root_when_stdin_closes(self):
process = self.start("--exit-on-stdin-eof")
self.assertEqual(len(self.roots()), 1, "the server works in its own temp root")
process.stdin.close()
code = process.wait(timeout=SERVER_TIMEOUT_S)
self.assertEqual(
code, 0, process.stderr.read().decode(errors="replace")[-4000:]
)
self.assertEqual(self.roots(), [], "the temp root and its database are removed")
def test_server_keeps_running_with_stdin_closed_without_the_flag(self):
process = self.start()
process.stdin.close()
with self.assertRaises(subprocess.TimeoutExpired):
process.wait(timeout=1)
if __name__ == "__main__":
unittest.main()
+88
View File
@@ -16,6 +16,7 @@ from tests.test_api_images import make_png
from database.operations import PromptDatabase
from py.config import GalleryConfig
from utils.execution_outputs import snapshot_output_roots
from utils.hashing import generate_prompt_hash
class CurrentLocationTests(unittest.TestCase):
@@ -331,6 +332,93 @@ class CurrentLocationTests(unittest.TestCase):
self.assertEqual(result[unique_locator]["library"]["status"], "limited")
self.assertIsNone(result[unique_locator]["library"].get("pair_id"))
def test_gallery_recipe_display_falls_back_to_positive_per_field(self):
# D39 ruling (2026-10-21), shared with the Images inspector.
mod = self.module("database.history_gallery")
db = PromptDatabase(str(self.base / "gallery.db"))
self.addCleanup(db.close_all)
cases = (
("Recipe cat", ["recipe"], ("Recipe cat", "pair", ["recipe"], "pair")),
(None, [], ("Positive cat", "positive", ["positive"], "positive")),
("Recipe cat", [], ("Recipe cat", "pair", ["positive"], "positive")),
("", ["recipe"], ("Positive cat", "positive", ["recipe"], "pair")),
)
for index, (recipe_category, recipe_tags, expected) in enumerate(cases):
with self.subTest(case=index):
text = f"owner {index}"
positive = db.save_prompt(
text,
prompt_hash=generate_prompt_hash(text),
category="Positive cat",
)
db.set_prompt_tags(positive, ["positive"])
pair = db.save_prompt_pair(
text, "noise", category=recipe_category, tags=recipe_tags
)
path = make_png(self.output / f"fallback-{index}.png")
locator = os.path.normcase(str(path))
image = db.link_image_to_prompt(positive, str(path))
db.link_image_to_pair(pair, image, {"source": "fixture"})
conn = db.model.get_connection()
with db.recorded_history_snapshot():
result = mod.read_history_gallery(conn, (locator,))
library = result[locator]["library"]
self.assertEqual(library["pair_id"], pair)
self.assertEqual(
(
library["category"],
library["category_scope"],
library["tags"],
library["tags_scope"],
),
expected,
)
def test_gallery_blank_recipe_category_and_skipped_positive_tags_query(self):
mod = self.module("database.history_gallery")
db = PromptDatabase(str(self.base / "blank.db"))
self.addCleanup(db.close_all)
cases = (
(" ", ["recipe"], 0),
("\u3000", [], 1),
("\u00a0 ", ["recipe"], 0),
)
for index, (blank, recipe_tags, positive_tag_queries) in enumerate(cases):
with self.subTest(case=index):
text = f"blank owner {index}"
positive = db.save_prompt(
text,
prompt_hash=generate_prompt_hash(text),
category="Positive cat",
)
db.set_prompt_tags(positive, ["positive"])
pair = db.save_prompt_pair(text, "noise", tags=recipe_tags)
path = make_png(self.output / f"blank-{index}.png")
locator = os.path.normcase(str(path))
image = db.link_image_to_prompt(positive, str(path))
db.link_image_to_pair(pair, image, {"source": "fixture"})
conn = db.model.get_connection()
# Legacy rows can hold a blank-looking category the writers now trim.
conn.execute(
"UPDATE prompt_pairs SET category=? WHERE id=?", (blank, pair)
)
conn.commit()
statements = []
conn.set_trace_callback(statements.append)
self.addCleanup(conn.set_trace_callback, None)
with db.recorded_history_snapshot():
result = mod.read_history_gallery(conn, (locator,))
conn.set_trace_callback(None)
library = result[locator]["library"]
self.assertEqual(library["category"], "Positive cat")
self.assertEqual(library["category_scope"], "positive")
tag_queries = [
statement
for statement in statements
if "FROM prompt_tags pt" in statement
]
self.assertEqual(len(tag_queries), positive_tag_queries, statements)
def test_host_root_failure_and_removal_do_not_search_another_root(self):
make_png(self.output / "image.png")
make_png(self.temp / "image.png")
+76
View File
@@ -913,3 +913,79 @@ class HistoryReadTests(unittest.TestCase):
db.list_history_outputs(
start.run_id, after=OutputPosition("0" * 64, (node, 0))
)
def test_newest_pair_run_follows_current_hints_in_the_raw_scope(self):
db = self.database()
pair_a = db.save_prompt_pair("positive", "A")
pair_b = db.save_prompt_pair("positive", "B")
quiet = db.save_prompt_pair("positive", "C")
other = db.save_prompt_pair("elsewhere", "A")
positive = db.get_prompt_pair(pair_a)["positive_prompt_id"]
self.capture(db, 0, bundle("A"), time=100)
newest = self.capture(db, 1, bundle("A"), time=200)
b = self.capture(db, 2, bundle("B"), time=300)
with db.recorded_history_snapshot():
found = db.get_newest_pair_run(positive, pair_a)
found_b = db.get_newest_pair_run(positive, pair_b)
none = db.get_newest_pair_run(positive, quiet)
foreign = db.get_newest_pair_run(positive, other)
missing = db.get_newest_pair_run(9999, pair_a)
self.assertEqual(found["run"]["run_id"], newest.run_id)
self.assertEqual(found["run"]["anchor_id"], "4")
self.assertRegex(found["run"]["context_id"], "^[0-9a-f]{64}$")
self.assertEqual(found_b["run"]["run_id"], b.run_id)
self.assertEqual(none, {"run": None})
self.assertIsNone(foreign)
self.assertIsNone(missing)
# A hint that no longer names the current pair is not followed.
db.delete_prompt_pair(pair_b)
recreated = db.save_prompt_pair("positive", "B")
with db.recorded_history_snapshot():
self.assertEqual(db.get_newest_pair_run(positive, recreated), {"run": None})
with self.assertRaises(sqlite3.ProgrammingError):
db.get_newest_pair_run(positive, pair_a)
def test_newest_pair_run_skips_stale_epoch_and_bounds_stale_hints(self):
db = self.database()
pair_a = db.save_prompt_pair("positive", "A")
pair_b = db.save_prompt_pair("positive", "B")
positive = db.get_prompt_pair(pair_a)["positive_prompt_id"]
older = self.capture(db, 0, bundle("A"), time=100)
stale_epoch = self.capture(db, 1, bundle("A"), time=200)
conn = db.model.get_connection()
# A row whose reference epoch is not the current one is never followed.
conn.execute(
"UPDATE execution_run_contexts SET reference_epoch=? WHERE run_id=?",
("00000000-0000-4000-8000-000000000000", stale_epoch.run_id),
)
conn.commit()
with db.recorded_history_snapshot():
self.assertEqual(
db.get_newest_pair_run(positive, pair_a)["run"]["run_id"],
older.run_id,
)
# Seventeen newer rows whose hint names pair A but whose texts resolve to
# pair B fill the candidate bound, so the older valid run is not reached.
for item in range(2, 19):
start = self.capture(db, item, bundle("B"), time=300 + item)
conn.execute(
"UPDATE execution_run_contexts SET pair_id=? WHERE run_id=?",
(pair_a, start.run_id),
)
conn.commit()
with db.recorded_history_snapshot():
self.assertEqual(db.get_newest_pair_run(positive, pair_a), {"run": None})
self.assertIsNotNone(db.get_newest_pair_run(positive, pair_b))
def test_newest_pair_run_rejects_an_ineligible_or_deleted_positive(self):
db = self.database()
pair = db.save_prompt_pair("positive", "negative")
saved = db.get_prompt_pair(pair)
self.capture(db, 0, bundle("negative"), time=100)
with db.recorded_history_snapshot():
# A prompt saved only as a pair's negative is not a Library positive.
self.assertIsNone(db.get_newest_pair_run(saved["negative_prompt_id"], pair))
db.delete_prompt_pair(pair)
db.delete_prompt(saved["positive_prompt_id"])
with db.recorded_history_snapshot():
self.assertIsNone(db.get_newest_pair_run(saved["positive_prompt_id"], pair))
+248
View File
@@ -0,0 +1,248 @@
"""Several sampler branches that share one prompt pair resolve to that pair (#249).
The shape is a LoRA epoch-compare grid: one sampler per LoRA epoch, every
sampler fed by the same positive ``CLIPTextEncode`` and the same
``ConditioningZeroOut`` negative, every decoded image labelled, batched and
concatenated into one grid that a single ``SaveImage`` writes. Which branch
made which tile does not matter to the prompt: every branch used the same pair.
Branches that differ stay ambiguous.
"""
import unittest
from utils import prompt_pair_resolver as resolver
from utils.prompt_inspector import inspect_prompt_evidence
from utils.prompt_pair_resolver import resolve_prompt_pair
POSITIVE = "a woman holding a coffee cup, in a beanie, sitting by a window"
OTHER = "a man reading a newspaper on a park bench"
NEGATIVE = "blurry, low quality"
# Each profile with the negative it can read: inspection reads the zero-out.
PROFILES = {
"default": (resolver.DEFAULT_PROFILE, NEGATIVE),
"inspection": (resolver.INSPECTION_PROFILE, None),
}
def _grid(epochs: int = 4, positives: dict = None, negative: str = None) -> dict:
"""A compare grid; ``positives`` maps an epoch to its own positive text.
The negative is a ``ConditioningZeroOut`` of the positive, or with
``negative`` an encoded text (the default profile cannot read a zero-out).
"""
graph = {
"1": {
"class_type": "UNETLoader",
"inputs": {"unet_name": "base.safetensors", "weight_dtype": "default"},
},
"2": {
"class_type": "CLIPLoader",
"inputs": {"clip_name": "te.safetensors", "type": "stable_diffusion"},
},
"3": {"class_type": "VAELoader", "inputs": {"vae_name": "vae.safetensors"}},
"4": {
"class_type": "CLIPTextEncode",
"inputs": {"text": POSITIVE, "clip": ["2", 0]},
},
"5": (
{"class_type": "ConditioningZeroOut", "inputs": {"conditioning": ["4", 0]}}
if negative is None
else {
"class_type": "CLIPTextEncode",
"inputs": {"text": negative, "clip": ["2", 0]},
}
),
"6": {
"class_type": "EmptyLatentImage",
"inputs": {"width": 512, "height": 512, "batch_size": 1},
},
}
batch = {}
for epoch in range(epochs):
base = 10 + epoch * 10
lora, sampler, decode, label = (str(base + offset) for offset in range(4))
positive = "4"
if positives and epoch in positives:
positive = str(base + 5)
graph[positive] = {
"class_type": "CLIPTextEncode",
"inputs": {"text": positives[epoch], "clip": ["2", 0]},
}
graph[lora] = {
"class_type": "LoraLoaderModelOnly",
"inputs": {
"lora_name": f"subject_epoch_{epoch:02d}.safetensors",
"strength_model": 1.0,
"model": ["1", 0],
},
}
graph[sampler] = {
"class_type": "KSampler",
"inputs": {
"seed": 42,
"steps": 8,
"cfg": 1.0,
"sampler_name": "euler",
"scheduler": "simple",
"denoise": 1.0,
"model": [lora, 0],
"positive": [positive, 0],
"negative": ["5", 0],
"latent_image": ["6", 0],
},
}
graph[decode] = {
"class_type": "VAEDecode",
"inputs": {"samples": [sampler, 0], "vae": ["3", 0]},
}
graph[label] = {
"class_type": "AddLabel",
"inputs": {"text": f"epoch {epoch}", "image": [decode, 0]},
}
batch[f"images.image{epoch}"] = [label, 0]
graph["900"] = {"class_type": "BatchImagesNode", "inputs": batch}
graph["901"] = {
"class_type": "ImageConcatFromBatch",
"inputs": {"num_columns": 2, "images": ["900", 0]},
}
graph["902"] = {
"class_type": "SaveImage",
"inputs": {"filename_prefix": "compare/grid", "images": ["901", 0]},
}
return graph
def _texts(result: dict) -> tuple:
return tuple(
result[role]["text"] if isinstance(result[role], dict) else None
for role in ("positive", "negative")
)
class TestIdenticalSamplerBranches(unittest.TestCase):
def test_grid_of_identical_branches_resolves_to_the_shared_pair(self):
for name, (profile, negative) in PROFILES.items():
with self.subTest(profile=name):
result = resolve_prompt_pair(
{"prompt": _grid(negative=negative)}, profile=profile
)
self.assertEqual(result["state"], "resolved")
self.assertEqual(_texts(result), (POSITIVE, negative or ""))
# No one branch is named: every branch made this pair.
self.assertIsNone(result["sampler_id"])
self.assertEqual(result["reason"], resolver.IDENTICAL_BRANCHES_REASON)
# Every branch stays listed as the evidence for the shared pair.
self.assertEqual(
[item["sampler_id"] for item in result["candidates"]],
["11", "21", "31", "41"],
)
self.assertEqual(
{_texts(item) for item in result["candidates"]},
{(POSITIVE, negative or "")},
)
self.assertEqual(result["reason_code"], None)
if negative is None:
self.assertEqual(
result["negative"]["provenance"]["text_source"],
"zeroed-conditioning",
)
def test_default_profile_keeps_an_unreadable_zero_out_grid_ambiguous(self):
# Ingestion cannot read ConditioningZeroOut: every branch is unresolved,
# so the grid stays ambiguous and the scan keeps its legacy fallback.
result = resolve_prompt_pair({"prompt": _grid()})
self.assertEqual(result["state"], "ambiguous")
self.assertEqual(result["reason"], "Multiple sampler branches")
self.assertEqual(
{item["state"] for item in result["candidates"]}, {"unresolved"}
)
def test_single_branch_grid_still_names_its_sampler(self):
for name, (profile, negative) in PROFILES.items():
with self.subTest(profile=name):
result = resolve_prompt_pair(
{"prompt": _grid(epochs=1, negative=negative)}, profile=profile
)
self.assertEqual(result["state"], "resolved")
self.assertEqual(result["sampler_id"], "11")
self.assertEqual(result["reason"], "")
def test_branches_with_different_prompts_stay_ambiguous(self):
for name, (profile, negative) in PROFILES.items():
with self.subTest(profile=name):
source = _grid(positives={2: OTHER}, negative=negative)
result = resolve_prompt_pair({"prompt": source}, profile=profile)
self.assertEqual(result["state"], "ambiguous")
self.assertEqual(result["reason"], "Multiple sampler branches")
self.assertIsNone(result["positive"])
self.assertEqual(
sorted(_texts(item)[0] for item in result["candidates"]),
sorted([POSITIVE] * 3 + [OTHER]),
)
def test_a_branch_that_cannot_be_read_keeps_the_grid_ambiguous(self):
for name, (profile, negative) in PROFILES.items():
with self.subTest(profile=name):
source = _grid(negative=negative)
source["31"]["inputs"]["negative"] = ["800", 0]
source["800"] = {"class_type": "MysteryConditioning", "inputs": {}}
result = resolve_prompt_pair({"prompt": source}, profile=profile)
self.assertEqual(result["state"], "ambiguous")
self.assertEqual(result["reason"], "Multiple sampler branches")
def test_identical_unreadable_branches_are_not_collapsed(self):
source = _grid(epochs=2)
source["5"] = {"class_type": "MysteryConditioning", "inputs": {}}
for name, (profile, _negative) in PROFILES.items():
with self.subTest(profile=name):
result = resolve_prompt_pair({"prompt": source}, profile=profile)
self.assertEqual(result["state"], "ambiguous")
def test_same_text_with_a_different_recipe_stays_ambiguous(self):
adapters = {
("RecipeText", 0): resolver.OutputAdapter(
"text", "conditioning", compose=True
)
}
source = _grid(epochs=2, negative=NEGATIVE)
source["21"]["inputs"]["positive"] = ["700", 0]
source["700"] = {
"class_type": "RecipeText",
"inputs": {"prepend_text": "a woman", "text": "holding a coffee cup"},
}
alone = resolve_prompt_pair(
{"prompt": source}, output_node_id="21", adapters=adapters
)
# Branch "10" reads the same effective text through a plain encoder.
source["4"]["inputs"]["text"] = alone["positive"]["text"]
result = resolve_prompt_pair({"prompt": source}, adapters=adapters)
self.assertEqual(result["state"], "ambiguous")
self.assertEqual(
{_texts(item) for item in result["candidates"]},
{(alone["positive"]["text"], NEGATIVE)},
)
self.assertEqual(
{item["positive"]["storage_text"] for item in result["candidates"]},
{alone["positive"]["text"], "holding a coffee cup"},
)
def test_inspector_shows_the_positive_and_an_explicitly_empty_negative(self):
evidence = inspect_prompt_evidence({"prompt": _grid()})
prompts = evidence["prompts"]
self.assertEqual(prompts["positive"]["status"], "resolved")
self.assertEqual(prompts["positive"]["text"], POSITIVE)
self.assertEqual(prompts["negative"]["status"], "resolved")
self.assertEqual(prompts["negative"]["text"], "")
self.assertEqual(prompts["negative"]["source"], "zeroed-conditioning")
self.assertEqual(evidence["resolution"]["state"], "resolved")
self.assertIsNone(evidence["resolution"]["sampler_id"])
def test_inspector_keeps_differing_grids_ambiguous(self):
evidence = inspect_prompt_evidence({"prompt": _grid(positives={0: OTHER})})
self.assertEqual(evidence["resolution"]["state"], "ambiguous")
self.assertEqual(evidence["prompts"]["positive"]["status"], "ambiguous")
self.assertNotIn(POSITIVE, str(evidence["prompts"]))
if __name__ == "__main__":
unittest.main()
+132
View File
@@ -292,6 +292,134 @@ class TestImageFavoriteApi(ImageAPITestCase):
self.assertEqual(data, {"success": False, "error": "Favorite update failed"})
class TestWorkspaceFavoritesFilter(ImageAPITestCase):
"""``favorites=1`` on the workspace listing: the Favorites page (#249 D51)."""
async def tearDownAsync(self):
self.api.db.close_all()
assert_closed(self, self._temp_db.name, self._output_tmp.name)
await super().tearDownAsync()
async def page(self, status=200, **params):
response = await self.client.get(
"/prompt_manager/images/output", params={"mode": "workspace", **params}
)
data = await response.json()
self.assertEqual(response.status, status, data)
return data
async def star(self, path, root_index=0, method="PUT"):
response = await self.client.request(
method, FAVORITE, json={"root_index": root_index, "path": path}
)
self.assertEqual(response.status, 200)
def make(self, *names):
for offset, name in enumerate(names):
target = make_png(self.output_dir / name)
os.utime(target, (1_700_000_000 + offset, 1_700_000_000 + offset))
async def test_lists_only_favorites_and_counts_them(self):
self.make("a.png", "sub/b.png", "c.png", "sub/d.png")
await self.star("a.png")
await self.star("sub/d.png")
data = await self.page(favorites="1")
self.assertEqual([i["path"] for i in data["images"]], ["sub/d.png", "a.png"])
self.assertEqual(data["total"], 2)
self.assertTrue(all(i["favorite"] is True for i in data["images"]))
# The nav count's read: limit=1 still reports every favorite's total.
self.assertEqual((await self.page(favorites="1", limit="1"))["total"], 2)
# Other filters intersect with it.
sub = await self.page(favorites="1", subfolder="sub")
self.assertEqual([i["path"] for i in sub["images"]], ["sub/d.png"])
self.assertEqual((await self.page(favorites="1", q="a.png"))["total"], 1)
self.assertEqual((await self.page(favorites="1", media="video"))["total"], 0)
# favorites=0 (and no parameter) is the whole listing.
self.assertEqual((await self.page(favorites="0"))["total"], 4)
self.assertEqual((await self.page())["total"], 4)
await self.star("a.png", method="DELETE")
self.assertEqual((await self.page(favorites="1"))["total"], 1)
async def test_cursor_pages_walk_only_favorites_in_every_order(self):
self.make("a.png", "b.png", "c.png", "d.png", "e.png")
for name in ("a.png", "c.png", "e.png"):
await self.star(name)
expected = {
"newest": ["e.png", "c.png", "a.png"],
"oldest": ["a.png", "c.png", "e.png"],
"name": ["a.png", "c.png", "e.png"],
"name-desc": ["e.png", "c.png", "a.png"],
"rating": ["e.png", "c.png", "a.png"],
}
for sort, names in expected.items():
seen, cursor = [], None
while True:
params = {"favorites": "1", "sort": sort, "limit": "1"}
if cursor:
params["cursor"] = cursor
data = await self.page(**params)
self.assertEqual(data["total"], 3, sort)
seen += [i["path"] for i in data["images"]]
cursor = data["next_cursor"]
if not data["has_more"]:
break
self.assertEqual(seen, names, sort)
# A cursor is bound to its filters: a whole-listing cursor is refused.
whole = await self.page(limit="1")
await self.page(400, favorites="1", cursor=whole["next_cursor"])
async def test_favorites_follow_their_own_root(self):
second = self.comfy_dir / "second"
make_png(second / "a.png")
make_png(self.output_dir / "a.png")
self.api._get_all_output_dirs = lambda: [self.output_dir, second]
await self.star("a.png", root_index=1)
data = await self.page(favorites="1")
self.assertEqual(
[(i["root_index"], i["path"]) for i in data["images"]], [(1, "a.png")]
)
self.assertEqual((await self.page(favorites="1", root="0"))["total"], 0)
self.assertEqual((await self.page(favorites="1", root="1"))["total"], 1)
async def test_windows_case_folded_favorite_is_listed(self):
make_png(self.output_dir / "Sub" / "A.png")
with patch("py.api.images._path_flavor", ntpath):
await self.star("Sub/A.png")
(self.output_dir / "Sub" / "A.png").rename(
self.output_dir / "Sub" / "a.png"
)
(self.output_dir / "Sub").rename(self.output_dir / "sub")
self.api.invalidate_gallery_cache()
data = await self.page(favorites="1")
self.assertEqual([i["path"] for i in data["images"]], ["sub/a.png"])
async def test_a_favorite_whose_file_is_gone_is_not_listed(self):
self.make("a.png", "b.png")
await self.star("a.png")
await self.star("b.png")
(self.output_dir / "b.png").unlink()
self.api.invalidate_gallery_cache()
data = await self.page(favorites="1")
self.assertEqual([i["path"] for i in data["images"]], ["a.png"])
self.assertEqual(data["total"], 1)
async def test_rejects_other_values(self):
for value in ("true", "2", "", "yes"):
data = await self.page(400, favorites=value)
self.assertFalse(data["success"])
async def test_store_failure_is_an_error_not_an_unfiltered_page(self):
self.make("a.png")
def broken(*_args, **_kwargs):
raise sqlite3.OperationalError("database is locked")
self.api.db.get_root_favorites = broken
data = await self.page(500, favorites="1")
self.assertFalse(data["success"])
self.assertNotIn("images", data)
class TestImageFavoriteSchema(unittest.TestCase):
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
@@ -314,6 +442,10 @@ class TestImageFavoriteSchema(unittest.TestCase):
db.get_image_favorites("output", ["a.png", "b.png"]), {"a.png"}
)
self.assertEqual(db.get_image_favorites("output", []), set())
# One root's favorites, read through the primary key.
self.assertEqual(db.get_root_favorites("output"), {"a.png"})
self.assertEqual(db.get_root_favorites("other"), {"a.png"})
self.assertEqual(db.get_root_favorites("missing"), set())
many = [f"{n}.png" for n in range(2000)] + ["a.png"]
self.assertEqual(db.get_image_favorites("output", many), {"a.png"})
self.assertTrue(db.clear_image_favorite("output", "a.png"))
+919
View File
@@ -0,0 +1,919 @@
"""Show in folder (TESTING.md D38): the local-only reveal route and its openers.
No test launches a file manager: ``subprocess.Popen``, ``sys.platform`` and
``shutil.which`` are mocked, and route tests swap the API's opener.
"""
import ctypes
import json
import logging
import os
import subprocess
import sys
import tempfile
import types
import unittest
from types import SimpleNamespace
from pathlib import Path
from unittest.mock import MagicMock, PropertyMock, patch
from aiohttp import web
from multidict import CIMultiDict
try:
from test_api_images import ImageAPITestCase, make_png
except ImportError:
from tests.test_api_images import ImageAPITestCase, make_png
import utils.file_reveal as file_reveal
from utils.file_reveal import (
OpenerUnavailable,
is_local_request,
reveal_in_file_manager,
)
REVEAL = "/prompt_manager/images/reveal"
PROXY_HEADERS = (
"Forwarded",
"Via",
"X-Forwarded-For",
"X-Forwarded-Host",
"X-Forwarded-Proto",
"X-Forwarded-Port",
"X-Forwarded-Server",
"X-Real-IP",
"X-Original-Forwarded-For",
"X-Original-Host",
"CF-Connecting-IP",
"True-Client-IP",
"X-Client-IP",
"X-Cluster-Client-IP",
"Fastly-Client-IP",
)
ORIGIN = "http://127.0.0.1:8188"
def headers(**values):
base = {"Host": "127.0.0.1:8188", "Origin": ORIGIN}
base.update({key.replace("_", "-"): value for key, value in values.items()})
return CIMultiDict({key: value for key, value in base.items() if value is not None})
class TestIsLocalRequest(unittest.TestCase):
def test_same_origin_loopback_clients_by_loopback_name_are_local(self):
for remote, host, origin in (
("127.0.0.1", "127.0.0.1:8188", ORIGIN),
("127.8.9.10", "localhost:8188", "http://localhost:8188"),
("::1", "[::1]:8188", "http://[::1]:8188"),
("::ffff:127.0.0.1", "comfy.localhost", "http://comfy.localhost"),
("127.0.0.1", "LOCALHOST:8188", "http://localhost:8188"),
("127.0.0.1", "localhost", "http://localhost:80"),
):
with self.subTest(remote=remote, host=host):
self.assertTrue(
is_local_request(remote, headers(Host=host, Origin=origin))
)
self.assertTrue(
is_local_request(
"127.0.0.1",
headers(Host="localhost", Origin="https://localhost:443"),
"https",
)
)
def test_non_loopback_or_unknown_remote_is_not_local(self):
for remote in ("192.168.1.5", "10.0.0.1", "::2", "fe80::1%eth0", "", None):
with self.subTest(remote=remote):
self.assertFalse(is_local_request(remote, headers()))
def test_any_forwarding_header_makes_loopback_remote(self):
for name in (*PROXY_HEADERS, "X-Forwarded-Anything", "X-Original-URI"):
for spelling in (name, name.lower()):
with self.subTest(header=spelling):
request_headers = headers()
request_headers[spelling] = "127.0.0.1"
self.assertFalse(is_local_request("127.0.0.1", request_headers))
def test_origin_is_required_and_must_be_the_request_origin(self):
# A header-less proxy or curl leaves Origin out; a page on another
# loopback port or scheme sends its own (security review M1, L2).
self.assertFalse(is_local_request("127.0.0.1", headers(Origin=None)))
for origin in (
"",
"null",
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://127.0.0.1",
"https://127.0.0.1:8188",
"http://localhost:8188",
"http://foo.localhost:8188",
"http://evil.example:8188",
"http://[bad",
"http://127.0.0.1:8188/path",
"http://user@127.0.0.1:8188",
"file://127.0.0.1:8188",
):
with self.subTest(origin=origin):
self.assertFalse(is_local_request("127.0.0.1", headers(Origin=origin)))
self.assertFalse(is_local_request("127.0.0.1", headers(Origin=ORIGIN), "https"))
def test_sec_fetch_site_must_be_same_origin_when_sent(self):
self.assertTrue(
is_local_request("127.0.0.1", headers(Sec_Fetch_Site="same-origin"))
)
for site in ("same-site", "cross-site", "none", ""):
with self.subTest(site=site):
self.assertFalse(
is_local_request("127.0.0.1", headers(Sec_Fetch_Site=site))
)
def test_foreign_host_is_not_local(self):
# A DNS-rebinding page reaches 127.0.0.1 under its own name.
for host in ("evil.example:8188", "", "[bad", "127.0.0.1:bad"):
with self.subTest(host=host):
self.assertFalse(
is_local_request(
"127.0.0.1", headers(Host=host, Origin=f"http://{host}")
)
)
self.assertFalse(is_local_request("127.0.0.1", CIMultiDict({"Origin": ORIGIN})))
class TestRevealGate(unittest.TestCase):
def test_one_reveal_at_a_time_then_a_cooldown(self):
now = [100.0]
gate = file_reveal.RevealGate(cooldown=1.0, clock=lambda: now[0])
self.assertTrue(gate.try_enter())
self.assertFalse(gate.try_enter())
now[0] += 5
self.assertFalse(gate.try_enter())
gate.leave()
now[0] += 0.5
self.assertFalse(gate.try_enter())
now[0] += 0.5
self.assertTrue(gate.try_enter())
def test_zero_cooldown_only_blocks_while_busy(self):
gate = file_reveal.RevealGate(cooldown=0)
self.assertTrue(gate.try_enter())
self.assertFalse(gate.try_enter())
gate.leave()
self.assertTrue(gate.try_enter())
class TestRevealInFileManager(unittest.TestCase):
def setUp(self):
temp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.addCleanup(temp.cleanup)
self.root = Path(os.path.realpath(temp.name)) / "out"
self.path = self.root / "sub" / "a.png"
self.path.parent.mkdir(parents=True)
self.path.write_bytes(b"png")
self.popen = patch.object(subprocess, "Popen").start()
self.reaped = patch.object(file_reveal, "_reap").start()
self.addCleanup(patch.stopall)
def reveal(self, path=None):
reveal_in_file_manager(self.path if path is None else path, self.root)
def which(self, available):
def lookup(name, path=None):
self.assertEqual(path, file_reveal.SYSTEM_PATH)
return available.get(name)
return patch.object(file_reveal.shutil, "which", side_effect=lookup)
def argv(self, call=0):
return self.popen.call_args_list[call].args[0]
def assert_detached(self, call=0):
options = self.popen.call_args_list[call].kwargs
for stream in ("stdin", "stdout", "stderr"):
self.assertIs(options[stream], subprocess.DEVNULL)
self.assertIs(options["close_fds"], True)
self.assertIs(options["start_new_session"], True)
self.assertNotIn("shell", options)
self.assertEqual(options["env"]["PATH"], file_reveal.SYSTEM_PATH)
def windows(self, selected=True, startfile=None):
"""Windows without Windows: the shell libraries and os.startfile
injected; the patched shell records its calls."""
shell = FakeShell(select=0 if selected else -2147467259)
select = patch.object(
file_reveal,
"_windows_libraries",
return_value=(shell.ole32, shell.shell32),
)
select.shell = shell
start = patch.object(
file_reveal.os, "startfile", startfile or MagicMock(), create=True
)
platform = patch.object(file_reveal.sys, "platform", "win32")
return select, start, platform
def test_windows_selects_through_the_shell_api_without_a_process(self):
# A comma path splits Explorer's own command line (security review H1);
# the shell API takes the path as one value, unchanged.
path = self.root / "a," / "b,c.png"
path.parent.mkdir()
path.write_bytes(b"png")
select, start, platform = self.windows(selected=True)
with select, start as startfile, platform:
self.reveal(path)
self.assertEqual(dict(select.shell.calls)["ILCreateFromPathW"], (str(path),))
startfile.assert_not_called()
self.popen.assert_not_called()
def test_windows_falls_back_to_opening_the_folder(self):
select, start, platform = self.windows(selected=False)
with select, start as startfile, platform:
self.reveal()
self.assertEqual(
dict(select.shell.calls)["ILCreateFromPathW"], (str(self.path),)
)
startfile.assert_called_once_with(str(self.path.parent), "explore")
self.popen.assert_not_called()
def test_windows_folder_start_failure_raises(self):
select, start, platform = self.windows(
selected=False, startfile=MagicMock(side_effect=OSError("denied"))
)
with select, start, platform, self.assertRaises(OSError):
self.reveal()
self.popen.assert_not_called()
def test_windows_without_startfile_is_unavailable(self):
with (
patch.object(file_reveal, "_windows_libraries", side_effect=OSError),
patch.object(file_reveal.sys, "platform", "win32"),
patch.object(file_reveal.os, "startfile", None, create=True),
self.assertRaises(OpenerUnavailable),
):
self.reveal()
self.popen.assert_not_called()
def test_macos_reveals_with_the_system_open(self):
with (
patch.object(file_reveal.sys, "platform", "darwin"),
patch.object(file_reveal, "_macos_open", return_value="/usr/bin/open"),
self.which({"open": "/planted/open"}),
):
self.reveal()
self.assertEqual(self.argv(), ["/usr/bin/open", "-R", str(self.path)])
self.assert_detached()
self.popen.return_value.wait.assert_not_called()
self.reaped.assert_called_once_with(self.popen.return_value)
def test_macos_open_is_the_fixed_system_path(self):
self.assertEqual(file_reveal.MACOS_OPEN, "/usr/bin/open")
with patch.object(file_reveal.os.path, "isfile", return_value=False):
self.assertIsNone(file_reveal._macos_open())
with (
patch.object(file_reveal.os.path, "isfile", return_value=True),
patch.object(file_reveal.os, "access", return_value=True),
):
self.assertEqual(file_reveal._macos_open(), "/usr/bin/open")
with (
patch.object(file_reveal.sys, "platform", "darwin"),
patch.object(file_reveal, "_macos_open", return_value=None),
self.assertRaises(OpenerUnavailable),
):
self.reveal()
self.popen.assert_not_called()
def test_relative_path_lookup_never_counts(self):
with (
patch.object(file_reveal.sys, "platform", "linux"),
self.which({"dbus-send": "./dbus-send", "xdg-open": "bin/xdg-open"}),
self.assertRaises(OpenerUnavailable),
):
self.reveal()
self.popen.assert_not_called()
def test_posix_opener_lookup_does_not_depend_on_the_host_path_rules(self):
# Python 3.13 on Windows no longer treats "/usr/bin/x" as absolute. The
# Linux and macOS openers are POSIX paths whatever host runs the code,
# so a host os.path with those rules must not reject them.
windows_313 = SimpleNamespace(isabs=lambda value: False)
with (
patch.object(file_reveal.os, "path", windows_313),
self.which({"xdg-open": "/usr/bin/xdg-open", "rel": "bin/rel"}),
):
self.assertEqual(file_reveal._executable("xdg-open"), "/usr/bin/xdg-open")
self.assertIsNone(file_reveal._executable("rel"))
def test_children_get_a_minimal_environment(self):
inherited = {
"PATH": "/venv/bin:/usr/bin",
"PYTHONPATH": "/comfy",
"LD_LIBRARY_PATH": "/venv/lib",
"VIRTUAL_ENV": "/venv",
"CIVITAI_API_KEY": "secret",
"HOME": "/home/u",
"DISPLAY": ":0",
"WAYLAND_DISPLAY": "wayland-0",
"XDG_RUNTIME_DIR": "/run/user/1000",
"XDG_CURRENT_DESKTOP": "KDE",
"DBUS_SESSION_BUS_ADDRESS": "unix:path=/run/user/1000/bus",
"LANG": "en_US.UTF-8",
# What a desktop opener can need (final review m1): the X11
# cookie under GDM/SDDM, the session and the user's mimeapps.
"XAUTHORITY": "/run/user/1000/gdm/Xauthority",
"DESKTOP_SESSION": "plasma",
"XDG_SESSION_DESKTOP": "KDE",
"XDG_CONFIG_HOME": "/home/u/.config",
"XDG_DATA_DIRS": "/usr/share",
}
with patch.dict(os.environ, inherited, clear=True):
env = file_reveal._child_env()
self.assertEqual(
env,
{
"PATH": file_reveal.SYSTEM_PATH,
"HOME": "/home/u",
"DISPLAY": ":0",
"WAYLAND_DISPLAY": "wayland-0",
"XDG_RUNTIME_DIR": "/run/user/1000",
"XDG_CURRENT_DESKTOP": "KDE",
"DBUS_SESSION_BUS_ADDRESS": "unix:path=/run/user/1000/bus",
"LANG": "en_US.UTF-8",
"XAUTHORITY": "/run/user/1000/gdm/Xauthority",
"DESKTOP_SESSION": "plasma",
"XDG_SESSION_DESKTOP": "KDE",
"XDG_CONFIG_HOME": "/home/u/.config",
"XDG_DATA_DIRS": "/usr/share",
},
)
self.assertNotIn("/venv", file_reveal.SYSTEM_PATH)
def test_linux_asks_the_file_manager_service_and_waits_for_its_reply(self):
self.popen.return_value.wait.return_value = 0
with (
patch.object(file_reveal.sys, "platform", "linux"),
self.which({"dbus-send": "/usr/bin/dbus-send", "xdg-open": "/x"}),
):
self.reveal()
self.assertEqual(
self.argv(),
[
"/usr/bin/dbus-send",
"--session",
"--print-reply",
"--reply-timeout=2000",
"--dest=org.freedesktop.FileManager1",
"--type=method_call",
"/org/freedesktop/FileManager1",
"org.freedesktop.FileManager1.ShowItems",
f"array:string:{self.path.as_uri()}",
"string:",
],
)
self.assertEqual(self.popen.call_count, 1)
self.popen.return_value.wait.assert_called_once_with(
timeout=file_reveal.DBUS_WAIT
)
self.assert_detached()
self.reaped.assert_not_called()
def test_linux_falls_back_to_xdg_open_on_the_folder(self):
both = {"dbus-send": "/usr/bin/dbus-send", "xdg-open": "/usr/bin/xdg-open"}
cases = {
"missing": ({"xdg-open": "/usr/bin/xdg-open"}, None),
"no reply": (both, 1),
"start error": (both, OSError("no bus")),
"hung": (both, subprocess.TimeoutExpired("dbus-send", 3)),
}
for name, (available, outcome) in cases.items():
with self.subTest(name):
self.popen.reset_mock(side_effect=True, return_value=True)
self.reaped.reset_mock()
dbus = MagicMock()
if isinstance(outcome, OSError):
self.popen.side_effect = [outcome, MagicMock()]
elif isinstance(outcome, subprocess.TimeoutExpired):
dbus.wait.side_effect = [outcome, None]
self.popen.side_effect = [dbus, MagicMock()]
else:
self.popen.return_value.wait.return_value = outcome
with (
patch.object(file_reveal.sys, "platform", "linux"),
self.which(available),
):
self.reveal()
self.assertEqual(
self.popen.call_args.args[0],
["/usr/bin/xdg-open", str(self.path.parent)],
)
self.assert_detached(self.popen.call_count - 1)
self.reaped.assert_called_once()
if name == "hung":
dbus.kill.assert_called_once_with()
def test_linux_without_any_opener_is_unavailable(self):
with (
patch.object(file_reveal.sys, "platform", "linux"),
self.which({}),
self.assertRaises(OpenerUnavailable),
):
self.reveal()
self.popen.assert_not_called()
def test_relative_paths_are_refused(self):
with self.assertRaises(ValueError):
reveal_in_file_manager(Path("sub/a.png"), self.root)
self.popen.assert_not_called()
def test_containment_is_rechecked_just_before_launching(self):
# A parent swapped for a symlink after the route resolved the path
# (security review L4) must not open a folder outside the root.
outside = self.root.parent / "elsewhere"
outside.mkdir()
(outside / "a.png").write_bytes(b"png")
(self.root / "sub" / "a.png").unlink()
(self.root / "sub").rmdir()
try:
os.symlink(outside, self.root / "sub", target_is_directory=True)
except (OSError, NotImplementedError):
self.skipTest("symlinks unavailable")
missing = self.root / "gone.png"
folder = self.root / "folder.png"
folder.mkdir()
for path in (self.path, missing, folder):
for platform in ("linux", "darwin", "win32"):
with (
self.subTest(path=path.name, platform=platform),
patch.object(file_reveal.sys, "platform", platform),
patch.object(file_reveal, "_windows_libraries") as shell,
self.assertRaises(file_reveal.RevealTargetChanged),
):
self.reveal(path)
shell.assert_not_called()
self.popen.assert_not_called()
def test_a_path_on_another_drive_is_not_contained(self):
# Windows: commonpath raises ValueError across drives.
with (
patch.object(file_reveal.os.path, "commonpath", side_effect=ValueError),
self.assertRaises(file_reveal.RevealTargetChanged),
):
self.reveal()
self.popen.assert_not_called()
class TestReaping(unittest.TestCase):
def test_reap_waits_on_a_daemon_thread(self):
process = MagicMock()
with patch.object(file_reveal.threading, "Thread") as thread:
file_reveal._reap(process)
thread.assert_called_once()
kwargs = thread.call_args.kwargs
self.assertIs(kwargs["target"], process.wait)
self.assertIs(kwargs["daemon"], True)
thread.return_value.start.assert_called_once_with()
def test_reveal_runs_on_its_own_single_worker(self):
executor = file_reveal.reveal_executor()
self.assertIs(file_reveal.reveal_executor(), executor)
self.assertEqual(executor._max_workers, 1)
name = executor.submit(lambda: file_reveal.threading.current_thread().name)
self.assertTrue(name.result(timeout=5).startswith("pm-reveal"))
class FakeShell:
"""ole32 and shell32 stand-ins recording the shell calls (H1)."""
def __init__(self, init=0, pidl=1234, select=0):
self.calls = []
self.ole32 = types.SimpleNamespace(
CoInitializeEx=self.fn("CoInitializeEx", init),
CoUninitialize=self.fn("CoUninitialize", None),
)
self.shell32 = types.SimpleNamespace(
ILCreateFromPathW=self.fn("ILCreateFromPathW", pidl),
SHOpenFolderAndSelectItems=self.fn("SHOpenFolderAndSelectItems", select),
ILFree=self.fn("ILFree", None),
)
def fn(self, name, result):
def call(*args):
self.calls.append((name, args))
if isinstance(result, Exception):
raise result
return result
return MagicMock(side_effect=call)
def names(self):
return [name for name, _ in self.calls]
class TestWindowsReveal(unittest.TestCase):
"""The Windows opener with the shell libraries and os.startfile faked.
The folder fallback is recorded in the same call list, so its place
inside the COM bracket is visible (final review m2)."""
def reveal(self, shell, path=r"C:\out\a,\b.png"):
def startfile(*args):
shell.calls.append(("startfile", args))
with (
patch.object(
file_reveal,
"_windows_libraries",
return_value=(shell.ole32, shell.shell32),
),
patch.object(
file_reveal.os,
"startfile",
MagicMock(side_effect=startfile),
create=True,
),
):
file_reveal._reveal_windows(Path(path))
return shell
def test_selects_the_item_and_frees_everything(self):
path = r"C:\out\a,\Windows\System32\calc.exe,b\x.png"
shell = self.reveal(FakeShell(), path)
self.assertEqual(
shell.names(),
[
"CoInitializeEx",
"ILCreateFromPathW",
"SHOpenFolderAndSelectItems",
"ILFree",
"CoUninitialize",
],
)
self.assertEqual(dict(shell.calls)["ILCreateFromPathW"], (str(Path(path)),))
self.assertEqual(
dict(shell.calls)["SHOpenFolderAndSelectItems"], (1234, 0, None, 0)
)
self.assertEqual(dict(shell.calls)["ILFree"], (1234,))
def test_declares_the_shell_signatures(self):
shell = self.reveal(FakeShell())
create = shell.shell32.ILCreateFromPathW
self.assertEqual(create.argtypes, [ctypes.c_wchar_p])
self.assertIs(create.restype, ctypes.c_void_p)
open_select = shell.shell32.SHOpenFolderAndSelectItems
self.assertEqual(
open_select.argtypes,
[ctypes.c_void_p, ctypes.c_uint, ctypes.c_void_p, ctypes.c_ulong],
)
self.assertIs(open_select.restype, ctypes.c_long)
self.assertEqual(shell.shell32.ILFree.argtypes, [ctypes.c_void_p])
self.assertEqual(
shell.ole32.CoInitializeEx.argtypes, [ctypes.c_void_p, ctypes.c_ulong]
)
self.assertIs(shell.ole32.CoInitializeEx.restype, ctypes.c_long)
def test_already_initialized_com_is_used_and_left_alone(self):
shell = self.reveal(FakeShell(init=file_reveal.RPC_E_CHANGED_MODE))
self.assertIn("SHOpenFolderAndSelectItems", shell.names())
self.assertNotIn("CoUninitialize", shell.names())
self.assertNotIn("startfile", shell.names())
shell = self.reveal(FakeShell(init=file_reveal.S_FALSE))
self.assertNotIn("startfile", shell.names())
self.assertEqual(shell.names()[-1], "CoUninitialize")
def test_the_folder_fallback_runs_inside_com(self):
# ShellExecute callers should initialize COM first (final review m2).
for name, shell in {
"no pidl": FakeShell(pidl=None),
"select failed": FakeShell(select=-2147467259),
"raises": FakeShell(select=OSError("access violation")),
}.items():
with self.subTest(name):
names = self.reveal(shell).names()
self.assertEqual(names[0], "CoInitializeEx")
self.assertEqual(names[-2:], ["startfile", "CoUninitialize"])
if name != "no pidl":
self.assertIn("ILFree", names)
def test_failed_com_still_opens_the_folder_without_balancing(self):
for init in (-2147024882, OSError("no ole32 export")):
with self.subTest(init=init):
names = self.reveal(FakeShell(init=init)).names()
self.assertEqual(names, ["CoInitializeEx", "startfile"])
def test_missing_libraries_open_the_folder(self):
startfile = MagicMock()
with (
patch.object(file_reveal, "_windows_libraries", side_effect=OSError),
patch.object(file_reveal.os, "startfile", startfile, create=True),
):
file_reveal._reveal_windows(Path(r"C:\out\a.png"))
startfile.assert_called_once_with(str(Path(r"C:\out\a.png").parent), "explore")
def test_libraries_are_unavailable_off_windows(self):
if sys.platform == "win32":
self.skipTest("loads the real shell libraries on Windows")
with self.assertRaises((AttributeError, OSError)):
file_reveal._windows_libraries()
@unittest.skipUnless(sys.platform == "win32", "the real Windows shell libraries")
class TestRealWindowsShell(unittest.TestCase):
"""Final review I1: the declared signatures against the real ole32 and
shell32, without SHOpenFolderAndSelectItems (no window opens on CI)."""
def setUp(self):
self.ole32, self.shell32 = file_reveal._windows_libraries()
file_reveal._declare_shell_signatures(self.ole32, self.shell32)
def test_a_pidl_is_created_for_a_real_file_and_freed(self):
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as folder:
path = Path(folder) / "a,b.png"
path.write_bytes(b"png") # write_bytes closes its handle
pidl = self.shell32.ILCreateFromPathW(str(path))
self.assertIsInstance(pidl, int)
self.assertNotEqual(pidl, 0)
self.shell32.ILFree(pidl)
self.assertIsNone(self.shell32.ILCreateFromPathW(str(path) + ".gone"))
path.unlink()
def probe(self):
"""CoInitializeEx's answer on this thread, balanced at once."""
result = self.ole32.CoInitializeEx(
None,
file_reveal.COINIT_APARTMENTTHREADED | file_reveal.COINIT_DISABLE_OLE1DDE,
)
if result in (file_reveal.S_OK, file_reveal.S_FALSE):
self.ole32.CoUninitialize()
return result
def test_com_is_entered_and_balanced_through_the_module_helper(self):
before = self.probe()
with file_reveal._com_apartment(self.ole32) as ready:
self.assertIs(ready, True)
# An unbalanced bracket would leave COM initialized: S_FALSE after S_OK.
self.assertEqual(self.probe(), before)
class TestRevealRoute(ImageAPITestCase):
async def get_application(self):
app = await super().get_application()
self.opened, self.roots, self.threads = [], [], []
self.outcome = None
def opener(path, root):
self.threads.append(file_reveal.threading.current_thread().name)
if self.outcome is not None:
raise self.outcome
self.opened.append(path)
self.roots.append(root)
self.api._reveal_opener = opener
self.api._reveal_gate = file_reveal.RevealGate(cooldown=0)
return app
def origin(self):
return str(self.client.make_url("/").origin())
def scope(self, index=0):
roots = self.api._get_all_output_dirs()
return self.api._gallery_root_scope(roots[index], index)
async def post(self, body, **request_headers):
# Like the workspace's fetch: a same-origin JSON POST.
sent = {"Origin": self.origin(), **request_headers}
sent = {key: value for key, value in sent.items() if value is not None}
response = await self.client.post(REVEAL, json=body, headers=sent)
return response.status, await response.json()
def body(self, path="sub/a.png", **changes):
return {"root_index": 0, "root_scope": self.scope(), "path": path, **changes}
async def test_loopback_request_opens_the_resolved_file(self):
target = make_png(self.output_dir / "sub" / "a.png")
status, data = await self.post(self.body())
self.assertEqual((status, data), (200, {"success": True}))
self.assertEqual(self.opened, [Path(os.path.realpath(target))])
self.assertEqual(self.roots, [self.output_dir])
# Its own worker, never ComfyUI's shared default executor (L1).
self.assertTrue(self.threads[0].startswith("pm-reveal"), self.threads)
async def test_target_changed_before_launch_is_404(self):
make_png(self.output_dir / "sub" / "a.png")
self.outcome = file_reveal.RevealTargetChanged()
status, data = await self.post(self.body())
self.assertEqual(status, 404)
self.assertEqual(data, {"success": False, "error": "Image file not found"})
async def test_non_loopback_remote_is_not_local(self):
make_png(self.output_dir / "sub" / "a.png")
with patch.object(
web.BaseRequest, "remote", new_callable=PropertyMock
) as remote:
remote.return_value = "192.168.1.20"
status, data = await self.post(self.body())
self.assertEqual(status, 403)
self.assertEqual(data["code"], "not-local")
self.assertIs(data["success"], False)
self.assertEqual(self.opened, [])
async def test_loopback_with_a_proxy_header_is_not_local(self):
make_png(self.output_dir / "sub" / "a.png")
for name in PROXY_HEADERS:
with self.subTest(header=name):
status, data = await self.post(self.body(), **{name: "127.0.0.1"})
self.assertEqual((status, data["code"]), (403, "not-local"))
status, data = await self.post(self.body(), Host="evil.example:8188")
self.assertEqual((status, data["code"]), (403, "not-local"))
self.assertEqual(self.opened, [])
async def test_missing_or_foreign_origin_is_not_local(self):
make_png(self.output_dir / "sub" / "a.png")
port = self.client.make_url("/").port
for origin in (None, f"http://127.0.0.1:{port + 1}", "null"):
with self.subTest(origin=origin):
status, data = await self.post(self.body(), Origin=origin)
self.assertEqual((status, data["code"]), (403, "not-local"))
status, data = await self.post(self.body(), **{"Sec-Fetch-Site": "same-site"})
self.assertEqual((status, data["code"]), (403, "not-local"))
status, _ = await self.post(self.body(), **{"Sec-Fetch-Site": "same-origin"})
self.assertEqual(status, 200)
self.assertEqual(len(self.opened), 1)
async def test_only_json_bodies_are_accepted(self):
# text/plain would be a CORS "simple" request with no preflight (L2).
make_png(self.output_dir / "sub" / "a.png")
body = json.dumps(self.body())
for content_type in ("text/plain", "application/x-www-form-urlencoded", None):
with self.subTest(content_type=content_type):
sent = {"Origin": self.origin()}
if content_type:
sent["Content-Type"] = content_type
response = await self.client.post(
REVEAL, data=body.encode(), headers=sent
)
self.assertEqual(response.status, 415)
data = await response.json()
self.assertEqual(
(data["success"], data["code"]), (False, "unsupported-media-type")
)
self.assertEqual(self.opened, [])
async def test_a_reveal_in_progress_or_cooling_down_is_busy(self):
make_png(self.output_dir / "sub" / "a.png")
gate = self.api._reveal_gate
self.assertTrue(gate.try_enter())
status, data = await self.post(self.body())
self.assertEqual(status, 429)
self.assertEqual(data, {**data, "success": False, "code": "busy"})
gate.leave()
now = [50.0]
self.api._reveal_gate = file_reveal.RevealGate(
cooldown=1.0, clock=lambda: now[0]
)
statuses = [(await self.post(self.body()))[0] for _ in range(2)]
now[0] += 1.0
statuses.append((await self.post(self.body()))[0])
self.assertEqual(statuses, [200, 429, 200])
self.assertEqual(len(self.opened), 2)
async def test_a_failed_reveal_releases_the_gate(self):
make_png(self.output_dir / "sub" / "a.png")
self.outcome = OSError("cannot start")
status, _ = await self.post(self.body())
self.assertEqual(status, 500)
self.outcome = None
status, _ = await self.post(self.body())
self.assertEqual(status, 200)
async def test_default_gate_is_one_per_process_with_a_cooldown(self):
gate = type(self.api)._reveal_gate
self.assertIsInstance(gate, file_reveal.RevealGate)
self.assertEqual(gate.cooldown, file_reveal.REVEAL_COOLDOWN)
self.assertGreaterEqual(file_reveal.REVEAL_COOLDOWN, 1.0)
async def test_resolution_errors_are_404_and_never_log_the_path(self):
# ENAMETOOLONG and friends must not escape as a non-JSON 500 (L3).
make_png(self.output_dir / "sub" / "a.png")
secret = "sub/" + "n" * 300 + "/a.png"
with self.assertLogs(self.api.logger, level="DEBUG") as logs:
self.api.logger.debug("start")
status, data = await self.post(self.body(path=secret))
self.assertEqual(status, 404)
self.assertEqual(data, {"success": False, "error": "Image file not found"})
with patch.object(
type(self.api),
"_reveal_path",
staticmethod(MagicMock(side_effect=OSError(36, "too long", secret))),
):
status, data = await self.post(self.body(path=secret))
self.assertEqual(status, 404)
self.assertIs(data["success"], False)
for record in logs.records:
if record.levelno >= logging.WARNING:
self.assertNotIn("n" * 300, record.getMessage())
self.assertEqual(self.opened, [])
async def test_start_failure_never_logs_the_path_at_error_level(self):
make_png(self.output_dir / "sub" / "a.png")
self.outcome = OSError(2, "No such file", str(self.output_dir / "sub"))
with self.assertLogs(self.api.logger, level="DEBUG") as logs:
status, _ = await self.post(self.body())
self.assertEqual(status, 500)
errors = [r for r in logs.records if r.levelno >= logging.ERROR]
self.assertTrue(errors)
for record in errors:
self.assertNotIn(str(self.output_dir), record.getMessage())
self.assertIsNone(record.exc_info)
async def test_changed_or_missing_scope_is_rejected(self):
make_png(self.output_dir / "sub" / "a.png")
status, data = await self.post(self.body(root_scope="0" * 64))
self.assertEqual((status, data["code"]), (409, "source-changed"))
status, data = await self.post(self.body(root_index=3))
self.assertEqual((status, data["code"]), (409, "source-changed"))
for scope in (None, "ABC", 7, "g" * 64):
with self.subTest(scope=scope):
status, _ = await self.post(self.body(root_scope=scope))
self.assertEqual(status, 400)
body = self.body()
del body["root_scope"]
status, _ = await self.post(body)
self.assertEqual(status, 400)
self.assertEqual(self.opened, [])
async def test_body_types_are_strict(self):
make_png(self.output_dir / "sub" / "a.png")
for changes in (
{"root_index": True},
{"root_index": "0"},
{"root_index": -1},
{"root_index": 0.0},
{"path": 5},
{"path": ""},
{"path": "a" * 5000 + ".png"},
{"path": "sub/a\0.png"},
):
with self.subTest(changes=changes):
status, data = await self.post(self.body(**changes))
self.assertEqual(status, 400, data)
response = await self.client.post(
REVEAL,
data="[1]",
headers={"Origin": self.origin(), "Content-Type": "application/json"},
)
self.assertEqual(response.status, 400)
self.assertEqual(self.opened, [])
async def test_traversal_and_absolute_paths_are_rejected(self):
make_png(self.output_dir / "sub" / "a.png")
outside = make_png(self.comfy_dir / "outside.png")
for path in (
"../outside.png",
"sub/../../outside.png",
"sub\\..\\..\\outside.png",
str(outside),
"/" + outside.name,
"C:/outside.png",
"C:outside.png",
):
with self.subTest(path=path):
status, data = await self.post(self.body(path=path))
self.assertEqual(status, 400, data)
self.assertEqual(self.opened, [])
async def test_symlink_out_of_the_root_is_refused(self):
outside = make_png(self.comfy_dir / "outside.png")
self._make_symlink(outside, self.output_dir / "link.png")
status, _ = await self.post(self.body(path="link.png"))
self.assertIn(status, (403, 404))
self.assertEqual(self.opened, [])
async def test_missing_non_media_and_non_regular_files(self):
(self.output_dir / "folder.png").mkdir()
(self.output_dir / "notes.txt").write_text("x")
for path, expected in (
("sub/missing.png", 404),
("folder.png", 404),
("notes.txt", 403),
):
with self.subTest(path=path):
status, data = await self.post(self.body(path=path))
self.assertEqual(status, expected, data)
self.assertIs(data["success"], False)
self.assertEqual(self.opened, [])
async def test_unavailable_opener_is_501_and_start_failure_500(self):
make_png(self.output_dir / "sub" / "a.png")
self.outcome = OpenerUnavailable()
status, data = await self.post(self.body())
self.assertEqual(status, 501)
self.assertEqual(data, {**data, "success": False, "code": "unavailable"})
self.outcome = OSError("cannot start")
status, data = await self.post(self.body())
self.assertEqual(status, 500)
self.assertIs(data["success"], False)
self.assertNotIn("cannot start", data["error"])
async def test_default_opener_is_the_platform_file_manager(self):
self.assertIs(type(self.api)._reveal_opener, file_reveal.reveal_in_file_manager)
if __name__ == "__main__":
unittest.main()
+3 -1
View File
@@ -254,7 +254,9 @@ class TestOutcomeMessages(unittest.TestCase):
def test_candidates_and_absence_keep_their_status_lines(self):
graph = _runtime()
graph["7"] = _encode("positive")
graph["5"] = _ksampler()
# A second sampler with its own prompt (#249: identical ones resolve).
graph["8"] = _encode("other positive")
graph["5"] = _ksampler("8")
evidence = inspect_prompt_evidence({"prompt": graph})
self.assertEqual(evidence["resolution"]["state"], "ambiguous")
self.assertEqual(_messages(evidence), {"positive": None, "negative": None})
+2 -1
View File
@@ -36,7 +36,8 @@ LIBRARY = {
"title": None,
"category": None,
"tags": [],
"metadata_scope": None,
"category_scope": None,
"tags_scope": None,
}
NODE_TIMEOUT_SECONDS = 120
+17 -1
View File
@@ -77,7 +77,8 @@ def encode(read, *, prompts=None, file=None, library=None):
"title": None,
"category": None,
"tags": [],
"metadata_scope": None,
"category_scope": None,
"tags_scope": None,
},
)
@@ -663,6 +664,21 @@ class TestInspectionResponse(unittest.TestCase):
for key in ("positive_id", "pair_id", "category", "title"):
self.assertNotIn(key, result["library"])
self.assertEqual(result["library"]["tags"], ["valid"])
# An unknown or non-string scope is dropped, never passed through.
for bad in ("recipe", 3, ["pair"]):
result = json.loads(
encode(
None,
library={
"status": "linked",
"category_scope": bad,
"tags_scope": bad,
"tags": [],
},
)
)
self.assertNotIn("category_scope", result["library"])
self.assertNotIn("tags_scope", result["library"])
result = json.loads(
encode(inspection({}), library={"status": "linked", "tags": ["猫" * 12000]})
)
+436
View File
@@ -0,0 +1,436 @@
"""Unit tests for py/api/jobs.py: the in-memory session job registry.
Fake runners only: no files, no database. Ordering is driven by events and
awaited tasks, never by wall-clock bounds.
"""
import asyncio
import contextlib
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from py.api.jobs import ( # noqa: E402
DEFAULT_SCAN_SCOPE,
LOG_CAP_LINE,
MAX_FINISHED_JOBS,
MAX_LOG_LINES,
MAX_OUTCOME_ROWS,
SCAN_GROUP_NAMES,
SCAN_GROUPS,
SCAN_SCOPES,
Job,
JobBusy,
JobRegistry,
)
async def _collect(job, stop_after=None):
seen = []
async with contextlib.aclosing(job.events()) as events:
async for payload in events:
seen.append(payload)
if stop_after is not None and len(seen) >= stop_after:
break
return seen
def _gated_runner(gate, publish=None):
"""A runner that publishes ``publish`` (if any) and waits for ``gate``."""
async def runner(job):
if publish is not None:
job.publish(publish)
await gate.wait()
return runner
async def _noop(job):
return None
class TestJobFanOut(unittest.IsolatedAsyncioTestCase):
"""The SSE fan-out the scan job always had, now on every Job."""
async def test_new_job_is_running_with_no_event(self):
job = Job("scan", "Scan")
self.assertTrue(job.running)
self.assertIsNone(job.last_event)
self.assertEqual(job.state, "running")
self.assertEqual(len(job.id), 32)
async def test_subscriber_receives_events_until_finish(self):
job = Job("scan", "Scan")
consumer = asyncio.ensure_future(_collect(job))
await asyncio.sleep(0)
job.publish({"type": "progress", "progress": 10})
job.publish({"type": "complete"})
job.finish("finished")
self.assertEqual(
await asyncio.wait_for(consumer, 2),
[{"type": "progress", "progress": 10}, {"type": "complete"}],
)
self.assertFalse(job.running)
async def test_late_subscriber_gets_latest_snapshot_then_live_events(self):
job = Job("scan", "Scan")
job.publish({"type": "progress", "progress": 10})
job.publish({"type": "progress", "progress": 40})
consumer = asyncio.ensure_future(_collect(job))
await asyncio.sleep(0)
job.publish({"type": "complete"})
job.finish()
self.assertEqual(
await asyncio.wait_for(consumer, 2),
[{"type": "progress", "progress": 40}, {"type": "complete"}],
)
async def test_subscriber_after_finish_gets_only_the_final_event(self):
job = Job("scan", "Scan")
job.publish({"type": "progress", "progress": 99})
job.publish({"type": "complete"})
job.finish()
self.assertEqual(await _collect(job), [{"type": "complete"}])
async def test_departed_subscriber_is_forgotten(self):
job = Job("scan", "Scan")
job.publish({"type": "progress", "progress": 1})
await _collect(job, stop_after=1)
self.assertEqual(job.subscriber_count, 0)
job.publish({"type": "complete"}) # must not raise or queue anywhere
job.finish()
async def test_second_subscriber_attaches_to_running_job(self):
registry = JobRegistry()
gate = asyncio.Event()
job = registry.start(
"scan",
"Scan",
record_changing=True,
scope={},
runner=_gated_runner(gate, {"type": "progress", "progress": 30}),
)
await asyncio.sleep(0) # the runner publishes its first event
first = asyncio.ensure_future(_collect(job))
second = asyncio.ensure_future(_collect(job))
await asyncio.sleep(0)
self.assertEqual(job.subscriber_count, 2)
self.assertIs(registry.active("scan"), job)
job.publish({"type": "progress", "progress": 60})
gate.set()
await asyncio.wait_for(job.task, 2)
expected = [
{"type": "progress", "progress": 30},
{"type": "progress", "progress": 60},
]
self.assertEqual(await asyncio.wait_for(first, 2), expected)
self.assertEqual(await asyncio.wait_for(second, 2), expected)
self.assertEqual(job.summary()["progress"], 60)
async def test_finish_rejects_unknown_state_and_is_final(self):
job = Job("scan", "Scan")
with self.assertRaises(ValueError):
job.finish("running")
job.finish("cancelled")
job.finish("finished") # a second finish keeps the first state
self.assertEqual(job.state, "cancelled")
self.assertIsNotNone(job.finished_at)
class TestJobRegistry(unittest.IsolatedAsyncioTestCase):
async def test_start_lists_and_finishes_a_job(self):
registry = JobRegistry()
gate = asyncio.Event()
job = registry.start(
"scan",
"Scan for metadata",
record_changing=True,
scope={"files": "new", "output_folders": 2},
runner=_gated_runner(gate),
)
self.assertEqual(job.state, "running")
self.assertIs(registry.get(job.id), job)
self.assertIsNone(registry.get("missing"))
summary = job.summary()
self.assertEqual(
set(summary),
{
"id",
"kind",
"title",
"state",
"started_at",
"finished_at",
"progress",
"status_line",
"processed",
"total",
"counters",
"scope",
"listed",
"log_length",
},
)
self.assertEqual(summary["scope"], {"files": "new", "output_folders": 2})
# What the detail would list, so a view reads it only when that changed.
self.assertEqual((summary["listed"], summary["log_length"]), ({}, 0))
self.assertIsNone(summary["finished_at"])
gate.set()
await asyncio.wait_for(job.task, 2)
self.assertEqual(job.state, "finished")
self.assertIsNone(registry.active("scan"))
# Running jobs list first, then finished ones newest first.
older = registry.start(
"thumbs", "Thumbnails", record_changing=False, scope={}, runner=_noop
)
await asyncio.wait_for(older.task, 2)
hold = asyncio.Event()
running = registry.start(
"other",
"Other",
record_changing=False,
scope={},
runner=_gated_runner(hold),
)
listed = registry.list()
self.assertEqual([j.id for j in listed], [running.id, older.id, job.id])
# Only the last MAX_FINISHED_JOBS finished jobs are kept.
for index in range(MAX_FINISHED_JOBS + 5):
done = registry.start(
f"k{index}", "Fake", record_changing=False, scope={}, runner=_noop
)
await asyncio.wait_for(done.task, 2)
listed = registry.list()
finished = [j for j in listed if not j.running]
self.assertEqual(len(finished), MAX_FINISHED_JOBS)
self.assertEqual(finished[0].id, done.id)
self.assertIsNone(registry.get(job.id))
self.assertIs(listed[0], running)
hold.set()
await asyncio.wait_for(running.task, 2)
async def test_record_changing_jobs_are_exclusive(self):
registry = JobRegistry()
gate = asyncio.Event()
first = registry.start(
"scan",
"Scan for metadata",
record_changing=True,
scope={},
runner=_gated_runner(gate),
)
with self.assertRaises(JobBusy) as raised:
registry.start(
"maintenance",
"Maintenance",
record_changing=True,
scope={},
runner=_noop,
)
self.assertIs(raised.exception.running, first)
self.assertIn("Scan for metadata", str(raised.exception))
reader = registry.start(
"report", "Report", record_changing=False, scope={}, runner=_noop
)
await asyncio.wait_for(reader.task, 2)
self.assertEqual(reader.state, "finished")
# One job per kind, even a read-only one.
hold = asyncio.Event()
registry.start(
"report",
"Report",
record_changing=False,
scope={},
runner=_gated_runner(hold),
)
with self.assertRaises(JobBusy):
registry.start(
"report", "Report", record_changing=False, scope={}, runner=_noop
)
gate.set()
hold.set()
await asyncio.wait_for(first.task, 2)
async def test_cancel_is_idempotent_and_keeps_partial_counts(self):
registry = JobRegistry()
reached = asyncio.Event()
proceed = asyncio.Event()
async def runner(job):
for index in range(3):
if job.cancelled:
return
job.record("added", {"path": f"a{index}.png", "root": "output"}, "+")
if index == 0:
reached.set()
await proceed.wait()
job = registry.start(
"scan",
"Scan",
record_changing=True,
scope={},
runner=runner,
groups=SCAN_GROUP_NAMES,
)
await asyncio.wait_for(reached.wait(), 2)
self.assertTrue(registry.cancel(job.id))
self.assertTrue(registry.cancel(job.id)) # still running: still requested
proceed.set()
await asyncio.wait_for(job.task, 2)
self.assertEqual(job.state, "cancelled")
self.assertEqual(job.counters["added"], 1) # the work done before the stop
self.assertFalse(registry.cancel(job.id)) # finished: a no-op
self.assertFalse(registry.cancel("missing"))
self.assertEqual(job.state, "cancelled")
async def test_failing_runner_ends_failed_and_frees_the_slot(self):
registry = JobRegistry()
async def runner(job):
raise RuntimeError("boom")
job = registry.start(
"scan", "Scan", record_changing=True, scope={}, runner=runner
)
with self.assertLogs("py.api.jobs", level="ERROR"):
await asyncio.wait_for(job.task, 2)
self.assertEqual(job.state, "failed")
self.assertIsNone(registry.active("scan"))
async def test_runner_that_finishes_its_own_job_keeps_its_state(self):
registry = JobRegistry()
async def runner(job):
job.finish("failed")
job = registry.start(
"scan", "Scan", record_changing=True, scope={}, runner=runner
)
await asyncio.wait_for(job.task, 2)
self.assertEqual(job.state, "failed")
class TestJobOutcomesAndLog(unittest.TestCase):
def test_outcome_and_log_caps(self):
job = Job("scan", "Scan", groups=SCAN_GROUP_NAMES)
for index in range(MAX_OUTCOME_ROWS + 50):
job.record("errors", {"path": f"f{index}.png", "root": "output"}, "!")
self.assertEqual(len(job.outcomes["errors"]), MAX_OUTCOME_ROWS)
self.assertEqual(job.counters["errors"], MAX_OUTCOME_ROWS + 50)
self.assertEqual(job.outcomes["errors"][0]["path"], "f0.png")
job = Job("scan", "Scan")
for index in range(MAX_LOG_LINES + 100):
job.log_line(f"line {index}")
self.assertEqual(len(job.log), MAX_LOG_LINES + 1)
self.assertEqual(job.log[-1], LOG_CAP_LINE)
self.assertTrue(job.log[0].endswith("line 0"))
self.assertEqual(LOG_CAP_LINE, "[Log capped at 2,000 lines]")
def test_paths_are_root_relative(self):
job = Job("scan", "Scan", groups=SCAN_GROUP_NAMES)
absolute = os.path.abspath(os.path.join("outputs", "sub", "a.png"))
job.record(
"added",
{"path": absolute, "root": "output", "detail": "x"},
f"+ {absolute}",
)
job.record("known", {"path": "sub/b.png", "root": "output"}, "= sub/b.png")
job.record("review", {"path": "C:\\out\\c.png", "root": "output"}, "?")
rows = [row for group in job.outcomes.values() for row in group]
for row in rows:
self.assertFalse(os.path.isabs(row["path"]), row)
self.assertNotIn(":", row["path"])
self.assertEqual(job.outcomes["added"][0]["path"], "a.png")
self.assertEqual(job.outcomes["known"][0]["path"], "sub/b.png")
self.assertEqual(job.outcomes["review"][0]["path"], "c.png")
self.assertEqual(job.outcomes["known"][0]["detail"], "")
self.assertNotIn(absolute, "\n".join(job.log))
def test_record_rejects_unknown_group(self):
job = Job("scan", "Scan", groups=SCAN_GROUP_NAMES)
with self.assertRaises(ValueError):
job.record("bogus", {"path": "a.png", "root": "output"}, "x")
def test_detail_and_progress(self):
job = Job(
"scan",
"Scan",
scope={"files": "new", "output_folders": 2},
groups=SCAN_GROUP_NAMES,
)
job.update(progress=150, status_line="Processing file 3 of 4", processed=3)
job.update(total=4)
job.record("known", {"path": "a.png", "root": "output"}, "= a.png")
detail = job.detail()
self.assertEqual(detail["progress"], 100)
self.assertEqual(detail["status_line"], "Processing file 3 of 4")
self.assertEqual((detail["processed"], detail["total"]), (3, 4))
self.assertEqual(detail["counters"]["known"], 1)
self.assertEqual(detail["outcomes"]["known"][0]["path"], "a.png")
self.assertEqual(len(detail["log"]), 1)
detail["outcomes"]["known"].clear() # a copy, not the live list
self.assertEqual(len(job.outcomes["known"]), 1)
job.publish({"type": "progress", "progress": -5})
self.assertEqual(job.summary()["progress"], 0)
def test_scan_groups_follow_the_counter_mapping(self):
self.assertEqual(
SCAN_GROUPS,
{
"added": "added",
"linked": "added",
"enriched": "added",
"unchanged": "known",
# Activity-2: a file the "new" scope skipped is Already known.
"skipped": "known",
"ambiguous": "review",
"conflict": "review",
"unresolved": "review",
"failed": "errors",
},
)
self.assertEqual((SCAN_SCOPES, DEFAULT_SCAN_SCOPE), (("new", "all"), "new"))
self.assertEqual(
SCAN_GROUP_NAMES, ("added", "known", "review", "no_metadata", "errors")
)
if __name__ == "__main__":
unittest.main()
class TestListedSummary(unittest.TestCase):
def test_summary_counts_listed_rows_and_log_lines(self):
job = Job("scan", "Scan", groups=("added", "errors"))
job.record(
"added", {"path": "a.png", "root": "output", "root_index": 0}, "+ a.png"
)
job.record("errors", {"path": "b.png", "root_index": "x"}, "! b.png")
summary = job.summary()
self.assertEqual(summary["listed"], {"added": 1, "errors": 1})
self.assertEqual(summary["log_length"], 2)
rows = job.detail()["outcomes"]
self.assertEqual(rows["added"][0]["root_index"], 0)
# Only a whole non-negative number is a root index.
self.assertIsNone(rows["errors"][0]["root_index"])
+11
View File
@@ -92,6 +92,17 @@ class TestLibrary(unittest.TestCase):
self.db.list_library_images(positive, pair_id=unrelated)
self.assertIsNone(self.db.get_library_positive(9999))
def test_detail_variants_carry_their_pair_run_count(self):
counted = self.db.save_prompt_pair("Cat", "Blur")
fresh = self.db.save_prompt_pair("Cat", "Noise")
self.db.record_prompt_pair_use(counted, 3)
positive = self.db.get_prompt_pair(counted)["positive_prompt_id"]
variants = {
row["id"]: row for row in self.db.get_library_positive(positive)["variants"]
}
self.assertEqual(variants[counted]["run_count"], 3)
self.assertEqual(variants[fresh]["run_count"], 0)
def test_options_include_active_pair_categories_not_negative_only_or_archived(self):
self.save("Legacy", category="Legacy category")
pair = self.db.save_prompt_pair("Cat", "Noise", category="Pair category")
+24 -1
View File
@@ -51,11 +51,34 @@ class TestPairImageLinking(unittest.TestCase):
self.assertEqual(pairs[0]["image_count"], 1)
self.assertEqual(pairs[0]["run_count"], 0)
def test_parallel_identical_branches_create_their_shared_pair(self):
# #249: two samplers on one positive and one negative are one certain pair.
meta = metadata()
meta["prompt"]["4"] = {
"class_type": "KSampler",
"inputs": {"positive": ["1", 0], "negative": ["2", 0]},
}
self.handler.link_image_to_prompt(self.path, {"id": self.pid}, meta)
pairs = self.db.list_prompt_pairs()["pairs"]
self.assertEqual(
[(p["positive_text"], p["negative_text"]) for p in pairs], [("cat", "blur")]
)
self.assertEqual(pairs[0]["image_count"], 1)
self.assertEqual(pairs[0]["run_count"], 0)
def test_stale_context_or_ambiguous_metadata_does_not_create_pair(self):
meta = metadata(positive="dog")
self.handler.link_image_to_prompt(self.path, {"id": self.pid}, meta)
self.assertEqual(self.db.list_prompt_pairs()["total"], 0)
meta = metadata()
meta["prompt"]["4"] = meta["prompt"]["3"]
# Two samplers with different positives (#249: identical ones resolve).
meta["prompt"]["5"] = {
"class_type": "CLIPTextEncode",
"inputs": {"text": "dog"},
}
meta["prompt"]["4"] = {
"class_type": "KSampler",
"inputs": {"positive": ["5", 0], "negative": ["2", 0]},
}
self.handler.link_image_to_prompt(self.path + "2", {"id": self.pid}, meta)
self.assertEqual(self.db.list_prompt_pairs()["total"], 0)
+9 -4
View File
@@ -44,10 +44,15 @@ class TestPairScan(AdminAPITestCase):
return [p["text"] for p in self.api.db.get_recent_prompts()["prompts"]]
def test_samplers_sharing_one_prompt_link_that_prompt(self):
# #249: every branch resolves to one exact pair, so the pair is ingested.
g = sampled_graph()
g["5"] = {**g["3"]}
self.assertEqual(self._scanned("multi.png", g), "added")
self.assertEqual(self._texts(), ["cat"])
self.assertEqual(sorted(self._texts()), ["blur", "cat"])
pairs = self.api.db.list_prompt_pairs()["pairs"]
self.assertEqual(
[(p["positive_text"], p["negative_text"]) for p in pairs], [("cat", "blur")]
)
def test_samplers_with_different_prompts_link_the_first_sampler_prompt(self):
# The prompt the inspector and the legacy panels show for this file.
@@ -115,9 +120,9 @@ class TestPairScan(AdminAPITestCase):
self.assertEqual(self.api._ingest_scanned_file(path, meta), "unchanged")
async def test_cancel_finishes_at_safe_boundary_and_restart_is_idempotent(self):
from py.api.scan_job import ScanJob
from py.api.jobs import Job
job = ScanJob()
job = Job("scan", "Scan")
self.api._scan_job = job
response = await self.client.post("/prompt_manager/scan/cancel")
self.assertEqual(response.status, 200)
@@ -128,7 +133,7 @@ class TestPairScan(AdminAPITestCase):
self.assertEqual(counts["processed"], 0)
self.assertEqual(self.api.db.list_prompt_pairs()["total"], 0)
job.finish()
self.api._scan_job = ScanJob()
self.api._scan_job = Job("scan", "Scan")
first = self.api._ingest_scan_batch_sync(
[(self.output_dir / "cancel.png", {"prompt": json.dumps(graph())})]
)
+23 -4
View File
@@ -54,13 +54,30 @@ class TestPromptPairResolver(unittest.TestCase):
self.assertEqual(result["positive"]["text"], "portrait")
self.assertIsNone(result["negative"])
def test_multiple_samplers_are_ambiguous_even_when_texts_match(self):
def test_multiple_samplers_with_matching_texts_resolve_to_the_shared_pair(self):
source = graph()
source["4"] = {
"class_type": "KSamplerAdvanced",
"inputs": source["3"]["inputs"],
}
result = resolve_prompt_pair(source)
self.assertEqual(result["state"], "resolved")
self.assertEqual(
(result["positive"]["text"], result["negative"]["text"]),
("portrait", "blurry"),
)
self.assertIsNone(result["sampler_id"])
self.assertEqual(result["reason"], resolver.IDENTICAL_BRANCHES_REASON)
self.assertEqual(len(result["candidates"]), 2)
def test_multiple_samplers_with_different_texts_are_ambiguous(self):
source = graph()
source["5"] = {"class_type": "CLIPTextEncode", "inputs": {"text": "other"}}
source["4"] = {
"class_type": "KSamplerAdvanced",
"inputs": {"positive": ["5", 0], "negative": ["2", 0]},
}
result = resolve_prompt_pair(source)
self.assertEqual(result["state"], "ambiguous")
self.assertEqual(len(result["candidates"]), 2)
self.assertIsNone(result["positive"])
@@ -2253,12 +2270,14 @@ class TestDetailerStages(unittest.TestCase):
self.assertEqual(_sides(result), (parity.DETAILER_POS, parity.NEG))
self.assertEqual(result["sampler_id"], "50")
def test_raw_and_detailed_saves_stay_ambiguous_without_an_anchor(self):
def test_raw_and_detailed_saves_of_one_prompt_share_it_without_an_anchor(self):
source = _detailed(same_prompt=True)
source["14"] = {"class_type": "SaveImage", "inputs": {"images": ["8", 0]}}
result = _inspect(source)
self.assertEqual(result["state"], "ambiguous")
self.assertEqual(result["reason"], "Multiple sampler branches")
# #249: both saves used one pair, so it resolves with no sampler named.
self.assertEqual(result["state"], "resolved")
self.assertIsNone(result["sampler_id"])
self.assertEqual(result["reason"], resolver.IDENTICAL_BRANCHES_REASON)
self.assertEqual(
sorted(item["sampler_id"] for item in result["candidates"]), ["3", "50"]
)
+652
View File
@@ -0,0 +1,652 @@
"""Saved views (D16): the persisted store, its restore schema and the API."""
import json
import os
import sqlite3
import tempfile
import unittest
from contextlib import closing
from pathlib import Path
try:
from test_api_images import ImageAPITestCase
from open_handles import assert_closed
except ImportError:
from tests.test_api_images import ImageAPITestCase
from tests.open_handles import assert_closed
from database.models import PromptModel
from database.operations import PromptDatabase
from database.saved_views import (
MAX_STATE_BYTES,
TABLE,
SavedViewInvalid,
encode_state,
migrate_saved_views,
normalize_state,
saved_views_schema_error,
validate_name,
)
from utils.hashing import generate_prompt_hash
URL = "/prompt_manager/saved-views"
IMAGES_STATE = {
"q": "cat",
"category": "Animals",
"sort": "newest",
"source": "all",
"folder": "",
"media": "all",
"tags": ["fluffy"],
"favorites": False,
}
LIBRARY_STATE = {"q": "", "category": "", "sort": "rating", "tags": []}
IMAGES_DEFAULTS = {
"q": "",
"category": "",
"sort": "newest",
"source": "all",
"folder": "",
"media": "all",
"tags": [],
"favorites": False,
}
LIBRARY_DEFAULTS = {"q": "", "category": "", "sort": "newest", "tags": []}
# A row saved by an older build: no favorites key, a retired key, a sort
# that is no longer offered and an over-long folder.
OLD_IMAGES_STATE = json.dumps(
{
"q": "sunset",
"category": "",
"sort": "shuffle",
"source": "all",
"folder": "x" * 2000,
"media": "image",
"tags": ["golden"],
"layout": "grid",
}
)
class TestSavedViewsApi(ImageAPITestCase):
async def send(self, method, path="", body=None, **kwargs):
if body is not None:
kwargs["json"] = body
response = await self.client.request(method, URL + path, **kwargs)
return response, await response.json()
async def create(self, page="images", name="Cats", state=None):
state = dict(
IMAGES_STATE if page == "images" else LIBRARY_STATE, **(state or {})
)
response, data = await self.send(
"POST", body={"page": page, "name": name, "state": state}
)
self.assertEqual(response.status, 201, data)
return data["view"]
async def test_create_list_rename_delete_round_trip(self):
response, data = await self.send("GET")
self.assertEqual(response.status, 200)
self.assertEqual(data, {"success": True, "views": []})
first = await self.create(name=" Cats ")
self.assertEqual(
first,
{
"id": first["id"],
"page": "images",
"name": "Cats",
"state": IMAGES_STATE,
},
)
second = await self.create(page="library", name="Best", state={})
_, data = await self.send("GET")
self.assertTrue(data["success"])
self.assertEqual(data["views"], [first, second])
response, data = await self.send(
"PATCH", f"/{first['id']}", {"name": " Kittens "}
)
self.assertEqual(response.status, 200)
self.assertEqual(data, {"success": True, "view": {**first, "name": "Kittens"}})
_, listed = await self.send("GET")
# Renaming keeps the view's place in the list.
self.assertEqual(
[view["name"] for view in listed["views"]], ["Kittens", "Best"]
)
response, data = await self.send("DELETE", f"/{first['id']}")
self.assertEqual(response.status, 200)
self.assertEqual(data, {"success": True, "id": first["id"]})
_, listed = await self.send("GET")
self.assertEqual(listed["views"], [second])
for method, body in (("PATCH", {"name": "Again"}), ("DELETE", None)):
response, data = await self.send(method, f"/{first['id']}", body)
self.assertEqual(response.status, 404)
self.assertFalse(data["success"])
self.assertIn("error", data)
async def test_views_are_ordered_by_position_and_ids_are_not_reused(self):
views = [await self.create(name=f"View {n}") for n in range(3)]
await self.send("DELETE", f"/{views[2]['id']}")
later = await self.create(name="Later")
self.assertGreater(later["id"], views[2]["id"])
with self.api.db.model.get_connection() as conn:
conn.execute(
f"UPDATE {TABLE} SET position = -1 WHERE id = ?", (views[1]["id"],)
)
_, listed = await self.send("GET")
self.assertEqual(
[view["name"] for view in listed["views"]], ["View 1", "View 0", "Later"]
)
async def test_create_rejects_invalid_bodies(self):
good = {"page": "images", "name": "Cats", "state": IMAGES_STATE}
cases = [
["not an object"],
{"page": "images", "name": "Cats"},
{**good, "extra": 1},
{**good, "page": "history"},
{**good, "page": 1},
{**good, "name": ""},
{**good, "name": " "},
{**good, "name": "x" * 101},
{**good, "name": 7},
{**good, "name": "bad\nname"},
{**good, "state": "q=cat"},
{**good, "state": None},
{**good, "state": {**IMAGES_STATE, "unknown": ""}},
{**good, "state": {k: v for k, v in IMAGES_STATE.items() if k != "q"}},
{**good, "state": {**IMAGES_STATE, "q": 5}},
{**good, "state": {**IMAGES_STATE, "q": "x" * 501}},
{**good, "state": {**IMAGES_STATE, "category": "x" * 101}},
{**good, "state": {**IMAGES_STATE, "sort": "random"}},
{**good, "state": {**IMAGES_STATE, "media": "audio"}},
{**good, "state": {**IMAGES_STATE, "source": "-1"}},
{**good, "state": {**IMAGES_STATE, "source": 0}},
{**good, "state": {**IMAGES_STATE, "folder": None}},
{**good, "state": {**IMAGES_STATE, "folder": "x" * 1025}},
{**good, "state": {**IMAGES_STATE, "tags": "cat"}},
{**good, "state": {**IMAGES_STATE, "tags": [1]}},
{**good, "state": {**IMAGES_STATE, "tags": [" "]}},
{**good, "state": {**IMAGES_STATE, "tags": ["a,b"]}},
{**good, "state": {**IMAGES_STATE, "tags": ["x" * 51]}},
{**good, "state": {**IMAGES_STATE, "tags": [f"t{n}" for n in range(21)]}},
# Favorites (the Favorites page's filter) is a JSON boolean.
{**good, "state": {**IMAGES_STATE, "favorites": "true"}},
{**good, "state": {**IMAGES_STATE, "favorites": 1}},
{**good, "state": {**IMAGES_STATE, "favorites": None}},
{
**good,
"state": {k: v for k, v in IMAGES_STATE.items() if k != "favorites"},
},
# Library has no source, folder or media, and its own sorts.
{"page": "library", "name": "L", "state": IMAGES_STATE},
{
"page": "library",
"name": "L",
"state": {**LIBRARY_STATE, "sort": "name-desc"},
},
]
for body in cases:
with self.subTest(body=body):
response, data = await self.send("POST", body=body)
self.assertEqual(response.status, 400)
self.assertFalse(data["success"])
self.assertIsInstance(data["error"], str)
response = await self.client.post(URL, data="{not json")
self.assertEqual(response.status, 400)
self.assertFalse((await response.json())["success"])
_, listed = await self.send("GET")
self.assertEqual(listed["views"], [])
async def test_encoded_state_is_capped(self):
# Each value is within its own limit; together they pass 4 KB.
state = {
**IMAGES_STATE,
"q": "é" * 500,
"folder": "é" * 1024,
"tags": ["é" * 50 for _ in range(20)],
}
encoded = json.dumps(state, ensure_ascii=False, separators=(",", ":"))
self.assertGreater(len(encoded.encode("utf-8")), MAX_STATE_BYTES)
response, data = await self.send(
"POST", body={"page": "images", "name": "Big", "state": state}
)
self.assertEqual(response.status, 400)
self.assertIn("4 KB", data["error"])
async def test_every_documented_value_is_accepted(self):
for sort in ("newest", "oldest", "name", "name-desc", "rating"):
for media in ("all", "image", "video"):
await self.create(state={"sort": sort, "media": media, "source": "3"})
favorite = await self.create(state={"favorites": True})
self.assertIs(favorite["state"]["favorites"], True)
for sort in ("newest", "oldest", "name", "rating"):
await self.create(page="library", state={"sort": sort, "tags": ["a b"]})
_, listed = await self.send("GET")
self.assertEqual(len(listed["views"]), 20)
async def test_rename_and_delete_validate_id_and_body(self):
view = await self.create()
for path in ("/0", "/-1", "/abc", "/1.5", f"/{2**63}"):
for method, body in (("PATCH", {"name": "X"}), ("DELETE", None)):
with self.subTest(path=path, method=method):
response, data = await self.send(method, path, body)
self.assertEqual(response.status, 400)
self.assertFalse(data["success"])
for body in ({}, {"name": ""}, {"name": "X", "page": "library"}, ["X"]):
with self.subTest(body=body):
response, _ = await self.send("PATCH", f"/{view['id']}", body)
self.assertEqual(response.status, 400)
_, listed = await self.send("GET")
self.assertEqual(listed["views"], [view])
async def test_database_failure_is_a_500_envelope(self):
def broken():
raise sqlite3.OperationalError("disk I/O error")
self.api.db.list_saved_views = broken
with self.assertLogs("prompt_manager.api", level="ERROR"):
response, data = await self.send("GET")
self.assertEqual(response.status, 500)
self.assertEqual(
data, {"success": False, "error": "Saved view operation failed"}
)
async def test_internal_value_errors_are_a_generic_500(self):
# Only the request's own validation is a 400; a ValueError or
# TypeError raised by the database layer is a server fault (M4).
for error in (
ValueError("secret parser detail"),
TypeError("secret type detail"),
json.JSONDecodeError("secret json detail", "{", 0),
):
def broken(*_args, error=error):
raise error
for method, path, body, attr in (
("GET", "", None, "list_saved_views"),
(
"POST",
"",
{"page": "library", "name": "L", "state": LIBRARY_STATE},
"create_saved_view",
),
("PATCH", "/1", {"name": "X"}, "rename_saved_view"),
("DELETE", "/1", None, "delete_saved_view"),
):
with self.subTest(error=type(error).__name__, method=method):
setattr(self.api.db, attr, broken)
try:
with self.assertLogs("prompt_manager.api", level="ERROR"):
response, data = await self.send(method, path, body)
finally:
delattr(self.api.db, attr)
self.assertEqual(response.status, 500)
self.assertEqual(
data,
{"success": False, "error": "Saved view operation failed"},
)
async def test_a_corrupt_state_is_skipped_by_the_listing(self):
kept = await self.create(page="library", name="Kept", state={})
with self.api.db.model.get_connection() as conn:
conn.execute(
f"INSERT INTO {TABLE} (page, name, state, position, created_at, "
"updated_at) VALUES ('images', 'Broken', '{', 0, 'x', 'x')"
)
response, data = await self.send("GET")
self.assertEqual(response.status, 200, data)
self.assertEqual(data["views"], [kept])
# Hidden from the listing, it is not found by rename either.
broken_id = kept["id"] + 1
response, _ = await self.send("PATCH", f"/{broken_id}", {"name": "X"})
self.assertEqual(response.status, 404)
response, _ = await self.send("DELETE", f"/{broken_id}")
self.assertEqual(response.status, 200)
async def test_a_row_saved_under_older_rules_lists_normalized(self):
stale = json.dumps(
{
"q": "cat",
"sort": "random",
"media": "video",
"tags": ["ok", 7],
"retired": True,
}
)
with self.api.db.model.get_connection() as conn:
conn.execute(
f"INSERT INTO {TABLE} (page, name, state, position, created_at, "
"updated_at) VALUES ('images', ' Old\tview ', ?, 0, 'x', 'x')",
(stale,),
)
conn.execute(
f"INSERT INTO {TABLE} (page, name, state, position, created_at, "
"updated_at) VALUES ('library', '', '{}', 1, 'x', 'x')"
)
response, data = await self.send("GET")
self.assertEqual(response.status, 200, data)
images, library = data["views"]
self.assertEqual(images["name"], "Oldview")
self.assertEqual(
images["state"],
{**IMAGES_DEFAULTS, "q": "cat", "media": "video"},
)
self.assertEqual(library["name"], "Saved view")
self.assertEqual(library["state"], LIBRARY_DEFAULTS)
# A stale view still renames and deletes.
response, data = await self.send(
"PATCH", f"/{images['id']}", {"name": "Renamed"}
)
self.assertEqual(response.status, 200, data)
self.assertEqual(data["view"]["state"], images["state"])
class TestSavedViewsSchema(unittest.TestCase):
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
self.path = os.path.join(self.tmpdir, "prompts.db")
self.db = PromptDatabase(self.path)
self.addCleanup(self._cleanup)
def _cleanup(self):
PromptModel.close_all_instances()
assert_closed(self, self.tmpdir)
for name in os.listdir(self.tmpdir):
os.unlink(os.path.join(self.tmpdir, name))
os.rmdir(self.tmpdir)
def _source(self, name="source.db", views=()):
"""A closed database file holding a prompt and the given views."""
path = os.path.join(self.tmpdir, name)
src = PromptDatabase(path)
try:
src.save_prompt(text="kept", prompt_hash=generate_prompt_hash("kept"))
for page, view_name, state in views:
src.create_saved_view(page, view_name, state)
finally:
src.close_all()
return path
def test_current_database_has_a_valid_table(self):
conn = self.db.model.get_connection()
self.assertIsNone(saved_views_schema_error(conn, allow_missing=False))
ok, reason = PromptModel.verify_database_file(
self.path, require_library_state=True
)
self.assertTrue(ok, reason)
def test_migration_is_idempotent(self):
conn = self.db.model.get_connection()
self.db.create_saved_view("images", "Cats", IMAGES_STATE)
migrate_saved_views(conn)
self.assertEqual(len(self.db.list_saved_views()), 1)
def test_backup_restore_round_trip_keeps_saved_views(self):
self.db.create_saved_view("library", "Live", LIBRARY_STATE)
src_path = self._source(
views=[
("images", "Cats", IMAGES_STATE),
("library", "Best", LIBRARY_STATE),
]
)
# The views may still sit in the WAL; the backup must carry them.
backup = os.path.join(self.tmpdir, "backup.db")
reopened = PromptDatabase(src_path)
try:
self.assertTrue(reopened.model.backup_database(backup))
finally:
reopened.close_all()
previous = self.db.model.restore_from_file(backup)
self.assertEqual(
[
(view["page"], view["name"], view["state"])
for view in self.db.list_saved_views()
],
[("images", "Cats", IMAGES_STATE), ("library", "Best", LIBRARY_STATE)],
)
created = self.db.create_saved_view("images", "After", IMAGES_STATE)
self.assertEqual(self.db.list_saved_views()[-1], created)
with closing(sqlite3.connect(previous)) as conn:
self.assertEqual(
conn.execute(f"SELECT name FROM {TABLE}").fetchall(), [("Live",)]
)
def test_restore_old_backup_without_saved_views(self):
self.db.create_saved_view("images", "Live", IMAGES_STATE)
src = self._source()
with closing(sqlite3.connect(src)) as conn:
conn.execute(f"DROP TABLE {TABLE}")
conn.commit()
self.assertTrue(PromptModel.verify_database_file(src)[0])
ok, _ = PromptModel.verify_database_file(src, require_library_state=True)
self.assertFalse(ok)
self.db.model.restore_from_file(src)
self.assertEqual(self.db.list_saved_views(), [])
self.db.create_saved_view("library", "New", LIBRARY_STATE)
self.assertEqual(len(self.db.list_saved_views()), 1)
with closing(sqlite3.connect(src)) as conn: # the source is untouched
self.assertIsNone(
conn.execute(
"SELECT 1 FROM sqlite_master WHERE name=?", (TABLE,)
).fetchone()
)
def _tampered(self, *statements, views=(("images", "Cats", IMAGES_STATE),)):
src = self._source(
name=f"tampered{len(os.listdir(self.tmpdir))}.db", views=views
)
with closing(sqlite3.connect(src)) as conn:
for statement in statements:
conn.execute(statement)
conn.commit()
return src
def test_restore_rejects_tampered_saved_views(self):
cases = {
"definition": (
f"DROP TABLE {TABLE}",
f"CREATE TABLE {TABLE} (id INTEGER PRIMARY KEY, page TEXT, "
"name TEXT, state TEXT, position INTEGER, created_at TEXT, "
"updated_at TEXT)",
),
"index": (f"CREATE INDEX saved_views_extra ON {TABLE}(name)",),
"page": (
"PRAGMA ignore_check_constraints = ON",
f"UPDATE {TABLE} SET page = 'history'",
),
"long name": (f"UPDATE {TABLE} SET name = '{'x' * 401}'",),
"blob name": (f"UPDATE {TABLE} SET name = CAST('Cats' AS BLOB)",),
"state json": (f"UPDATE {TABLE} SET state = '{{'",),
"state array": (f"UPDATE {TABLE} SET state = '[]'",),
"state string": (f"UPDATE {TABLE} SET state = '\"q\"'",),
"state size": (
f"UPDATE {TABLE} SET state = '{'x' * (MAX_STATE_BYTES + 1)}'",
),
"position": (f"UPDATE {TABLE} SET position = 'first'",),
"timestamp": (f"UPDATE {TABLE} SET created_at = X'00'",),
}
for label, statements in cases.items():
with self.subTest(label=label):
src = self._tampered(*statements)
ok, reason = PromptModel.verify_database_file(src)
self.assertFalse(ok)
self.assertIn("Saved views", reason)
with self.assertRaises(ValueError):
self.db.model.restore_from_file(src)
def _insert_old_rows(self, path):
"""Rows written under older value rules, straight into the file."""
with closing(sqlite3.connect(path)) as conn:
conn.execute(
f"INSERT INTO {TABLE} (page, name, state, position, created_at, "
"updated_at) VALUES ('images', 'Golden hour', ?, 5, 'x', 'x')",
(OLD_IMAGES_STATE,),
)
conn.execute(
f"INSERT INTO {TABLE} (page, name, state, position, created_at, "
"updated_at) VALUES ('library', ?, ?, 6, 'x', 'x')",
(" " + "n" * 120, json.dumps({"sort": "name-desc", "q": 4})),
)
conn.commit()
def _assert_old_rows_normalized(self, views):
self.assertEqual(
[(view["page"], view["name"], view["state"]) for view in views],
[
(
"images",
"Golden hour",
{
**IMAGES_DEFAULTS,
"q": "sunset",
"media": "image",
"tags": ["golden"],
},
),
("library", "n" * 100, LIBRARY_DEFAULTS),
],
)
def test_a_database_with_rows_from_older_rules_still_opens(self):
self.db.close_all()
self._insert_old_rows(self.path)
PromptModel.reset_schema_cache()
reopened = PromptDatabase(self.path)
try:
self._assert_old_rows_normalized(reopened.list_saved_views())
finally:
reopened.close_all()
ok, reason = PromptModel.verify_database_file(
self.path, require_library_state=True
)
self.assertTrue(ok, reason)
def test_restore_accepts_rows_from_older_rules(self):
src = self._source()
self._insert_old_rows(src)
ok, reason = PromptModel.verify_database_file(src, require_library_state=True)
self.assertTrue(ok, reason)
self.db.model.restore_from_file(src)
self._assert_old_rows_normalized(self.db.list_saved_views())
def _insert_corrupt_rows(self, path):
"""A valid view between two rows whose state is no JSON object."""
with closing(sqlite3.connect(path)) as conn:
ids = []
for name, state in (
("Broken", "{not json"),
("Kept", json.dumps(LIBRARY_STATE)),
("Array", "[1, 2]"),
):
ids.append(
conn.execute(
f"INSERT INTO {TABLE} (page, name, state, position, "
"created_at, updated_at) VALUES ('library', ?, ?, 0, "
"'x', 'x')",
(name, state),
).lastrowid
)
conn.commit()
return ids
def test_startup_tolerates_a_corrupt_state_and_list_skips_it(self):
self.db.close_all()
broken, kept, array = self._insert_corrupt_rows(self.path)
PromptModel.reset_schema_cache()
with self.assertLogs("prompt_manager.database", level="WARNING") as logs:
reopened = PromptDatabase(self.path)
try:
self.assertEqual(
[(view["id"], view["name"]) for view in reopened.list_saved_views()],
[(kept, "Kept")],
)
finally:
reopened.close_all()
output = "\n".join(logs.output)
self.assertIn(f"id {broken}", output)
self.assertIn(f"id {array}", output)
self.assertNotIn("not json", output)
# The rows stay in place, untouched.
with closing(sqlite3.connect(self.path)) as conn:
self.assertEqual(
conn.execute(f"SELECT COUNT(*) FROM {TABLE}").fetchone()[0], 3
)
def test_restore_rejects_a_backup_with_a_corrupt_state(self):
src = self._source()
self._insert_corrupt_rows(src)
ok, reason = PromptModel.verify_database_file(src)
self.assertFalse(ok)
self.assertEqual(reason, "Saved views contains invalid values")
with self.assertRaises(ValueError):
self.db.model.restore_from_file(src)
def test_normalize_state_keeps_valid_values_and_defaults_the_rest(self):
self.assertEqual(normalize_state("images", IMAGES_STATE), IMAGES_STATE)
self.assertEqual(normalize_state("library", LIBRARY_STATE), LIBRARY_STATE)
self.assertEqual(
normalize_state("images", json.dumps(IMAGES_STATE)), IMAGES_STATE
)
for raw in (None, "{", "[]", '"q"', 7, ["q"], b"{}"):
with self.subTest(raw=raw):
self.assertEqual(normalize_state("library", raw), LIBRARY_DEFAULTS)
self.assertEqual(
normalize_state(
"images",
{
"q": "bad\x00",
"source": 3,
"favorites": "true",
"tags": [f"t{n}" for n in range(21)],
"folder": "kept",
},
),
{**IMAGES_DEFAULTS, "folder": "kept"},
)
self.assertEqual(normalize_state("history", {}), {})
def test_validation_errors_are_saved_view_invalid(self):
for call in (
lambda: validate_name(""),
lambda: validate_name(3),
lambda: encode_state("history", {}),
lambda: encode_state("library", {"q": ""}),
lambda: encode_state("library", {**LIBRARY_STATE, "sort": "x"}),
):
with self.assertRaises(SavedViewInvalid):
call()
self.assertTrue(issubclass(SavedViewInvalid, ValueError))
def test_migration_refuses_an_unsupported_table(self):
path = os.path.join(self.tmpdir, "foreign.db")
with closing(sqlite3.connect(path)) as conn:
conn.execute(f"CREATE TABLE {TABLE} (id INTEGER PRIMARY KEY)")
with self.assertRaises(sqlite3.DatabaseError):
migrate_saved_views(conn)
# The savepoint rolled back, so the foreign table is untouched.
self.assertEqual(
conn.execute(
f"SELECT COUNT(*) FROM pragma_table_info('{TABLE}')"
).fetchone()[0],
1,
)
self.assertTrue(Path(path).exists())
if __name__ == "__main__":
unittest.main()
-92
View File
@@ -1,92 +0,0 @@
"""Unit tests for py/api/scan_job.py: fan-out of scan progress to SSE subscribers."""
import asyncio
import contextlib
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from py.api.scan_job import ScanJob # noqa: E402
async def _collect(job, stop_after=None):
seen = []
async with contextlib.aclosing(job.events()) as events:
async for payload in events:
seen.append(payload)
if stop_after is not None and len(seen) >= stop_after:
break
return seen
class TestScanJob(unittest.IsolatedAsyncioTestCase):
async def test_new_job_is_running_with_no_event(self):
job = ScanJob()
self.assertTrue(job.running)
self.assertIsNone(job.last_event)
async def test_subscriber_receives_events_until_finish(self):
job = ScanJob()
consumer = asyncio.ensure_future(_collect(job))
await asyncio.sleep(0)
job.publish({"type": "progress", "progress": 10})
job.publish({"type": "complete"})
job.finish()
self.assertEqual(
await asyncio.wait_for(consumer, 2),
[{"type": "progress", "progress": 10}, {"type": "complete"}],
)
self.assertFalse(job.running)
async def test_late_subscriber_gets_latest_snapshot_then_live_events(self):
job = ScanJob()
job.publish({"type": "progress", "progress": 10})
job.publish({"type": "progress", "progress": 40})
consumer = asyncio.ensure_future(_collect(job))
await asyncio.sleep(0)
job.publish({"type": "complete"})
job.finish()
self.assertEqual(
await asyncio.wait_for(consumer, 2),
[{"type": "progress", "progress": 40}, {"type": "complete"}],
)
async def test_subscriber_after_finish_gets_only_the_final_event(self):
job = ScanJob()
job.publish({"type": "progress", "progress": 99})
job.publish({"type": "complete"})
job.finish()
self.assertEqual(await _collect(job), [{"type": "complete"}])
async def test_two_subscribers_both_see_every_event(self):
job = ScanJob()
first = asyncio.ensure_future(_collect(job))
second = asyncio.ensure_future(_collect(job))
await asyncio.sleep(0)
job.publish({"type": "progress", "progress": 1})
job.finish()
self.assertEqual(await first, [{"type": "progress", "progress": 1}])
self.assertEqual(await second, [{"type": "progress", "progress": 1}])
async def test_departed_subscriber_is_forgotten(self):
job = ScanJob()
job.publish({"type": "progress", "progress": 1})
await _collect(job, stop_after=1)
self.assertEqual(job.subscriber_count, 0)
job.publish({"type": "complete"}) # must not raise or queue anywhere
job.finish()
if __name__ == "__main__":
unittest.main()
+390
View File
@@ -0,0 +1,390 @@
"""Scan scope (#249 Activity-2 Task 4): new and unresolved files by default.
A "Scan for metadata" job started without ``scope`` (or with ``"new"``) skips
the files the Library already links with a resolved link, before their
metadata is read, and counts them as Already known. ``"all"`` reads every file
again, as the legacy ``/prompt_manager/scan`` route always does.
"""
import asyncio
import json
import os
import sqlite3
import unittest
from unittest.mock import patch
try:
from tests.test_api_admin import AdminAPITestCase, _sse_payloads, _write_png
except ImportError: # discovered with tests/ as the top-level directory
from test_api_admin import AdminAPITestCase, _sse_payloads, _write_png
from database import operations # noqa: E402
from database.pair_ingestion import ingest_resolved_pair # noqa: E402
from py.config import GalleryConfig # noqa: E402
from utils.prompt_pair_resolver import resolve_prompt_pair # noqa: E402
SKIPPED_DETAIL = "Skipped · already linked"
ON_DISK = object() # _link_resolved: store the file's size on disk
def _graph(text):
return {
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": text}},
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": "bad"}},
"3": {
"class_type": "KSampler",
"inputs": {"positive": ["1", 0], "negative": ["2", 0]},
},
}
class ScanScopeTestCase(AdminAPITestCase):
"""Helpers: link files the way the scan and the monitor do, spy on reads."""
def _link_resolved(self, path, text, size=ON_DISK):
"""``path`` linked with a resolved pair link (what a resolved scan writes).
The row stores ``size`` as its file size: by default the file's size on
disk (None when the file does not exist).
"""
if size is ON_DISK:
size = os.path.getsize(path) if os.path.exists(path) else None
metadata = {"prompt": _graph(text)}
if size is not None:
metadata["file_info"] = {"size": size}
result = ingest_resolved_pair(
self.api.db, str(path), metadata, resolve_prompt_pair(metadata)
)
self.assertEqual(result["status"], "added")
def _link_without_pair(self, path, text):
"""``path`` linked to a prompt with no pair link (fallback / FIFO link)."""
prompt_id = self.api.db.save_prompt(text, None, [], None, None)
self.assertTrue(self.api.db.link_image_to_prompt(prompt_id, str(path)))
return prompt_id
def _spy_reads(self):
reads = []
original = self.api._extract_comfyui_metadata
def spy(path):
reads.append(os.path.basename(path))
return original(path)
self.api._extract_comfyui_metadata = spy
return reads
async def _scan(self, body=None):
kwargs = {} if body is None else {"json": body}
resp = await self.client.request("POST", "/prompt_manager/jobs/scan", **kwargs)
data = await resp.json()
self.assertEqual(resp.status, 202, data)
await asyncio.wait_for(self.api._scan_job.task, 10)
detail = await self.client.request(
"GET", f"/prompt_manager/jobs/{data['job']['id']}"
)
return (await detail.json())["job"]
class TestScanScope(ScanScopeTestCase):
async def test_new_scope_skips_known_resolved_files_without_reading_them(self):
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.output_dir)]
known = self.output_dir / "known.png"
_write_png(known, prompt_text="a red square")
_write_png(self.output_dir / "new.png", prompt_text="a blue circle")
self._link_resolved(known, "a red square")
reads = self._spy_reads()
job = await self._scan()
self.assertEqual(reads, ["new.png"])
self.assertEqual(job["scope"]["files"], "new")
self.assertEqual(job["counters"]["known"], 1)
self.assertEqual(job["counters"]["added"], 1)
self.assertEqual(job["processed"], 2)
self.assertEqual(
job["outcomes"]["known"],
[
{
"path": "known.png",
"root": "output",
"root_index": 0,
"detail": SKIPPED_DETAIL,
}
],
)
self.assertIn(f"= output/known.png · {SKIPPED_DETAIL}", "\n".join(job["log"]))
self.assertTrue(
job["log"][0].endswith(
"Scan started · 1 output folder · new and unresolved files"
)
)
legacy = self.api._scan_job.last_event
self.assertEqual((legacy["processed"], legacy["unchanged"]), (2, 1))
self.assertTrue(job["status_line"].startswith("Processing file 2 of 2"))
async def test_whole_known_batch_skips_the_executor_reads(self):
from py.api import admin
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.output_dir)]
for name in ("a.png", "b.png"):
_write_png(self.output_dir / name, prompt_text=f"prompt {name}")
self._link_resolved(self.output_dir / name, f"prompt {name}")
_write_png(self.output_dir / "c.png", prompt_text="prompt c")
reads = self._spy_reads()
with patch.object(admin, "SCAN_BATCH_SIZE", 1):
job = await self._scan({"scope": "new"})
self.assertEqual(reads, ["c.png"])
self.assertEqual(job["counters"]["known"], 2)
self.assertEqual(job["counters"]["added"], 1)
async def test_unresolved_files_are_rescanned_in_new_scope(self):
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.output_dir)]
names = ("fallback.png", "conflict.png", "unlinked.png")
for name in names:
_write_png(self.output_dir / name, prompt_text=f"text of {name}")
# Linked without a resolved pair link (the monitor's fallback / FIFO link).
self._link_without_pair(self.output_dir / "fallback.png", "text of fallback")
# Resolved once, then also linked to a second prompt: ambiguous.
conflict = self.output_dir / "conflict.png"
self._link_resolved(conflict, "text of conflict.png")
self._link_without_pair(conflict, "another prompt")
reads = self._spy_reads()
job = await self._scan()
self.assertEqual(sorted(reads), sorted(names))
self.assertNotIn(SKIPPED_DETAIL, json.dumps(job["outcomes"]))
async def test_all_scope_reads_everything(self):
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.output_dir)]
known = self.output_dir / "known.png"
_write_png(known, prompt_text="a red square")
self._link_resolved(known, "a red square")
reads = self._spy_reads()
job = await self._scan({"scope": "all"})
self.assertEqual(reads, ["known.png"])
self.assertEqual(job["scope"]["files"], "all")
self.assertEqual(job["counters"]["known"], 1)
self.assertNotEqual(job["outcomes"]["known"][0]["detail"], SKIPPED_DETAIL)
self.assertTrue(
job["log"][0].endswith("Scan started · 1 output folder · all files")
)
async def test_legacy_scan_route_reads_everything(self):
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.output_dir)]
known = self.output_dir / "known.png"
_write_png(known, prompt_text="a red square")
self._link_resolved(known, "a red square")
reads = self._spy_reads()
resp = await self.client.request("POST", "/prompt_manager/scan", data="{}")
payloads = _sse_payloads(await resp.text())
self.assertEqual(reads, ["known.png"])
self.assertEqual(self.api._scan_job.scope["files"], "all")
self.assertEqual(payloads[-1]["type"], "complete")
self.assertEqual(payloads[-1]["unchanged"], 1)
async def test_file_replaced_at_a_known_path_is_reread_and_needs_review(self):
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.output_dir)]
path = self.output_dir / "out_00012.png"
_write_png(path, prompt_text="the first prompt")
self._link_resolved(path, "the first prompt")
before = os.path.getsize(path)
# ComfyUI reused the name for a new image with another prompt.
_write_png(path, prompt_text="a much longer second prompt for the same name")
self.assertNotEqual(os.path.getsize(path), before)
reads = self._spy_reads()
job = await self._scan()
self.assertEqual(reads, ["out_00012.png"])
self.assertEqual(job["counters"]["review"], 1)
self.assertEqual(job["counters"]["known"], 0)
self.assertIn("different prompt", job["outcomes"]["review"][0]["detail"])
async def test_scanned_files_store_their_size_so_the_next_new_scan_skips(self):
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.output_dir)]
_write_png(self.output_dir / "fresh.png", prompt_text="a fresh prompt")
first = await self._scan()
self.assertEqual(first["counters"]["added"], 1)
reads = self._spy_reads()
second = await self._scan()
self.assertEqual(reads, [])
self.assertEqual(second["outcomes"]["known"][0]["detail"], SKIPPED_DETAIL)
async def test_stored_null_size_is_reread(self):
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.output_dir)]
path = self.output_dir / "old.png"
_write_png(path, prompt_text="an old prompt")
self._link_resolved(path, "an old prompt", size=None)
reads = self._spy_reads()
job = await self._scan()
self.assertEqual(reads, ["old.png"])
# The read stores the size it was missing, so the file is known next time.
self.assertEqual(job["counters"]["added"], 1)
self.assertEqual(
self.api.db.get_known_resolved_paths({str(path): os.path.getsize(path)}),
{str(path)},
)
async def test_known_set_failure_falls_back_to_reading_every_file(self):
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.output_dir)]
known = self.output_dir / "known.png"
_write_png(known, prompt_text="a red square")
self._link_resolved(known, "a red square")
reads = self._spy_reads()
def broken(files):
raise sqlite3.OperationalError("database is locked")
self.api.db.get_known_resolved_paths = broken
with self.assertLogs("prompt_manager.api", level="WARNING"):
job = await self._scan()
self.assertEqual(job["state"], "finished")
self.assertEqual(reads, ["known.png"])
self.assertIn(
"Known files could not be read · reading every file", "\n".join(job["log"])
)
async def test_empty_read_body_asks_for_the_default_scope(self):
"""A chunked POST with no bytes can read a body: still the default."""
from py.api.job_routes import _scan_scope
class Request:
can_read_body = True
def __init__(self, text):
self._text = text
async def text(self):
return self._text
self.assertEqual(await _scan_scope(Request("")), "new")
self.assertEqual(await _scan_scope(Request(" \n")), "new")
self.assertEqual(await _scan_scope(Request('{"scope": "all"}')), "all")
self.assertIsNone(await _scan_scope(Request("{")))
async def test_invalid_scope_is_refused(self):
for body in ({"scope": "everything"}, {"scope": 1}, ["new"]):
with self.subTest(body=body):
resp = await self.client.request(
"POST", "/prompt_manager/jobs/scan", json=body
)
self.assertEqual(resp.status, 400)
data = await resp.json()
self.assertFalse(data["success"])
resp = await self.client.request(
"POST", "/prompt_manager/jobs/scan", data="not json"
)
self.assertEqual(resp.status, 400)
self.assertIsNone(self.api._scan_job)
class TestKnownResolvedPaths(ScanScopeTestCase):
"""``PromptDatabase.get_known_resolved_paths``: one streamed query."""
async def test_returns_the_given_paths_that_are_known_and_resolved(self):
known, fallback = self.output_dir / "known.png", self.output_dir / "fb.png"
self._link_resolved(known, "a red square", size=10)
self._link_without_pair(fallback, "a fallback")
asked = {str(known): 10, str(fallback): 10, str(self.output_dir / "n.png"): 10}
self.assertEqual(self.api.db.get_known_resolved_paths(asked), {str(known)})
self.assertEqual(self.api.db.get_known_resolved_paths({}), set())
async def test_size_must_match_and_a_stored_null_size_is_not_known(self):
same, other = self.output_dir / "same.png", self.output_dir / "other.png"
unknown = self.output_dir / "unknown.png"
self._link_resolved(same, "same size", size=10)
self._link_resolved(other, "other size", size=10)
self._link_resolved(unknown, "no stored size", size=None)
asked = {str(same): 10, str(other): 11, str(unknown): 10}
self.assertEqual(self.api.db.get_known_resolved_paths(asked), {str(same)})
# A file the walk could not stat is not known either.
self.assertEqual(self.api.db.get_known_resolved_paths({str(same): None}), set())
async def test_row_without_file_path_matches_through_image_path(self):
known = self.output_dir / "legacy.png"
self._link_resolved(known, "a legacy row", size=10)
with sqlite3.connect(self.api.db.model.db_path) as conn:
conn.execute("UPDATE generated_images SET file_path=NULL")
conn.close()
found = self.api.db.get_known_resolved_paths({str(known): 10})
self.assertEqual(found, {str(known)})
async def test_stored_spelling_is_normalized_like_the_scanned_path(self):
known = self.output_dir / "sub" / "known.png"
self._link_resolved(known, "a red square", size=10)
spelled = os.path.join(str(self.output_dir), "sub", "..", "sub", "known.png")
with sqlite3.connect(self.api.db.model.db_path) as conn:
conn.execute("UPDATE generated_images SET file_path=?", (spelled,))
conn.close()
found = self.api.db.get_known_resolved_paths({str(known): 10})
self.assertEqual(found, {str(known)})
async def test_one_query_streamed_in_small_fetches(self):
paths = [self.output_dir / f"{index}.png" for index in range(5)]
for path in paths:
self._link_resolved(path, f"prompt {path.name}", size=10)
original = self.api.db.model.get_connection
opened = []
def counting():
opened.append(1)
return original()
self.api.db.model.get_connection = counting
with patch.object(operations, "KNOWN_PATHS_FETCH_SIZE", 2):
found = self.api.db.get_known_resolved_paths({str(p): 10 for p in paths})
self.assertEqual(found, {str(p) for p in paths})
self.assertEqual(len(opened), 1)
async def test_windows_path_case_matches_known_set(self):
stored = self.output_dir / "Sub" / "Known.PNG"
self._link_resolved(stored, "a red square", size=10)
# A row written before paths were case-folded keeps its own spelling.
with sqlite3.connect(self.api.db.model.db_path) as conn:
conn.execute(
"UPDATE generated_images SET file_path=?", (str(stored).upper(),)
)
conn.close()
asked = str(self.output_dir / "sub" / "known.png")
with patch("os.path.normcase", lambda path: path.lower()):
self.assertEqual(self.api.db.get_known_resolved_paths({asked: 10}), {asked})
if os.path.normcase("A") == "A": # a case-sensitive platform
self.assertEqual(self.api.db.get_known_resolved_paths({asked: 10}), set())
class TestSkippedOutcome(unittest.TestCase):
def test_skipped_file_is_already_known_with_its_own_detail(self):
from py.api.admin import _scan_outcome
root = os.path.abspath("out")
group, row, line = _scan_outcome(
os.path.join(root, "a", "b.png"), [(root, "output", 0)], "skipped"
)
self.assertEqual(group, "known")
self.assertEqual(row["detail"], SKIPPED_DETAIL)
self.assertEqual(row["path"], "a/b.png")
self.assertEqual(line, f"= output/a/b.png · {SKIPPED_DETAIL}")
if __name__ == "__main__":
unittest.main()
+234
View File
@@ -0,0 +1,234 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:net';
import { spawn } from 'node:child_process';
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 } 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));
const fake = (...extra) => ({ command: process.execPath, args: port => [fakeServer, '--port', String(port), ...extra] });
const REAL_SERVER_TIMEOUT_MS = 90_000;
const launcher = fileURLToPath(new URL('../../tools/vendor/start-browser-server.mjs', import.meta.url));
/** Polls `check` until it is truthy, failing with `what` after `ms`. */
async function waitFor(check, what, ms = 5_000) {
const deadline = Date.now() + ms;
while (!check()) {
if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`);
await delay(20);
}
}
async function writeStderr(server, text) {
await (await fetch(`${server.url}/__stderr?text=${encodeURIComponent(text)}`)).text();
await waitFor(() => server.stderrTail().endsWith(text.trimEnd()), `stderr ${JSON.stringify(text)}`);
}
/** A process that exited counts as gone even while a non-reaping PID 1 (CI's container) leaves it a zombie. */
function alive(pid) {
try { process.kill(pid, 0); } catch { return false; }
try { return readFileSync(`/proc/${pid}/stat`, 'utf8').replace(/^.*\) /s, '')[0] !== 'Z'; } catch { return true; }
}
/** Starts a server from a separate Node worker process, SIGKILLs that worker and returns the server's pid. */
async function serverOfKilledWorker(options) {
const script = `import { startBrowserServer } from ${JSON.stringify(pathToFileURL(launcher).href)};
const server = await startBrowserServer(${options});
console.log('SERVER_PID=' + server.pid);
setInterval(() => {}, 1000);`;
const worker = spawn(process.execPath, ['--input-type=module', '-e', script], { stdio: ['ignore', 'pipe', 'inherit'] });
let out = '';
worker.stdout.setEncoding('utf8');
worker.stdout.on('data', chunk => { out += chunk; });
await waitFor(() => /SERVER_PID=\d+\n/.test(out) || worker.exitCode !== null, 'the worker to start its server', REAL_SERVER_TIMEOUT_MS);
const pid = Number(/SERVER_PID=(\d+)/.exec(out)?.[1]);
assert.ok(pid > 0, `the worker started a server (output: ${out})`);
assert.ok(alive(pid), 'the server runs while its worker lives');
worker.kill('SIGKILL');
await once(worker, 'exit');
return pid;
}
function listen(port = 0) {
return new Promise((resolve, reject) => {
const server = createServer();
server.once('error', reject);
server.listen(port, '127.0.0.1', () => resolve(server));
});
}
async function releasedPort() {
const probe = await listen();
const { port } = probe.address();
await new Promise(resolve => probe.close(resolve));
return port;
}
test('free port allocation never reuses a bound port', async t => {
const held = await listen();
t.after(() => held.close());
const heldPort = held.address().port;
const servers = await Promise.all(Array.from({ length: 6 }, () => startBrowserServer(fake())));
t.after(() => Promise.all(servers.map(server => server.stop())));
const ports = servers.map(server => server.port);
for (const server of servers) {
assert.ok(Number.isInteger(server.port) && server.port > 0 && server.port < 65536, `port ${server.port} is a TCP port`);
assert.equal(server.url, `http://127.0.0.1:${server.port}`);
assert.equal(await (await fetch(`${server.url}/prompt_manager/test`)).text(), 'ready');
}
assert.ok(!ports.includes(heldPort), 'a port another process holds is never handed out');
assert.equal(new Set(ports).size, ports.length, 'concurrent servers never share a port');
});
test('startBrowserServer asks the server to bind port 0 and reads the port it announces', async () => {
const asked = [];
const server = await startBrowserServer({ command: process.execPath, args: port => { asked.push(port); return fake().args(port); } });
try {
assert.deepEqual(asked, [0], 'no port is reserved and released before the server binds');
assert.ok(server.port > 0);
} finally {
await server.stop();
}
});
test('startBrowserServer serves the readiness page on a requested port and stops cleanly', async () => {
const port = await releasedPort();
const server = await startBrowserServer({ port, ...fake() });
assert.equal(server.port, port);
assert.equal(server.url, `http://127.0.0.1:${port}`);
assert.equal(await (await fetch(`${server.url}/prompt_manager/test`)).text(), 'ready');
await server.stop();
await assert.rejects(fetch(`${server.url}/prompt_manager/test`));
});
test('startBrowserServer reports a startup failure with the server\'s stderr tail', async () => {
await assert.rejects(startBrowserServer(fake('--fail')), error => {
assert.match(error.message, /exited with code 4 before it was ready/);
assert.match(error.message, /last fake stderr line/);
return true;
});
});
test('startBrowserServer gives up on a server that never announces a port or becomes ready', async () => {
const silent = { command: process.execPath, args: () => ['-e', 'setInterval(() => {}, 1000)'] };
await assert.rejects(startBrowserServer({ ...silent, timeoutMs: 500 }), /not ready .* within 500 ms/);
});
test('worker server restarts after an unexpected exit', async t => {
const supervisor = createServerSupervisor({ start: () => startBrowserServer(fake()) });
t.after(() => supervisor.stop());
const first = await supervisor.ensure();
assert.equal(supervisor.crash(first), null, 'a running server reports no crash');
await fetch(`${first.url}/__exit`);
await first.exited;
const crash = supervisor.crash(first);
assert.match(crash.message, /test server on port \d+ exited unexpectedly \(code 3\)/);
assert.match(crash.message, /crashed on purpose/);
const second = await supervisor.ensure();
assert.notEqual(second, first, 'the next test gets a fresh server');
assert.equal(await (await fetch(`${second.url}/prompt_manager/test`)).text(), 'ready');
assert.equal(await supervisor.ensure(), second, 'a healthy server is reused');
});
test('supervisor reports a startup failure once and tries again on the next ensure', async t => {
let attempts = 0;
const supervisor = createServerSupervisor({
start: async () => {
attempts += 1;
if (attempts === 1) throw new Error('ImportError: no module');
return startBrowserServer(fake());
},
});
t.after(() => supervisor.stop());
await assert.rejects(supervisor.ensure(), /ImportError/);
assert.equal(attempts, 1, 'a failed startup is not retried behind the caller\'s back');
const server = await supervisor.ensure();
assert.equal(attempts, 2);
assert.equal(await (await fetch(`${server.url}/prompt_manager/test`)).text(), 'ready');
});
test('the port announcement counts only once its line is complete', async t => {
const server = await startBrowserServer({ ...fake('--split-announce'), timeoutMs: 5_000 });
t.after(() => server.stop());
assert.equal(await (await fetch(`${server.url}/prompt_manager/test`)).text(), 'ready');
assert.equal(server.url, `http://127.0.0.1:${server.port}`);
});
test('server stderr since a mark holds only what came after it, bounded to the newest output', async t => {
const server = await startBrowserServer({ ...fake(), stderrLogChars: 64 });
t.after(() => server.stop());
await writeStderr(server, 'earlier test\n');
const mark = server.stderrMark();
assert.equal(server.stderrSince(mark), '');
await writeStderr(server, 'Traceback: this test\n');
assert.equal(server.stderrSince(mark), 'Traceback: this test\n');
const flood = `${'x'.repeat(80)}newest\n`;
await writeStderr(server, flood);
const kept = server.stderrSince(mark);
assert.match(kept, /^\[earlier server stderr dropped\]\n/);
assert.ok(kept.endsWith('newest\n'), 'the newest output is kept');
assert.ok(kept.length <= 64 + '[earlier server stderr dropped]\n'.length, `bounded, got ${kept.length} chars`);
});
test('a failed test gets the server stderr written during it attached; a passing one does not', async t => {
const server = await startBrowserServer(fake());
t.after(() => server.stop());
const attached = [];
const info = status => ({ status, expectedStatus: 'passed', attach: async (name, options) => { attached.push({ name, ...options }); } });
await writeStderr(server, 'before the test\n');
const mark = server.stderrMark();
await attachServerStderr(info('failed'), server, mark);
assert.deepEqual(attached, [], 'nothing to attach when the server wrote nothing');
await writeStderr(server, 'Traceback (most recent call last): boom\n');
await attachServerStderr(info('passed'), server, mark);
assert.deepEqual(attached, [], 'a passing test attaches nothing');
await attachServerStderr(info('timedOut'), server, mark);
assert.equal(attached.length, 1);
assert.equal(attached[0].name, 'server-stderr');
assert.equal(attached[0].contentType, 'text/plain');
assert.equal(String(attached[0].body), 'Traceback (most recent call last): boom\n');
});
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`);
});
test('tests/browser/server.py exits with a SIGKILLed worker', { timeout: 2 * REAL_SERVER_TIMEOUT_MS }, async () => {
const pid = await serverOfKilledWorker(`{ timeoutMs: ${REAL_SERVER_TIMEOUT_MS} }`);
await waitFor(() => !alive(pid), `server.py ${pid} to exit after its worker died`, 15_000);
});
test('tests/browser/server.py binds an OS-chosen port for --port 0 and still honours --port N', { timeout: 2 * REAL_SERVER_TIMEOUT_MS }, async t => {
const held = await listen();
t.after(() => held.close());
const chosen = await startBrowserServer({ timeoutMs: REAL_SERVER_TIMEOUT_MS });
try {
assert.notEqual(chosen.port, held.address().port);
assert.equal((await fetch(`${chosen.url}/prompt_manager/test`)).status, 200);
} finally {
await chosen.stop();
}
const port = await releasedPort();
const fixed = await startBrowserServer({ port, timeoutMs: REAL_SERVER_TIMEOUT_MS });
try {
assert.equal(fixed.port, port);
assert.equal((await fetch(`http://127.0.0.1:${port}/prompt_manager/test`)).status, 200);
} finally {
await fixed.stop();
}
});
test('worker count follows PM_TEST_WORKERS, then one in CI, then a quarter of the CPUs up to eight', () => {
assert.equal(workflowWorkers({ env: { PM_TEST_WORKERS: '3', CI: '1' }, cpus: 32 }), 3);
assert.equal(workflowWorkers({ env: { CI: '1' }, cpus: 32 }), 1);
assert.equal(workflowWorkers({ env: {}, cpus: 32 }), 8);
assert.equal(workflowWorkers({ env: {}, cpus: 12 }), 3);
assert.equal(workflowWorkers({ env: {}, cpus: 2 }), 1);
assert.throws(() => workflowWorkers({ env: { PM_TEST_WORKERS: 'many' }, cpus: 8 }), /PM_TEST_WORKERS must be a positive integer/);
assert.throws(() => workflowWorkers({ env: { PM_TEST_WORKERS: '0' }, cpus: 8 }), /PM_TEST_WORKERS must be a positive integer/);
});
+223
View File
@@ -0,0 +1,223 @@
// Structural checks on .github/workflows/viewerjs-check.yml. The tooling has no YAML parser, so these
// read the workflow's fixed two-space layout: jobs at two spaces, their keys at four, steps at six.
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdtemp, mkdir, readdir, rm, writeFile } from 'node:fs/promises';
import { readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const workflow = readFileSync(new URL('../../.github/workflows/viewerjs-check.yml', import.meta.url), 'utf8');
const PINNED_IMAGE = /^\s+image: mcr\.microsoft\.com\/playwright:v\d+\.\d+\.\d+-noble$/m;
function jobs(text) {
const lines = text.split('\n');
const start = lines.indexOf('jobs:');
assert.notEqual(start, -1, 'workflow has a jobs section');
const found = new Map();
let current = null;
for (const line of lines.slice(start + 1)) {
const header = /^ {2}([\w-]+):\s*$/.exec(line);
if (header) {
current = header[1];
found.set(current, []);
} else if (current && (line.startsWith(' ') || line.trim() === '' || /^ {2}#/.test(line))) {
found.get(current).push(line);
} else if (current && /^\S/.test(line)) {
break;
}
}
return new Map([...found].map(([id, body]) => [id, body.join('\n')]));
}
function steps(jobText) {
const parts = jobText.split(/^(?= {6}- )/m);
return parts.slice(1);
}
function runScript(stepText) {
const lines = stepText.split('\n');
const index = lines.findIndex(line => /^ {8}run: \|\s*$/.test(line));
assert.notEqual(index, -1, 'step has a block run script');
const body = [];
for (const line of lines.slice(index + 1)) {
if (line.trim() !== '' && !line.startsWith(' ')) break;
body.push(line.slice(10));
}
return body.join('\n');
}
function needsOf(jobText) {
const match = /^ {4}needs: \[([^\]]*)\]\s*$/m.exec(jobText) ?? /^ {4}needs: ([\w-]+)\s*$/m.exec(jobText);
assert.ok(match, 'job declares needs');
return match[1].split(',').map(value => value.trim()).filter(Boolean);
}
function matrixList(jobText, key) {
const match = new RegExp(`^ {8}${key}: \\[([^\\]]*)\\]\\s*$`, 'm').exec(jobText);
assert.ok(match, `job has a ${key} matrix`);
return match[1].split(',').map(value => value.trim());
}
const allJobs = jobs(workflow);
const browserJobIds = [...allJobs].filter(([, body]) => /npm run --prefix tools\/vendor test:(workflows|coverage)/.test(body))
.map(([id]) => id);
const mergeJob = () => allJobs.get('frontend-coverage-merge');
const verifyStep = () => steps(mergeJob()).find(step => /name: Verify every shard's raw coverage arrived/.test(step));
test('viewerjs keeps its name and needs every frontend job', () => {
const viewerjs = allJobs.get('viewerjs');
assert.ok(viewerjs, 'the required context is the job id viewerjs');
assert.doesNotMatch(viewerjs, /^ {4}name:/m, 'no display name may replace the context "viewerjs"');
const needs = needsOf(viewerjs);
const others = [...allJobs.keys()].filter(id => id !== 'viewerjs');
assert.deepEqual([...needs].sort(), [...others].sort(), 'viewerjs needs every other job in the workflow');
assert.deepEqual([...needs].sort(), ['browser-workflows', 'frontend-coverage', 'frontend-coverage-merge']);
assert.match(viewerjs, /^ {4}if: always\(\)$/m, 'viewerjs runs (and fails) when a needed job failed or was skipped');
const script = runScript(steps(viewerjs).find(step => /run: \|/.test(step)));
for (const id of needs) {
const variable = new RegExp(`^ {10}([A-Z_]+): \\$\\{\\{ needs\\.${id}\\.result \\}\\}$`, 'm').exec(viewerjs);
assert.ok(variable, `${id}'s result reaches the check`);
assert.match(script, new RegExp(`test "\\$${variable[1]}" = success`), `${id} must be exactly success`);
}
});
test('viewerjs fails unless every needed job succeeded', () => {
const viewerjs = allJobs.get('viewerjs');
const script = runScript(steps(viewerjs).find(step => /run: \|/.test(step)));
const variables = [...viewerjs.matchAll(/^ {10}([A-Z_]+): \$\{\{ needs\.[\w-]+\.result \}\}$/gm)].map(match => match[1]);
const run = env => {
try {
execFileSync('bash', ['--noprofile', '--norc', '-eo', 'pipefail', '-c', script], { env: { PATH: process.env.PATH, ...env }, stdio: 'pipe' });
return true;
} catch {
return false;
}
};
const allSuccess = Object.fromEntries(variables.map(name => [name, 'success']));
assert.equal(run(allSuccess), true);
for (const name of variables) {
for (const result of ['failure', 'cancelled', 'skipped']) {
assert.equal(run({ ...allSuccess, [name]: result }), false, `${name}=${result} must fail viewerjs`);
}
}
});
test('coverage runs as three shards that each upload their raw coverage', () => {
const coverage = allJobs.get('frontend-coverage');
assert.deepEqual(matrixList(coverage, 'shard'), ['1', '2', '3']);
assert.match(coverage, /^ {4}name: frontend coverage \(shard \$\{\{ matrix\.shard \}\}\/3\)$/m);
assert.match(coverage, /npm run --prefix tools\/vendor test:coverage:shard -- \$\{\{ matrix\.shard \}\}\/3/);
const upload = steps(coverage).find(step => /name: coverage-raw-\$\{\{ matrix\.shard \}\}/.test(step));
assert.ok(upload, 'each shard uploads coverage-raw-N');
assert.match(upload, /^ {10}path: coverage\/js\/raw\/?$/m);
assert.match(upload, /^ {10}retention-days: 3$/m);
assert.match(upload, /^ {10}if-no-files-found: error$/m);
});
test('merge job downloads every shard and runs the merge-only report and check', () => {
const merge = mergeJob();
assert.ok(merge, 'frontend-coverage-merge exists');
assert.deepEqual(needsOf(merge), ['frontend-coverage']);
assert.match(merge, /^ {4}name: frontend coverage \(merge\)$/m);
const download = steps(merge).find(step => /uses: actions\/download-artifact@/.test(step));
assert.ok(download, 'merge downloads the shard artifacts');
assert.match(download, /^ {10}pattern: coverage-raw-\*$/m);
assert.doesNotMatch(download, /merge-multiple: true/, 'each artifact keeps its own directory so the check can count them');
assert.ok(verifyStep(), 'merge has an explicit artifact count check');
assert.match(verifyStep(), new RegExp(`^ {10}EXPECTED_SHARDS: ${matrixList(allJobs.get('frontend-coverage'), 'shard').length}$`, 'm'),
'the expected count matches the shard matrix');
const order = steps(merge).map(step => (/name: (.*)/.exec(step) ?? [, step.trim().split('\n')[0]])[1]);
const verifyAt = order.findIndex(name => /Verify every shard/.test(name));
const mergeAt = steps(merge).findIndex(step => /npm run --prefix tools\/vendor coverage:merge/.test(step));
assert.ok(verifyAt >= 0 && mergeAt > verifyAt, 'the count check runs before coverage:merge');
const report = steps(merge).find(step => /name: frontend-test-reports$/m.test(step));
assert.ok(report, 'merge uploads the HTML report as frontend-test-reports');
assert.match(report, /!coverage\/js\/raw\/\*\*/);
});
async function stagedShards(root, shards) {
const staged = join(root, 'staged');
for (const [shard, files] of shards) {
const directory = join(staged, `coverage-raw-${shard}`);
await mkdir(directory, { recursive: true });
for (const file of files) await writeFile(join(directory, file), '{"result":[]}');
}
await mkdir(staged, { recursive: true });
return staged;
}
async function verify(t, shards) {
const root = await mkdtemp(join(tmpdir(), 'pm-ci-merge-'));
t.after(() => rm(root, { recursive: true, force: true }));
const staged = await stagedShards(root, shards);
try {
execFileSync('bash', ['--noprofile', '--norc', '-eo', 'pipefail', '-c', runScript(verifyStep())], {
cwd: root, stdio: 'pipe',
env: { PATH: process.env.PATH, STAGED_RAW: staged, EXPECTED_SHARDS: '3' },
});
} catch (error) {
return { ok: false, output: `${error.stdout}${error.stderr}` };
}
return { ok: true, raw: (await readdir(join(root, 'coverage/js/raw'))).sort() };
}
test('merge refuses when a shard\'s raw coverage is missing', async t => {
const missing = await verify(t, [[1, ['a.json']], [3, ['c.json']]]);
assert.equal(missing.ok, false);
assert.match(missing.output, /2 of 3/);
const empty = await verify(t, [[1, ['a.json']], [2, []], [3, ['c.json']]]);
assert.equal(empty.ok, false, 'an artifact without raw files counts as missing');
const collision = await verify(t, [[1, ['a.json']], [2, ['a.json']], [3, ['c.json']]]);
assert.equal(collision.ok, false, 'two shards writing the same file name must not silently drop one');
const complete = await verify(t, [[1, ['a.json', 'node.json']], [2, ['b.json']], [3, ['c.json']]]);
assert.deepEqual(complete, { ok: true, raw: ['a.json', 'b.json', 'c.json', 'node.json'] });
});
test('every browser job sets PM_TEST_WORKERS and runs in the pinned image', () => {
assert.deepEqual([...browserJobIds].sort(), ['browser-workflows', 'frontend-coverage']);
for (const id of browserJobIds) {
const body = allJobs.get(id);
assert.match(body, /^ {6}PM_TEST_WORKERS: 3$/m, `${id} sets PM_TEST_WORKERS for the whole job`);
assert.match(body, PINNED_IMAGE, `${id} keeps the pinned Playwright image`);
assert.match(body, /PIP_SHA256: [0-9a-f]{64}/, `${id} keeps the pinned pip bootstrap`);
assert.match(body, /^ {4}timeout-minutes: \d+$/m);
}
assert.match(mergeJob(), PINNED_IMAGE, 'the merge sees the same workspace paths as the shards');
});
test('page workflows run each browser in two shards', () => {
const workflows = allJobs.get('browser-workflows');
assert.deepEqual(matrixList(workflows, 'browser'), ['firefox', 'webkit']);
assert.deepEqual(matrixList(workflows, 'shard'), ['1', '2']);
assert.match(workflows, /test:workflows -- --project \$\{\{ matrix\.browser \}\} --shard \$\{\{ matrix\.shard \}\}\/2/);
assert.match(workflows, /^ {6}fail-fast: false$/m);
assert.match(workflows, /name: browser-traces-\$\{\{ matrix\.browser \}\}-shard-\$\{\{ matrix\.shard \}\}/);
});
test('every npm ci restores the npm download cache keyed on the vendor lockfile first', () => {
const installing = [...allJobs].filter(([, body]) => /npm ci --prefix tools\/vendor/.test(body));
assert.deepEqual(installing.map(([id]) => id).sort(), ['browser-workflows', 'frontend-coverage', 'frontend-coverage-merge']);
for (const [id, body] of installing) {
const jobSteps = steps(body);
const cacheAt = jobSteps.findIndex(step => /uses: actions\/cache@v\d+/.test(step));
const installAt = jobSteps.findIndex(step => /npm ci --prefix tools\/vendor/.test(step));
assert.ok(cacheAt >= 0 && cacheAt < installAt, `${id} restores the npm cache before npm ci`);
assert.match(jobSteps[cacheAt], /^ {10}path: ~\/\.npm$/m, `${id} caches npm's download cache, not node_modules`);
assert.match(jobSteps[cacheAt], /hashFiles\('tools\/vendor\/package-lock\.json'\)/, `${id} keys the cache on the lockfile`);
assert.match(jobSteps[installAt], /npm ci --prefix tools\/vendor --ignore-scripts/, `${id} still installs without scripts`);
// The key names the job's own pinned image, so an image bump starts a fresh cache.
const image = /^ {6}image: mcr\.microsoft\.com\/playwright:(v\d+\.\d+\.\d+)-noble$/m.exec(body)?.[1];
assert.ok(image, `${id} runs in the pinned Playwright image`);
assert.match(jobSteps[cacheAt], new RegExp(`key: npm-\\$\\{\\{ runner\\.os \\}\\}-playwright-${image.replaceAll('.', '\\.')}-\\$\\{\\{ hashFiles`),
`${id} keys the cache on its image ${image}`);
}
});
test('browser job timeouts keep a real margin over measured runs', () => {
for (const id of ['frontend-coverage', 'browser-workflows']) {
const minutes = Number(/^ {4}timeout-minutes: (\d+)$/m.exec(allJobs.get(id))[1]);
assert.ok(minutes >= 20, `${id} allows at least 20 minutes, since runner speed varies`);
}
});
+40 -11
View File
@@ -73,11 +73,41 @@ test('overlay rules on concept selectors carry a decision marker', () => {
assert.deepEqual(violations, []);
});
// Each kept page stylesheet is linted: a page rule on a concept selector is an override like the overlay's.
test('kept page stylesheets mark their concept-selector overrides', () => {
for (const name of ['library.css']) {
const { violations } = lintOverlay(readFileSync(concept, 'utf8'), readFileSync(join(root, 'web/js/workspace', name), 'utf8'));
assert.deepEqual(violations, [], name);
// #249 PR F-b Task 3: every page is rebuilt, so no rule waits for a page PR any more and the history page's
// production-only rules live in the overlay in the concept's tokens (D48). A comment that opens with a page PR
// ("PR F: ...", "PR E/F: ...") or defers to one ("until ... PR", "retired in PR F") is a waiting rule; a provenance
// label inside a heading ("(#249 PR E)") is not.
const WAITING_RULE = [
/\/\*\s*PR [A-F](\/[A-F]|-[ab])?\b/i,
/\buntil\b[^*]*\b(PR [A-F]?|rebuild)/i,
/\bretired in PR\b/i,
/\binterim page guards\b/i,
];
const waitingRules = css => WAITING_RULE.filter(pattern => pattern.test(css)).map(String);
test('the waiting-rule guard catches the retired comments and spares provenance labels', () => {
// The exact comments this PR retired (workspace.css and library.css at 10a4847).
for (const retired of [
'/* Interim page guards: concept element rules (`.ca-tools dialog`, `pre`) now reach production dialogs and prompt text',
' that keep their own design until their page PR rebuilds them in concept markup. */',
'.ca-tools dialog.prompt-sender::backdrop { background: #000b; } /* PR D */',
'.ca-tools dialog.recorded-run-dialog { padding: 16px; } /* PR F */',
' .ca-tools .recorded-history .history-toolbar button { min-height: 44px; } /* PR F: concept coarse buttons are 40px */',
'/* Until their pages rebuild (PR F history): the history page\'s and the recorded run\'s prompt blocks keep their',
'/* PR E/F: the Archive dialog and the frozen field provenance keep `pre.prompt-text`, guarded against the concept `pre` rule. */',
" keeps the concept's) and are retired in PR F (history). */",
]) assert.notDeepEqual(waitingRules(retired), [], retired);
for (const kept of [
"/* Prompt library cards (#249 PR E): the concept's `article.prompt-card` (s653–708) with real prompts, titles and media. */",
'/* History variants panel (#249 PR F). */',
]) assert.deepEqual(waitingRules(kept), [], kept);
});
test('the overlay keeps no rule waiting for a page rebuild', () => {
const css = readFileSync(join(root, 'web/js/workspace/workspace.css'), 'utf8');
assert.deepEqual(waitingRules(css), []);
for (const retired of ['.library-chip', '--library-positive', '--library-negative', '--prompt-role']) {
assert.equal(css.includes(retired), false, `${retired} is retired`);
}
});
@@ -100,22 +130,21 @@ test('only workspace.html loads workspace CSS', () => {
assert.ok(offenders.every(path => path === 'web/workspace.html'), `unexpected consumers: ${offenders.join(', ')}`);
});
// Task A2 order: the concept's own order (toolkit, Viewer.js, concept) with the page stylesheets that their own PRs
// retire, then the production overlay last.
test('workspace.html loads controls, viewer, concept, page CSS and the overlay in order', () => {
// Task A2 order: the concept's own order (toolkit, Viewer.js, concept), then the production overlay last; the page
// stylesheets that waited for their PRs are all retired (PR F-b deleted the last, library.css).
test('workspace.html loads controls, viewer, concept and the overlay in order', () => {
const html = readFileSync(join(root, 'web/workspace.html'), 'utf8');
const hrefs = [...html.matchAll(/<link[^>]+rel="stylesheet"[^>]+href="([^"]+)"/g)].map(match => match[1]);
assert.deepEqual(hrefs, [
'js/shared/toolkit/controls.css',
'lib/viewerjs/viewer.min.css',
'js/workspace/concept.css',
'js/workspace/library.css',
'js/workspace/workspace.css',
]);
});
test('the retired shell, vocabulary, editor, Images and inspector stylesheets stay deleted', () => {
for (const name of ['style.css', 'vocabulary.css', 'library-editor.css', 'images.css', 'inspector.css']) {
test('the retired shell, vocabulary, editor, Images, inspector and library stylesheets stay deleted', () => {
for (const name of ['style.css', 'vocabulary.css', 'library-editor.css', 'images.css', 'inspector.css', 'library.css']) {
assert.equal(existsSync(join(root, 'web/js/workspace', name)), false, `web/js/workspace/${name} must not come back`);
}
});
+33
View File
@@ -0,0 +1,33 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { baselineGaps, measuredSources, readBaseline } from '../../tools/vendor/coverage-baseline-check.mjs';
const floor = { lines: 80, branches: 80, functions: 80, statements: 80 };
test('baseline gaps name measured files without a baseline entry', () => {
const gaps = baselineGaps(['a.js', 'nested/new.mjs'], { 'a.js': floor });
assert.deepEqual(gaps.missing, ['nested/new.mjs']);
assert.deepEqual(gaps.stale, []);
});
test('baseline gaps name baseline entries whose file no longer exists', () => {
const gaps = baselineGaps(['a.js'], { 'a.js': floor, 'gone.js': floor });
assert.deepEqual(gaps.missing, []);
assert.deepEqual(gaps.stale, ['gone.js']);
});
test('baseline gaps are empty when files and baseline agree', () => {
assert.deepEqual(baselineGaps(['a.js'], { 'a.js': floor }), { missing: [], stale: [] });
});
test('the committed baseline covers exactly the measured frontend sources', () => {
const gaps = baselineGaps(measuredSources(), readBaseline());
assert.deepEqual(gaps.missing, [], `Missing baseline (run coverage:update): ${gaps.missing.join(', ')}`);
assert.deepEqual(gaps.stale, [], `Stale baseline entries (file removed): ${gaps.stale.join(', ')}`);
});
test('the real gate and this check select files through the same function', () => {
const gate = readFileSync(new URL('../../tools/vendor/frontend-coverage.mjs', import.meta.url), 'utf8');
assert.match(gate, /discoverSources\(sourceRoot\)/);
});
+44
View File
@@ -0,0 +1,44 @@
// A stand-in for tests/browser/server.py: `--port N` (0 = an OS-chosen port)
// serves the readiness page and announces the bound port on stdout as
// `PM_TEST_SERVER_PORT=<n>`, `--fail` writes to stderr and exits before
// listening, and GET /__exit makes the process die unexpectedly after writing a
// last stderr line. GET /__stderr?text=T writes T to stderr before answering.
// `--split-announce` writes the announcement in two chunks, the first ending
// mid-number. `--exit-on-stdin-eof` exits when its stdin closes, as server.py does.
import { createServer } from 'node:http';
const args = process.argv.slice(2);
const port = Number(args[args.indexOf('--port') + 1]);
if (args.includes('--fail')) {
process.stderr.write('fake server: refusing to start\nlast fake stderr line\n');
process.exit(4);
}
const server = createServer((request, response) => {
const url = new URL(request.url, 'http://fake');
if (url.pathname === '/__stderr') {
process.stderr.write(url.searchParams.get('text'));
response.end('written');
return;
}
if (request.url === '/__exit') {
process.stderr.write('fake server: crashed on purpose\n');
response.end('bye', () => process.exit(3));
return;
}
response.end(request.url === '/prompt_manager/test' ? 'ready' : `pid ${process.pid}`);
});
server.listen(port, '127.0.0.1', () => {
const line = `PM_TEST_SERVER_PORT=${server.address().port}\n`;
if (!args.includes('--split-announce')) {
process.stdout.write(`fake server: listening\n${line}`);
return;
}
// The first chunk is a complete-looking number, one digit short of the real port.
process.stdout.write(`fake server: listening\n${line.slice(0, -2)}`);
setTimeout(() => process.stdout.write(line.slice(-2)), 300);
});
if (args.includes('--exit-on-stdin-eof')) {
process.stdin.resume();
process.stdin.once('end', () => process.exit(0));
}
for (const signal of ['SIGTERM', 'SIGINT']) process.once(signal, () => server.close(() => process.exit(0)));
+93
View File
@@ -0,0 +1,93 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { plan, runCoverage } from '../../tools/vendor/frontend-coverage.mjs';
const steps = argv => plan(argv).map(({ step, ...options }) => (Object.keys(options).length ? { step, ...options } : step));
function tempPaths(t) {
const root = mkdtempSync(join(tmpdir(), 'pm-coverage-cli-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const reportRoot = join(root, 'coverage/js');
return { root, paths: { repositoryRoot: root, tools: join(root, 'tools'), reportRoot, raw: join(reportRoot, 'raw'), sourceRoot: join(root, 'web/js') } };
}
function recorder() {
const calls = [];
const run = (args, env = {}) => { calls.push({ args, env }); };
return { calls, run };
}
test('no arguments plans the unchanged full run', () => {
assert.deepEqual(steps([]), [
'clean', 'node-units', 'viewer', { step: 'workflows', shard: null }, 'report', { step: 'check', updateBaseline: false },
]);
assert.deepEqual(steps(['--update-baseline']), [
'clean', 'node-units', 'viewer', { step: 'workflows', shard: null }, 'report', { step: 'check', updateBaseline: true },
]);
});
test('--shard 2/3 plans only workflows with --shard 2/3 and no report', () => {
assert.deepEqual(steps(['--shard', '2/3']), ['prepare', { step: 'workflows', shard: '2/3' }]);
assert.deepEqual(steps(['--shard=3/3']), ['prepare', { step: 'workflows', shard: '3/3' }]);
});
test('--shard 1/N also runs the node units and the viewer suite', () => {
assert.deepEqual(steps(['--shard', '1/3']), ['prepare', 'node-units', 'viewer', { step: 'workflows', shard: '1/3' }]);
assert.deepEqual(steps(['--shard', '1/1']), ['prepare', 'node-units', 'viewer', { step: 'workflows', shard: '1/1' }]);
});
test('--merge-only plans the report and the check over existing raw coverage', () => {
assert.deepEqual(steps(['--merge-only']), ['require-raw', 'report', { step: 'check', updateBaseline: false }]);
assert.deepEqual(steps(['--merge-only', '--update-baseline']), ['require-raw', 'report', { step: 'check', updateBaseline: true }]);
});
test('invalid shard and mode combinations are refused', () => {
for (const argv of [['--shard'], ['--shard', '0/3'], ['--shard', '4/3'], ['--shard', '2'], ['--shard', 'a/b'], ['--shard', '1/0'], ['--shard', '1.5/3']]) {
assert.throws(() => plan(argv), /--shard needs I\/N/, argv.join(' '));
}
assert.throws(() => plan(['--shard', '1/2', '--merge-only']), /either --shard or --merge-only/);
assert.throws(() => plan(['--shard', '1/2', '--update-baseline']), /--update-baseline .* --merge-only or a full run/);
assert.throws(() => plan(['--shard', '1/2', '--shard', '2/2']), /--shard once/);
assert.throws(() => plan(['--report']), /Unknown argument: --report/);
});
test('--merge-only refuses an empty raw dir', async t => {
const { paths } = tempPaths(t);
const { calls, run } = recorder();
await assert.rejects(runCoverage({ argv: ['--merge-only'], run, paths }), /no raw coverage .*coverage\/js\/raw.* run the --shard steps first/i);
mkdirSync(paths.raw, { recursive: true });
await assert.rejects(runCoverage({ argv: ['--merge-only'], run, paths }), /no raw coverage/i);
writeFileSync(join(paths.raw, 'notes.txt'), 'not coverage');
await assert.rejects(runCoverage({ argv: ['--merge-only'], run, paths }), /no raw coverage/i);
assert.deepEqual(calls, [], 'nothing is reported or checked over partial or empty coverage');
});
test('a shard keeps raw coverage other shards wrote and passes its shard to Playwright', async t => {
const { paths } = tempPaths(t);
mkdirSync(paths.raw, { recursive: true });
const earlier = join(paths.raw, 'browser-earlier-shard.json');
writeFileSync(earlier, '{"result":[]}');
const { calls, run } = recorder();
await runCoverage({ argv: ['--shard', '2/3'], run, paths });
assert.ok(existsSync(earlier), 'raw files from another shard survive');
assert.equal(calls.length, 1, 'shard 2 runs only the workflows');
const [{ args, env }] = calls;
assert.deepEqual(args.slice(-6), ['--project', 'chromium', '--project', 'touch', '--shard', '2/3']);
assert.equal(env.PM_JS_COVERAGE, paths.raw);
});
test('shard 1 runs the node units into the shared raw dir, then the viewer suite, then its workflows', async t => {
const { root, paths } = tempPaths(t);
mkdirSync(join(root, 'tests/js'), { recursive: true });
writeFileSync(join(root, 'tests/js/a.test.js'), '');
const { calls, run } = recorder();
await runCoverage({ argv: ['--shard', '1/3'], run, paths });
assert.equal(calls.length, 3);
assert.deepEqual(calls[0].args, ['--test', 'tests/js/a.test.js']);
assert.equal(calls[0].env.NODE_V8_COVERAGE, paths.raw);
assert.ok(calls[1].args.includes(join(paths.tools, 'playwright.config.mjs')));
assert.deepEqual(calls[2].args.slice(-2), ['--shard', '1/3']);
});
+38
View File
@@ -0,0 +1,38 @@
// Fast, browser-free check that frontend-coverage-baseline.json lists exactly the
// files the coverage gate measures. CI's `frontend coverage` job reports a missing
// entry only after ~20 minutes; this reports it in milliseconds.
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { repositoryRoot } from './vendor-viewerjs.mjs';
import { discoverSources } from './frontend-coverage.mjs';
const baselinePath = join(dirname(fileURLToPath(import.meta.url)), 'frontend-coverage-baseline.json');
export function measuredSources() {
return discoverSources(join(repositoryRoot, 'web/js'));
}
export function readBaseline() {
return JSON.parse(readFileSync(baselinePath, 'utf8'));
}
export function baselineGaps(files, baseline) {
const measured = new Set(files);
return {
missing: files.filter(file => !baseline[file]),
stale: Object.keys(baseline).filter(file => !measured.has(file)).sort(),
};
}
function main() {
const { missing, stale } = baselineGaps(measuredSources(), readBaseline());
if (!missing.length && !stale.length) {
console.log('Coverage baseline matches the measured frontend files.');
return;
}
if (missing.length) console.error(`Missing baseline for:\n ${missing.join('\n ')}\nRun: npm run --prefix tools/vendor coverage:update`);
if (stale.length) console.error(`Baseline lists files that no longer exist:\n ${stale.join('\n ')}\nRemove them from tools/vendor/frontend-coverage-baseline.json`);
process.exitCode = 1;
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main();
+44 -14
View File
@@ -155,12 +155,36 @@
"functions": 98,
"statements": 98
},
"workspace/file-inspector.mjs": {
"workspace/activity-job-view.mjs": {
"lines": 97,
"branches": 83,
"functions": 98,
"statements": 97
},
"workspace/activity-scan.mjs": {
"lines": 95,
"branches": 92,
"functions": 88,
"branches": 64,
"functions": 98,
"statements": 95
},
"workspace/activity-store.mjs": {
"lines": 98,
"branches": 95,
"functions": 98,
"statements": 98
},
"workspace/activity.mjs": {
"lines": 95,
"branches": 87,
"functions": 98,
"statements": 95
},
"workspace/file-inspector.mjs": {
"lines": 96,
"branches": 92,
"functions": 92,
"statements": 96
},
"workspace/heading.mjs": {
"lines": 98,
"branches": 98,
@@ -229,7 +253,7 @@
},
"workspace/index.mjs": {
"lines": 98,
"branches": 96,
"branches": 97,
"functions": 98,
"statements": 98
},
@@ -264,10 +288,10 @@
"statements": 98
},
"workspace/library.mjs": {
"lines": 95,
"lines": 96,
"branches": 85,
"functions": 96,
"statements": 95
"statements": 96
},
"workspace/media-viewer.mjs": {
"lines": 98,
@@ -282,10 +306,10 @@
"statements": 98
},
"workspace/prompt-blocks.mjs": {
"lines": 89,
"branches": 89,
"lines": 90,
"branches": 92,
"functions": 98,
"statements": 89
"statements": 90
},
"workspace/prompt-inspector.mjs": {
"lines": 98,
@@ -302,9 +326,15 @@
"workspace/recorded-run.mjs": {
"lines": 95,
"branches": 83,
"functions": 77,
"functions": 80,
"statements": 95
},
"workspace/saved-views.mjs": {
"lines": 98,
"branches": 95,
"functions": 95,
"statements": 98
},
"workspace/state.mjs": {
"lines": 98,
"branches": 98,
@@ -319,15 +349,15 @@
},
"workspace/toast.mjs": {
"lines": 98,
"branches": 64,
"branches": 98,
"functions": 98,
"statements": 98
},
"workspace/toolbar.mjs": {
"lines": 94,
"branches": 93,
"lines": 96,
"branches": 96,
"functions": 98,
"statements": 94
"statements": 96
},
"workspace/topbar.mjs": {
"lines": 98,
+99 -20
View File
@@ -1,4 +1,4 @@
import { readFileSync, readdirSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { existsSync, readFileSync, readdirSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { spawnSync } from 'node:child_process';
@@ -7,33 +7,69 @@ import { repositoryRoot } from './vendor-viewerjs.mjs';
import { discoverSources, checkCoverage } from '@comfyassets/toolkit/testing/coverage';
export { discoverSources, checkCoverage };
const metrics = ['lines', 'branches', 'functions', 'statements'];
const SHARD = /^([1-9]\d*)\/([1-9]\d*)$/;
const USAGE = 'usage: frontend-coverage.mjs [--update-baseline] | --shard I/N | --merge-only [--update-baseline]';
function run(args, env = {}) {
function runNode(args, env = {}) {
const result = spawnSync(process.execPath, args, { cwd: repositoryRoot, stdio: 'inherit',
env: { ...process.env, ...env } });
if (result.status !== 0) throw new Error(`Command failed (${result.status}): ${args.join(' ')}`);
}
async function main() {
const tools = dirname(fileURLToPath(import.meta.url));
function defaultPaths() {
const reportRoot = join(repositoryRoot, 'coverage/js');
const raw = join(reportRoot, 'raw');
const sourceRoot = join(repositoryRoot, 'web/js');
return { repositoryRoot, tools: dirname(fileURLToPath(import.meta.url)), reportRoot,
raw: join(reportRoot, 'raw'), sourceRoot: join(repositoryRoot, 'web/js') };
}
function parseShard(value) {
const match = SHARD.exec(value ?? '');
if (!match || Number(match[1]) > Number(match[2])) {
throw new Error(`--shard needs I/N with 1 <= I <= N, got ${value ?? 'nothing'}\n${USAGE}`);
}
return { index: Number(match[1]), total: Number(match[2]), value };
}
// Turn the command line into the ordered steps of one coverage run:
// - no mode: the full run (clean, node units, viewer suite, chromium+touch
// workflows, report, check), exactly as before shards existed;
// - --shard I/N: one slice of the workflows into coverage/js/raw, without wiping
// it or reporting; shard 1 also runs the node units and the viewer suite;
// - --merge-only: report and check over raw coverage the shards already wrote.
export function plan(argv) {
let shard = null;
let mergeOnly = false;
let updateBaseline = false;
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === '--update-baseline') updateBaseline = true;
else if (arg === '--merge-only') mergeOnly = true;
else if (arg === '--shard' || arg.startsWith('--shard=')) {
if (shard) throw new Error(`Pass --shard once\n${USAGE}`);
shard = parseShard(arg === '--shard' ? argv[(index += 1)] : arg.slice('--shard='.length));
} else throw new Error(`Unknown argument: ${arg}\n${USAGE}`);
}
if (shard && mergeOnly) throw new Error(`Pass either --shard or --merge-only\n${USAGE}`);
if (shard && updateBaseline) throw new Error(`--update-baseline needs --merge-only or a full run, not a shard\n${USAGE}`);
const check = { step: 'check', updateBaseline };
if (mergeOnly) return [{ step: 'require-raw' }, { step: 'report' }, check];
if (shard) {
const first = shard.index === 1 ? [{ step: 'node-units' }, { step: 'viewer' }] : [];
return [{ step: 'prepare' }, ...first, { step: 'workflows', shard: shard.value }];
}
return [{ step: 'clean' }, { step: 'node-units' }, { step: 'viewer' }, { step: 'workflows', shard: null },
{ step: 'report' }, check];
}
function rawCoverageFiles(raw) {
return existsSync(raw) ? readdirSync(raw).filter(file => file.endsWith('.json')) : [];
}
function checkSummary({ tools, reportRoot, sourceRoot }, updateBaseline) {
const files = discoverSources(sourceRoot);
rmSync(reportRoot, { recursive: true, force: true });
mkdirSync(raw, { recursive: true });
const tests = readdirSync(join(repositoryRoot, 'tests/js')).filter(file => file.endsWith('.test.js')).map(file => `tests/js/${file}`);
run(['--test', ...tests], { NODE_V8_COVERAGE: raw });
const playwright = join(tools, 'node_modules/@playwright/test/cli.js');
run([playwright, 'test', '--config', join(tools, 'playwright.config.mjs')]);
run([playwright, 'test', '--config', join(tools, 'playwright.workflows.config.mjs'),
'--project', 'chromium', '--project', 'touch'], { PM_JS_COVERAGE: raw });
run([join(tools, 'node_modules/c8/bin/c8.js'), 'report', '--all', '--src', sourceRoot,
'--include', 'web/js/**/*.js', '--include', 'web/js/**/*.mjs', '--temp-directory', raw, '--reports-dir', reportRoot,
'--reporter', 'text', '--reporter', 'json-summary', '--reporter', 'html']);
const summary = JSON.parse(readFileSync(join(reportRoot, 'coverage-summary.json'), 'utf8'));
const baselinePath = join(tools, 'frontend-coverage-baseline.json');
if (process.argv.includes('--update-baseline')) {
if (updateBaseline) {
const previous = JSON.parse(readFileSync(baselinePath, 'utf8'));
const failures = checkCoverage(summary, previous, sourceRoot, files.filter(file => previous[file]));
if (failures.length) throw new Error(`Baseline updates cannot lower coverage:\n${failures.join('\n')}`);
@@ -53,8 +89,51 @@ async function main() {
const failures = checkCoverage(summary, baseline, sourceRoot, files);
if (failures.length) throw new Error(`Frontend coverage regressed:\n${failures.join('\n')}`);
}
console.log(`Frontend coverage passed for ${files.length} files. Report: ${relative(repositoryRoot, reportRoot)}/index.html`);
return files.length;
}
export async function runCoverage({ argv = process.argv.slice(2), run = runNode, paths = defaultPaths() } = {}) {
const { repositoryRoot: root, tools, reportRoot, raw, sourceRoot } = paths;
const playwright = join(tools, 'node_modules/@playwright/test/cli.js');
const actions = {
clean() {
rmSync(reportRoot, { recursive: true, force: true });
mkdirSync(raw, { recursive: true });
},
prepare() {
mkdirSync(raw, { recursive: true });
},
'require-raw'() {
if (!rawCoverageFiles(raw).length) {
throw new Error(`No raw coverage in ${relative(root, raw) || raw}: run the --shard steps first, `
+ 'or put every shard\'s raw files there');
}
},
'node-units'() {
const tests = readdirSync(join(root, 'tests/js')).filter(file => file.endsWith('.test.js'))
.map(file => `tests/js/${file}`);
run(['--test', ...tests], { NODE_V8_COVERAGE: raw });
},
viewer() {
run([playwright, 'test', '--config', join(tools, 'playwright.config.mjs')]);
},
workflows({ shard }) {
run([playwright, 'test', '--config', join(tools, 'playwright.workflows.config.mjs'),
'--project', 'chromium', '--project', 'touch', ...(shard ? ['--shard', shard] : [])], { PM_JS_COVERAGE: raw });
},
report() {
run([join(tools, 'node_modules/c8/bin/c8.js'), 'report', '--all', '--src', sourceRoot,
'--include', 'web/js/**/*.js', '--include', 'web/js/**/*.mjs', '--temp-directory', raw, '--reports-dir', reportRoot,
'--reporter', 'text', '--reporter', 'json-summary', '--reporter', 'html']);
},
check({ updateBaseline }) {
const count = checkSummary(paths, updateBaseline);
console.log(`Frontend coverage passed for ${count} files. Report: ${relative(root, reportRoot)}/index.html`);
},
};
for (const { step, ...options } of plan(argv)) await actions[step](options);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch(error => { console.error(error.message); process.exitCode = 1; });
runCoverage().catch(error => { console.error(error.message); process.exitCode = 1; });
}
+3
View File
@@ -15,6 +15,9 @@
"test:comfyui": "playwright test --config comfyui.config.mjs",
"test:executions": "playwright test --config playwright.execution.config.mjs",
"test:coverage": "node frontend-coverage.mjs",
"test:coverage:shard": "node frontend-coverage.mjs --shard",
"coverage:merge": "node frontend-coverage.mjs --merge-only",
"coverage:baseline-check": "node coverage-baseline-check.mjs",
"coverage:update": "node frontend-coverage.mjs --update-baseline",
"test:compat": "playwright test --config pack-compat.config.mjs"
},

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