Compare commits

..
Author SHA1 Message Date
Vito Sansevero 94e8dcb73d fix(send): refresh held tickets and compare the reviewed composition for All (#542)
A review held longer than the host's 30 s ticket lifetime would have refused
the first node while sending the rest. Every node now takes a fresh ticket
except the first of a pass taken in the same click, and that ticket must
show the reviewed Before texts and composition. The revision is checked
until something has been posted. Node review sections are labelled by
their h4 heading.
2026-10-31 15:06:49 -07:00
Vito Sansevero 611249526e feat(send): send a prompt to all eligible nodes of a window (#542)
The Destination node select offers "All N eligible nodes" when a window has
two or more nodes that can receive the prompt. It is never selected for you.
Apply previews every node first and writes nothing if one refuses; then it
sends one node after another in node id order, each later node with a fresh
ticket checked against the reviewed text, and reports "Sent to X of N"
naming each refusal. A lost reply stops the run and names the node whose
outcome is unknown. Recorded as D191.
2026-10-31 14:56:21 -07:00
Vito e29c23de8d chore(init): print a one-line startup banner (#574)
Replace the version line, one Loaded line per node and the total with
"[ComfyUI-PromptManager] Version: <v> - <n> tools loaded".

Closes #573
2026-10-31 18:56:42 +00:00
Vito b82e68a9d0 ci: stop testing macOS in CI (#572)
macOS is not a popular ComfyUI platform. Drop the macOS legs from the
portability workflow and its native-trash proof; Windows keeps its
Python 3.12-3.14 legs. The macOS code stays, and README calls its
support best-effort.

Closes #571
2026-10-31 18:13:27 +00:00
Vito fb02066cda test(vocabulary): open the long-list check as a new page, not a reload (#567) (#570)
Firefox restores the scroll position of a reloaded page's tag window, so its
end scrolled into view and appended a second page before the 100-row check.
Scrolling the window to its top before the reload did not always stick on CI.
A fresh navigation has no position to restore.
2026-10-31 18:13:05 +00:00
Vito ab17818792 fix(system): show the Images page count in System Overview (#568)
System > Overview's Images metric read the overview's indexed
generated_images rows, while the workspace Overview card and the Images
sidebar badge show the Images page's own total, which also lists files
no row links (110 against 122 on the demo library). The concept's
System metric and sidebar badge are both `images.length`, so the metric
now reads the shell's Images count: one source for all three, a dash
until it is read or when its read failed, re-read on each visit.

The Statistics dialog's "Library totals" were the legacy /stats raw row
counts under the same nouns; they now read "Database records" with
"Prompt records", "Tags on prompts" and "Image records". /stats and the
legacy admin page are unchanged.

Closes #555
2026-10-31 18:04:45 +00:00
Vito ef97d01dbd ci: run frontend coverage in four shards (#565)
* ci: run frontend coverage in four shards

Coverage shards reached their 18-minute step limit three times on
2026-10-31 with every test passing. Split the run into four shards with
the limits unchanged; the merge job now expects four raw coverage
artifacts, and the vendor checks move to shard 4.

Closes #563

* test(tags): pin the 390px window check to the first page's last row

Reaching the last row loads the next page (D183), and last() then followed
row 200, far outside the window, so the WebKit check failed in 4-5 of 20
runs on 2 CPUs. Pinned to row 100, it passed 90 of 90 across WebKit,
Chromium and Firefox.

Closes #566
2026-10-31 16:47:50 +00:00
Vito b7a43755ae chore: release 3.4.1 (#564)
Fixes the Windows exit hang with watchdog 7 (#562), stops LoRA scan
errors from showing server details (#560), and carries the code-quality
sweep (#558).
2026-10-31 15:38:28 +00:00
Vito 2c7e84dedd fix(security): send a generic LoRA scan error to the browser (#560)
An unexpected exception's text could reach the page in the LoRA scan's
error frame, including a path or a model name. Those failures now send a
fixed message and keep the detail in the server log. Expected refusals keep
their wording, and an OSError still shows its reason and the file's basename.

Refs #552
2026-10-31 13:59:29 +00:00
Vito cc65d15d14 fix(deps): keep watchdog below 7 (#562)
watchdog 7.0.0 (released 2026-10-31) makes the package's exit tests hang
on Windows on Python 3.12 to 3.14; the same commit passed with 6.0.0.
Cap it until observer.stop() is bounded at exit.

Refs #561
2026-10-31 13:21:14 +00:00
24 changed files with 590 additions and 190 deletions
+5 -33
View File
@@ -1,9 +1,9 @@
name: Windows and macOS
name: Windows
permissions:
contents: read
# Non-blocking portability runs (#420): never on pull requests or in the merge queue, and no context
# Non-blocking Windows runs (#420; macOS dropped from CI in #571): never on pull requests or in the merge queue, and no context
# here is a required check. A red run is fixed at its cause before the workspace cutover (#334).
on:
push:
@@ -25,10 +25,7 @@ env:
# run counts (tests/vendor/ci-workflow.test.mjs recomputes it, #493).
# Measured 2026-10-29 on main runs 40935337099 (dd247e3), 40938407252 (dd247e3), 40941240624 (dd247e3):
# slowest leg: 7m24s (py3.12 windows) / 7m31s (py3.13 windows) / 7m32s (py3.14 windows) -> 15 min
# (windows 4m56s-7m32s, macos 4m39s-6m14s)
# native-trash (#499) has no green main runs yet: its probe, run 40949370943 (2026-10-29), took
# about 1 minute per macOS leg, so ceil(1.5 x 2) + 3 gives it 6 minutes (step 4). Recalibrate
# after three green runs.
# (windows 4m56s-7m32s)
# native-trash-linux (#543) has no green main runs yet: it installs the dev requirements and runs
# one test module, about 1 minute by the same estimate, so ceil(1.5 x 2) + 3 gives it 6 minutes.
# Recalibrate after three green runs.
@@ -40,7 +37,7 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [windows-latest, macos-latest]
os: [windows-latest]
python-version: ["3.12", "3.13", "3.14"]
steps:
@@ -60,7 +57,7 @@ jobs:
# The runner's TEMP is on C:, the OS disk, where a flush took about 6 ms
# and a test database's create-to-delete about 111 ms; on the D: work
# disk the same took 0.5 ms and 23 ms (macOS: 19 ms). Tests write their
# disk the same took 0.5 ms and 23 ms. Tests write their
# databases under TEMP, so it moves to a folder on D: (measured in #487,
# run 40915866444). The runner's TEMP was an 8.3 short name
# (C:\Users\RUNNER~1\...), so the new one is spelled by its short name too
@@ -83,31 +80,6 @@ jobs:
PM_TEST_SLOWEST: "40"
run: python -m tests.run_suite -v
# The real Trash on both macOS CPUs (#499 design §10.2). It runs apart from the suite, so a red
# suite never skips the native proof.
native-trash:
name: native trash on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
timeout-minutes: 6
strategy:
fail-fast: false
matrix:
os: [macos-latest, macos-15-intel]
steps:
- uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: "3.12"
- name: Native trash proof
timeout-minutes: 4
env:
PM_NATIVE_TRASH: macos
run: python -m unittest tests.test_trash_macos_native tests.test_trash_macos -v
# The same tests as the fake mount table, on a real ext4 mount used as the output root (#543). A
# runner can mount with sudo; the tests run as the normal user, so owner and mode checks are real.
native-trash-linux:
+2 -2
View File
@@ -28,7 +28,7 @@ env:
# py matrix, slowest py3.10 on windows-latest: 12m43s / 15m54s / 14m53s -> 27 min
# (ubuntu 5m27s-6m27s, macos 6m14s-8m30s, windows 11m42s-15m54s)
# 3.10 and 3.11 left the matrix and 3.14 joined it in #356; re-measure after three green main runs.
# Windows and macOS moved to portability.yml (#420), which has its own measured limit (#493);
# Windows moved to portability.yml (#420; macOS left CI in #571), which has its own measured limit (#493);
# this Linux-only matrix keeps 27 minutes until it is re-measured.
# real ComfyUI compatibility: 13m32s / 8m55s / 11m46s -> 24 min
# coverage gate (85%): 7m26s / 7m22s / 7m54s -> 15 min
@@ -39,7 +39,7 @@ env:
# Pull requests run a fast subset (#420): Python 3.12 on ubuntu-latest, coverage, lint and frontend
# modules. The merge queue (merge_group), pushes to main and manual runs add Python 3.13 and 3.14 on
# Linux and the real ComfyUI job. "all tests" is the required gate that holds the queue to that set.
# Windows and macOS run in portability.yml (push to main, nightly, on demand) and never gate a merge.
# Windows runs in portability.yml (push to main, nightly, on demand) and never gate a merge.
jobs:
test:
name: py${{ matrix.python-version }} on ${{ matrix.os }}
+12 -8
View File
@@ -41,9 +41,13 @@ concurrency:
# 7.3m-12.2m), because --shard splits by test count and the slow concept specs sort into shard 1. The page
# workflows now run in three shards per browser with the limits unchanged; the per-shard times are
# re-measured after three green main runs.
# #563: coverage shards reached their 18-minute step limit three times on 2026-10-31 with no failing
# test (PR #560 shard 2/3: 814 passed in 17.9m; queue run 41206495451 shard 1/3: 827 passed; PR #564
# shard 2/3 twice), against recent green shards of 11m-17m. Coverage now runs in four shards with the
# limits unchanged; the per-shard times are re-measured after three green main runs.
jobs:
frontend-coverage:
name: frontend coverage (shard ${{ matrix.shard }}/3)
name: frontend coverage (shard ${{ matrix.shard }}/4)
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;
@@ -59,7 +63,7 @@ jobs:
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3]
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v7
# The image's own Python 3.12 and Node 24: setup-python/setup-node install the runner's toolcache
@@ -88,13 +92,13 @@ jobs:
- run: npm ci --prefix tools/vendor --ignore-scripts
# 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
- if: matrix.shard == 4
run: npm run --prefix tools/vendor check
- if: matrix.shard == 3
- if: matrix.shard == 4
run: npm run --prefix tools/vendor test
- name: Page workflows with coverage, shard ${{ matrix.shard }}/3
- name: Page workflows with coverage, shard ${{ matrix.shard }}/4
timeout-minutes: 18
run: npm run --prefix tools/vendor test:coverage:shard -- ${{ matrix.shard }}/3
run: npm run --prefix tools/vendor test:coverage:shard -- ${{ matrix.shard }}/4
- name: Upload raw coverage
uses: actions/upload-artifact@v7
with:
@@ -136,13 +140,13 @@ jobs:
with:
pattern: coverage-raw-*
path: ${{ runner.temp }}/coverage-raw
# coverage:merge only checks that raw files exist; merging two of three shards would report
# coverage:merge only checks that raw files exist; merging three of four 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
EXPECTED_SHARDS: 4
run: |
found=0
for shard in $(seq 1 "$EXPECTED_SHARDS"); do
+2 -2
View File
@@ -236,12 +236,12 @@ The documentation for those pages is in [README-legacy.md](README-legacy.md).
| Layer | What runs |
| --- | --- |
| Python | Over 200 test files on Linux, Windows and macOS with Python 3.12 to 3.14, with an 85% coverage gate |
| Python | Over 200 test files on Linux and Windows with Python 3.12 to 3.14, with an 85% coverage gate |
| Frontend | About 100 unit test files, with coverage floors per file |
| Browser | Over 60 Playwright spec files in Chromium, Firefox, WebKit and touch emulation |
| Real ComfyUI | A pinned CPU ComfyUI, started in Classic and Nodes 2.0, running real workflows |
Linux runs on every pull request and in the merge queue. Windows and macOS run on every push to main and nightly. To run the tests yourself, see [TESTING.md](TESTING.md).
Linux runs on every pull request and in the merge queue. Windows runs on every push to main and nightly. macOS is not tested; it should work, but support is best-effort. To run the tests yourself, see [TESTING.md](TESTING.md).
## License
+48 -23
View File
@@ -74,14 +74,14 @@ 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 test:coverage:shard -- 1/4 # node units, viewer suite, workflows 1/4
npm run --prefix tools/vendor test:coverage:shard -- 2/4 # workflows 2/4 (likewise 3/4, 4/4)
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
every shard first (CI's merge job checks all four 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`,
@@ -189,14 +189,14 @@ 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. It requires the three `frontend coverage (shard N/3)` jobs and
aggregate job. It requires the four `frontend coverage (shard N/4)` jobs and
`frontend coverage (merge)` to succeed on every event. The four
`page workflows (firefox|webkit, shard N/2)` jobs run only in the merge queue,
on `main` and on `workflow_dispatch` (see "Pull requests and the merge queue").
`viewerjs` requires them to be skipped on a pull request and to succeed
everywhere else. Any other 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
runners have 4 vCPUs). Each coverage shard runs `test:coverage:shard -- N/4`
(shard 1 also runs the node units and the viewer suite, shard 4 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
@@ -261,7 +261,7 @@ the process exits non-zero.
job fails anyway and the dump is what matters.
- `PM_TEST_SLOWEST=N` (off by default) lists the N slowest tests after the
summary, each timed from its `start:` line to the next one like the hang
limit. The Windows and macOS jobs (`portability.yml`) set it to 40, so every
limit. The Windows jobs (`portability.yml`) set it to 40, so every
run shows where its time went (#487). The list prints only when the run
ends: a job GitHub cancels or a watchdog exit prints none, so read the
timestamps between `start:` lines instead.
@@ -327,7 +327,7 @@ Python 3.13/3.14, real ComfyUI and Firefox/WebKit failures first show up in the
merge queue, on the merge-group commit rather than the
pushed one. Find that run with `gh run list --event merge_group --workflow test.yml`
(or `viewerjs-check.yml`) and match its head branch
(`gh-readonly-queue/main/pr-<N>-...`). Windows and macOS failures show up only
(`gh-readonly-queue/main/pr-<N>-...`). Windows failures show up only
after the merge, in `gh run list --workflow portability.yml`.
Start fixing as soon as a failed log shows the cause, but push the fix only after
@@ -450,7 +450,7 @@ measurement: `ubuntu-latest`, one Python, no coverage, only the wall-clock tests
It runs on every push to `main` and on `workflow_dispatch`, never on pull
requests, because shared-runner timing must not block a merge. Dispatch it on a
branch to measure before merging.
Shared Windows and macOS runners do not run them; the structural tests cover
Shared Windows runners do not run them; the structural tests cover
portability. Run it locally with
`PROMPTMANAGER_PERF_BUDGET=1 python -m unittest tests.test_api_workspace_images.TestWorkspaceImages.test_filter_budget`
and quote the printed p95 in the PR body.
@@ -646,10 +646,10 @@ Before changing paths, SQLite, streams, restore or cleanup:
ordinary busy timeout; cross-thread handoffs wait on events with generous
ceilings, never a short wall clock (#242).
- Run native Windows CI early for these changes. Pull requests and the merge
queue run only Linux, so start Windows and macOS on the branch with
queue run only Linux, so start Windows on the branch with
`gh workflow run portability.yml --ref <branch>`.
Include a Windows impact and evidence note in the PR. All six Windows/macOS
combinations in that run, every Linux leg and every applicable
Include a Windows impact and evidence note in the PR. All three Windows
legs in that run, every Linux leg and every applicable
exact-head check must pass before merge. Portable `ntpath`/`PureWindowsPath`
controls do not establish native filesystem, locking or cleanup success.
@@ -664,11 +664,11 @@ what runs:
| `py3.13 on ubuntu-latest`, `py3.14 on ubuntu-latest` | not created | run |
| `real ComfyUI compatibility` | skipped | runs |
| `coverage gate (85%)`, `black and flake8`, `frontend modules`, `bandit (high severity)` | run | run |
| `frontend coverage (shard N/3)`, `frontend coverage (merge)` (vendor pins and tooling tests on shard 3) | run | run |
| `frontend coverage (shard N/4)`, `frontend coverage (merge)` (vendor pins and tooling tests on shard 4) | run | run |
| `page workflows (firefox\|webkit, shard N/2)` | skipped | run |
| `all tests`, `viewerjs` (gates) | run | run |
| `perf budget` | skipped | push and dispatch only (not in the queue) |
| `py3.12-3.14 on windows-latest/macos-latest` (`portability.yml`) | not run | push to `main`, nightly, `workflow_dispatch` only; never required |
| `py3.12-3.14 on windows-latest` (`portability.yml`) | not run | push to `main`, nightly, `workflow_dispatch` only; never required |
GitHub has one required set for pull requests and the queue. A job skipped by
its `if:` reports Success. A dropped matrix leg never reports, so it would stay
@@ -681,8 +681,9 @@ guarantee:
So a merge group cannot turn green unless all three Linux Python legs, real
ComfyUI and the four browser shards passed.
Windows and macOS (Python 3.12-3.14) are out of the blocking path for now
(maintainer, 2026-10-28). They run in `portability.yml` on every push to `main`,
Windows (Python 3.12-3.14) is out of the blocking path for now
(maintainer, 2026-10-28). macOS is not tested in CI (maintainer, 2026-10-31, #571);
its code stays and support is best-effort. They run in `portability.yml` on every push to `main`,
nightly and on demand, and no context there is required. A red portability run
is still a failure: fix it at its cause, not by rerunning, and have every one
green before the workspace cutover (#334). `tests/vendor/ci-workflow.test.mjs` pins the
@@ -1407,6 +1408,8 @@ is shorter than the row it reserves.
Each Overview card is the count of the page it names: the shell's Library, Images and Tags sidebar counts, one source,
so a card and its badge cannot differ. `/prompt_manager/stats` is for the legacy page and counts raw rows. A visit re-reads
the three unless a read is in flight. An unread or failed card shows a dash; Retry re-reads only the failed counts.
System › Overview's Images metric reads the same shell count (#555), so it, the Overview card and the Images badge show
one number; its Statistics dialog labels the legacy row counts as "Database records".
Tests: `tests/js/workspace-state.test.js`, `tests/browser/workspace.spec.mjs`, `tests/comfyui/workspace.spec.mjs`.
### Images page controls and recorded deviations (#249 PR C)
@@ -2549,8 +2552,8 @@ files to the system trash. **No code path unlinks or overwrites a media file.**
("Moving files to the Recycle Bin isn't available on Windows: files larger than the bin's
capacity would be deleted permanently."). Library-only removal works everywhere. Tests use `tests/fake_trash.py`; the
browser test server installs it, so no test touches a real trash. The native proof,
`tests/test_trash_macos_native.py`, runs only with `PM_NATIVE_TRASH=macos` in
portability.yml's `native-trash` job (`macos-latest` and `macos-15-intel`): the startup
`tests/test_trash_macos_native.py`, runs only with `PM_NATIVE_TRASH=macos` on a Mac, by hand
(CI dropped its `native-trash` macOS job in #571; it last passed on `macos-latest` and `macos-15-intel`): the startup
volume, a name clash, fresh APFS and FAT32 disk images, an image used as an output root,
a read-only image and an unwritable folder (design and probe:
`docs/egm/499-macos-trash/`, local).
@@ -4006,7 +4009,11 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
"Save preferences" is `aria-disabled` (opacity .55, `not-allowed`) while clean or
saving; a setting the last save could not save gets a red outline. CSS under D92.
- **D93** (Overview): real values. The metrics are the Library's eligible positives
and active saved negatives and the indexed `generated_images` rows (Q2). "Database
and active saved negatives and the Images count (#555, amending Q2's indexed
`generated_images` rows): the shell's Images sidebar count, the Images page's own
total, which also lists files no row links, as the concept's `images.length` is
both. A visit re-reads it unless a read is in flight; "–" until read or after a
failed read. `images.indexed` stays in the API and is not shown. "Database
integrity" is "Not checked" until a check ran this session, then "N issues" (checks
that found issues, even beside a check that could not run), "Passed" or "Incomplete".
The review row is "N ambiguous image links" (the Needs review queue's pending count,
@@ -4044,10 +4051,11 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
- **D96** (Statistics; amended by D153): "Usage statistics" without the sample note: the
concept's "Runs by category" table without its Avg. rating column (Category / Runs)
with the real runs of eligible parents per category ("Uncategorized"), then a second
table "Library totals" (Total / Count: Prompts, Tags, Images from the legacy
statistics; no Rated images). The API still reports `average_rating` and
table "Database records" (Total / Count: Prompt records, Tags on prompts, Image
records: the raw row counts of the legacy statistics, labelled as rows so they never
read as the Library or Images page counts, #555; no Rated images). The API still reports `average_rating` and
`rated_images`; the workspace does not show them.
The Overview row says "Runs by category, with library totals." because
The Overview row says "Runs by category, with database record counts." because
that is what the dialog shows (the concept's own dialog shows only "Runs by
category"); per-prompt run counts, prompt tags and dated runs are recorded, but no
table of them exists yet. A failed read toasts "Statistics could not
@@ -4350,6 +4358,23 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
choice. The host's checks still refuse a linked field or a changed workflow, and the
sender also refuses if the destination's `revision` (node object plus graph version)
moved since discovery; it is undefined on an older tab, which skips the comparison. A host error reads as a refusal, not "may have applied".
- **D191** (#542, beyond the concept; maintainer 2026-10-31, Q542-1 A to 5 A): a window with two or more
eligible nodes adds a last Destination node option "All N eligible nodes" (`ALL_NODES` in
`prompt-sender.mjs`), never selected for you (D173). Eligible is D173's filter; the nodes it leaves out
stay counted in the discovery sentence. One window only. Preview stays optional (D175): Preview changes
shows one `section.prompt-sender-node` per node, headed "#<id> <title>", in send order (node id,
numeric). Apply previews every node first and writes nothing if any refuses ("#<id> <title>: <reason>
Nothing was sent."). Then it sends one node after another. Only the first node of a pass taken in that
click keeps its ticket; every other node, and every node of a held Preview (host tickets expire after
30 s), takes a fresh ticket that is refused unless its Before texts and composition equal the reviewed
ones. That fresh preview checks the discovered revision until something has been posted, and not after,
since our own write may move the graph version. Refusals are best effort: "Sent to 2 of 3 nodes in
<window>. #12 <title>: <host's refusal>."; Apply stays available and a retry sends to every node again
(rewriting the same text). A lost reply stops the run, names that node as unknown and lists the nodes
not sent. Closing the dialog stops after the current node. The review sections are labelled by their
`h4` node heading. Unit tests in `tests/js/prompt-sender.test.js`, a fake three-node host in
`tests/browser/prompt-inspector.spec.mjs`, real Classic and Nodes 2.0 widgets in
`tests/comfyui/prompt-sender.spec.mjs`.
- **D106** (Integrations, S10, Q8): the send bridge always asks for the ComfyUI window
and node, and the nodes always follow ComfyUI's own renderer, so "Default send
destination" offers only "Ask each time" and "Host compatibility" only "Follow host
@@ -5413,7 +5438,7 @@ list stay unscoped: any row's control proves them.
| Worker threads | `admin.html:437–445` | S4 Sources | covered | `sources` · slider · "Worker threads" | worker range max is cpu_count (workspace-system-sources.spec.mjs) |
| LoraManager badge, enable, path, trigger words, CivitAI key, clear key | `admin.html:448–490`; `admin.js:1052–1156` | S10 Integrations | covered | `integrations` · switch · "LoraManager integration" | LoRA rows show detected / enabled / cache-loaded separately (workspace-system-integrations.spec.mjs) |
| LoRA data import with progress | `admin.html:492–498,517–541`; `admin.js:1157` | S10 Integrations | covered | `integrations` · button · "Review import" | Review import shows replacement, blocked items and network effects with the backup box; Start opens the job (D107) (workspace-system-integrations.spec.mjs) |
| Statistics | `admin.html:159`; `admin.js:1627` | S2 Overview | covered | `overview` · button · "Explore" | statistics dialog lists runs by category and library totals (workspace-system.spec.mjs) |
| Statistics | `admin.html:159`; `admin.js:1627` | S2 Overview | covered | `overview` · button · "Explore" | statistics dialog lists runs by category and database record counts (workspace-system.spec.mjs) |
| Export components JSON `/export` | `admin.html:163`; `admin.js:1607`; `py/api/prompts.py:238` | S9 Backup & export | covered | `backups` · button · "Export all prompts JSON" | Export JSON downloads /library/export; secondary buttons download all prompts (/export) and /pairs/export (D103) (workspace-system-backups.spec.mjs) |
| Saved pairs export | `py/api/pairs.py:84` | S9 Backup & export | covered | `backups` · button · "Export saved pairs JSON" | Export JSON downloads /library/export; secondary buttons download all prompts (/export) and /pairs/export (D103) (workspace-system-backups.spec.mjs) |
| Library export | `py/api/library.py:223` | S9 Backup & export | covered | `backups` · button · "Export JSON" | Export JSON downloads /library/export; secondary buttons download all prompts (/export) and /pairs/export (D103) (workspace-system-backups.spec.mjs) |
+6 -9
View File
@@ -435,12 +435,9 @@ except Exception as e:
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
# Print startup message with loaded tools
print()
print(f"\033[94m[ComfyUI-PromptManager] Version:\033[0m {get_version()}")
for node_key, display_name in NODE_DISPLAY_NAME_MAPPINGS.items():
# ASCII only: a Windows console using a legacy code page cannot print emoji,
# and a UnicodeEncodeError here would abort loading the whole node.
print(f" \033[94mLoaded:\033[0m {display_name}")
print(f"\033[94mTotal: {len(NODE_CLASS_MAPPINGS)} tools loaded\033[0m")
print()
# One startup line. ASCII only: a Windows console using a legacy code page
# cannot print emoji, and a UnicodeEncodeError here would abort loading the node.
print(
f"\033[94m[ComfyUI-PromptManager] Version:\033[0m {get_version()}"
f" - {len(NODE_CLASS_MAPPINGS)} tools loaded"
)
+2 -2
View File
@@ -1,10 +1,10 @@
[project]
name = "promptmanager"
description = "A powerful ComfyUI custom node that extends the standard text encoder with persistent prompt storage, advanced search capabilities, and an automatic image gallery system using SQLite."
version = "3.4.0"
version = "3.4.1"
license = {file = "LICENSE"}
requires-python = ">=3.12"
dependencies = ["watchdog>=2.1.0", "Pillow>=8.0.0"]
dependencies = ["watchdog>=2.1.0,<7", "Pillow>=8.0.0"]
[build-system]
requires = ["setuptools>=84.2.1"]
+1 -1
View File
@@ -1,5 +1,5 @@
# Runtime dependencies
watchdog>=6.0.0
watchdog>=6.0.0,<7
Pillow>=12.3.0
aiohttp>=3.14.5
+1 -1
View File
@@ -1,4 +1,4 @@
# Runtime dependencies for PromptManager.
# aiohttp, numpy and torch come from ComfyUI itself and are not listed here.
watchdog>=2.1.0 # Output directory monitoring
watchdog>=2.1.0,<7 # Output directory monitoring
Pillow>=8.0.0 # Image metadata extraction and thumbnails
+41
View File
@@ -320,3 +320,44 @@ test('copy toast and fallback dialog',async({page})=>{
await expect(fallback).toContainText('Automatic clipboard access requires a secure browser context.');
await page.keyboard.press('Escape');await expect(fallback).toBeHidden();await expect(copy).toBeFocused();
});
// #542 D191: "All N eligible nodes" previews every node, sends one after another and names each refusal.
test('All eligible nodes previews each node, sends in node id order and names a refusal',async({page,context},testInfo)=>{
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 targets=['12','3','7'].map(id=>({id,label:id==='7'?'Edited in ComfyUI':'Prompt Manager (+/-)',type:'PromptManagerPair',roles:['positive','negative']}));
window.hostValues=Object.fromEntries(targets.map(target=>[target.id,{positive:`Existing ${target.id}`,negative:'Existing negative'}]));window.applied=[];
window.fixtureHost=createBridgeHost({origin:location.origin,namespace:'/',label:'Test ComfyUI',adapter:{
listTargets:()=>targets,
preview:(id,roles)=>{const target=targets.find(item=>item.id===id);return{preview:{target,fields:Object.entries(roles).map(([role,after])=>({role,before:window.hostValues[id][role],after}))},snapshot:{id,roles}};},
apply:({id,roles})=>{if(id==='7')throw new Error('Workflow or prompt changed. Preview again.');Object.assign(window.hostValues[id],roles);window.applied.push(id);return{targetId:id,values:roles};},
}});
});
try {
await page.goto(workspace);await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:/^Send prompt pair to ComfyUI: /}).click();
const sender=dialog(page);const nodes=sender.getByRole('combobox',{name:'Destination node',exact:true});
await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'Test ComfyUI'});
await expect(nodes.locator('option')).toHaveText(['Choose a destination node','#12 Prompt Manager (+/-)','#3 Prompt Manager (+/-)','#7 Edited in ComfyUI','All 3 eligible nodes']);
await expect(nodes).toHaveValue('');
await nodes.selectOption({label:'All 3 eligible nodes'});
await expect(sender.getByRole('status')).toHaveText('Sending to all 3 eligible nodes in Test ComfyUI. Apply to send it, or preview the changes first.');
await page.screenshot({path:testInfo.outputPath('send-all-option.png')});
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
const reviews=sender.locator('section.prompt-sender-node');
await expect(reviews.locator('> h4')).toHaveText(['#3 Prompt Manager (+/-)','#7 Edited in ComfyUI','#12 Prompt Manager (+/-)']);
await expect(sender.getByRole('region',{name:'#12 Prompt Manager (+/-)',exact:true})).toContainText('Existing 12');
expect(await host.evaluate(()=>window.applied)).toEqual([]);
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();
await expect(sender.getByRole('alert')).toHaveText('Sent to 2 of 3 nodes in Test ComfyUI. #7 Edited in ComfyUI: Workflow or prompt changed. Preview again.');
expect(await host.evaluate(()=>window.applied)).toEqual(['3','12']);
expect(await host.evaluate(()=>window.hostValues['7'].positive)).toBe('Existing 7');
await page.screenshot({path:testInfo.outputPath('send-all-result.png')});
await page.setViewportSize({width:390,height:844});
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();await expect(reviews).toHaveCount(3);
await reviews.last().scrollIntoViewIfNeeded();
expect(await page.evaluate(()=>document.querySelector('dialog.prompt-sender').scrollWidth<=document.querySelector('dialog.prompt-sender').clientWidth+1)).toBe(true);
await page.screenshot({path:testInfo.outputPath('send-all-mobile.png')});
const apply=sender.getByRole('button',{name:'Apply to ComfyUI',exact:true});await apply.focus();await expect(apply).toBeFocused();
} finally {await host.evaluate(()=>window.fixtureHost.dispose());await host.close();}
});
+19 -11
View File
@@ -162,13 +162,15 @@ async function serverJson(request, url) {
}
const number = value => value.toLocaleString('en-US');
// The sidebar's Images count (state.mjs), which System's Images metric shows (#555).
const IMAGES_COUNT_URL = '/prompt_manager/images/output?mode=workspace&limit=1';
/** The concept's shell and Overview with the recorded deviations applied, given the server's real overview. */
function mapOverviewConcept(reference, overview) {
function mapOverviewConcept(reference, overview, images) {
// D165: the discreet legacy-pages line follows the settings layout.
reference = { ...reference, tree: [...reference.tree, 'p.legacy-pages.small'] };
const relabel = { 'Database integrity': 'Not checked', 'Positive prompts': number(overview.library.positive_prompts),
'Negative variants': number(overview.library.negative_variants), Images: number(overview.images.indexed) };
'Negative variants': number(overview.library.negative_variants), Images: number(images) };
const rows = {
// D93: the real pending count; Review metadata opens Needs review (the concept's go-review).
'3 ambiguous image links': row => ({ ...row,
@@ -179,8 +181,9 @@ function mapOverviewConcept(reference, overview) {
'One display configuration': row => ({ ...row, description: 'Grid and density settings are shared across views.' }),
// D93: the real sources, not samples.
'2 sample output sources': row => ({ ...row, title: `${number(overview.sources.effective)} output sources` }),
// D96: the dialog shows runs by category and library totals, not prompt, tag or date usage (D153: no ratings).
Statistics: row => ({ ...row, description: 'Runs by category, with library totals.' }),
// D96: the dialog shows runs by category and database record counts, not prompt, tag or date usage (D153: no
// ratings; #555: the counts are labelled as records, not as the Library or Images page counts).
Statistics: row => ({ ...row, description: 'Runs by category, with database record counts.' }),
};
const surfaces = reference.columns.surfaces.map(surface => ({ ...surface,
rows: surface.rows.map(row => (rows[row.title] ?? (value => value))(row)) }));
@@ -218,6 +221,8 @@ async function conceptOverview(page, theme) {
async function productionOverview(page, theme) {
await openProduction(page, 'system/overview', theme);
await expect(page.locator('#workspace-main .summary-metrics')).toBeVisible();
// #555: the Images metric is the shell's Images count: compare once it was read.
await expect(page.locator('#workspace-main .summary-metrics > .metric:nth-child(3) strong')).toHaveText(/^[\d,]+$/);
// The thumbnail row is its own read (I2): compare once it answered.
await expect(page.locator('#workspace-main .operation h3').filter({ hasText: 'thumbnails' })).toHaveText('No missing thumbnails');
// So is the backup row (S9).
@@ -231,6 +236,7 @@ for (const theme of THEMES) for (const [width, height] of SIZES) {
test(`system shell and overview match the concept ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => {
await page.setViewportSize({ width, height });
const overview = await serverJson(request, '/prompt_manager/system/overview');
const images = (await serverJson(request, IMAGES_COUNT_URL)).total;
// The fixture's thumbnails all exist, so the concept's "No missing thumbnails" is also the real title.
expect((await serverJson(request, '/prompt_manager/images/thumbnails/status')).pending).toBe(0);
const concept = await conceptOverview(page, theme);
@@ -239,7 +245,7 @@ for (const theme of THEMES) for (const [width, height] of SIZES) {
await page.screenshot({ path: testInfo.outputPath(`concept-system-${theme}-${width}.png`), fullPage: true });
const production = await productionOverview(page, theme);
await page.screenshot({ path: testInfo.outputPath(`production-system-${theme}-${width}.png`), fullPage: true });
expect(diffInventory(mapOverviewConcept(concept, overview), production)).toEqual([]);
expect(diffInventory(mapOverviewConcept(concept, overview, images), production)).toEqual([]);
expect(production.pageScroll).toBe(false);
});
}
@@ -265,8 +271,9 @@ const UNMAPPED_PATHS = [
test('system comparison detects mutations', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const overview = await serverJson(request, '/prompt_manager/system/overview');
const images = (await serverJson(request, IMAGES_COUNT_URL)).total;
const concept = await conceptOverview(page, 'dark');
const expected = mapOverviewConcept(concept, overview);
const expected = mapOverviewConcept(concept, overview, images);
const production = await productionOverview(page, 'dark');
expect(diffInventory(expected, production)).toEqual([]);
// Unmapped, the concept differs exactly where a deviation is recorded, and nowhere else.
@@ -290,7 +297,7 @@ test('system comparison detects mutations', async ({ page, request }) => {
});
/**
* D96: the concept's table, its rows the real ones; then "Library totals" in the same table markup. D153 (#447): the
* D96: the concept's table, its rows the real ones; then "Database records" (#555) in the same table markup. D153 (#447): the
* concept's "Avg. rating" column and a "Rated images" total are not drawn.
*/
function mapStatisticsConcept(reference, statistics) {
@@ -302,9 +309,10 @@ function mapStatisticsConcept(reference, statistics) {
tables: [
{ ...runs, head: runs.head.filter(([, , text]) => text !== 'Avg. rating'),
rows: statistics.by_category.map(row => [row.category ?? 'Uncategorized', number(row.runs)]) },
{ caption: 'Library totals', head: [['th', 'col', 'Total'], ['th', 'col', 'Count']],
rows: [['Prompts', number(statistics.totals.total_prompts)], ['Tags', number(statistics.totals.total_tags)],
['Images', number(statistics.totals.total_images)]] },
{ caption: 'Database records', head: [['th', 'col', 'Total'], ['th', 'col', 'Count']],
rows: [['Prompt records', number(statistics.totals.total_prompts)],
['Tags on prompts', number(statistics.totals.total_tags)],
['Image records', number(statistics.totals.total_images)]] },
],
};
}
@@ -420,7 +428,7 @@ for (const theme of THEMES) test(`statistics and health dialogs match the concep
expect(diffInventory(mapHealthConcept(concept.health), await page.evaluate(measureDialog)).map(item => item.path))
.toEqual(['operations.rows.0.title']);
await page.evaluate(() => { document.querySelector('#modal .operation h3').textContent = 'Integrity check'; });
// And a removed "Library totals" table and a changed caption in the statistics dialog.
// And a removed "Database records" table and a changed caption in the statistics dialog.
await page.keyboard.press('Escape');
await page.locator('#workspace-main .section-nav').getByRole('button', { name: 'Overview', exact: true }).click();
await page.locator('#workspace-main').getByRole('button', { name: 'Explore', exact: true }).click();
@@ -156,8 +156,9 @@ test('at 390px the window fits the screen, actions stay reachable and the page s
expect(frame.bottom-frame.top).toBeLessThan(844);
expect(frame.bottom-frame.top).toBeGreaterThanOrEqual(280);
expect(await page.evaluate(()=>document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
// The last row's actions can be scrolled to inside the window and fit its width.
const last=rows(page).last().getByRole('button',{name:'Rename / merge',exact:true});
// The first page's last row's actions can be scrolled to inside the window and fit its width.
// Pinned to row 100: reaching it loads the next page (D183), and last() would then follow row 200.
const last=rows(page).nth(99).getByRole('button',{name:'Rename / merge',exact:true});
await last.scrollIntoViewIfNeeded();await expect(last).toBeInViewport();
const [button]=await boxes(last);const [after]=await boxes(tagWindow(page));
expect(button.right).toBeLessThanOrEqual(after.right);
+31 -6
View File
@@ -8,6 +8,8 @@ import { readCoverageTable, parseControl, CONTROL_EXEMPTIONS } from '../../tools
const workspace = '/prompt_manager/workspace';
const OVERVIEW_URL = '/prompt_manager/system/overview';
const THUMBNAILS_URL = '/prompt_manager/images/thumbnails/status';
// The sidebar's Images count (state.mjs NAV_COUNT_SOURCES): the Images page's own total.
const IMAGES_COUNT_URL = '/prompt_manager/images/output?mode=workspace&limit=1';
const number = value => value.toLocaleString('en-US');
test.beforeEach(async ({ page, request, browserName }) => {
@@ -55,6 +57,7 @@ test('overview shows real counts and Not checked', async ({ page, request }) =>
page.on('request', item => { if (!['GET', 'HEAD', 'OPTIONS'].includes(item.method())) writes.push(item.url()); });
const overview = await serverJson(request, OVERVIEW_URL);
const thumbnails = await serverJson(request, THUMBNAILS_URL);
const images = (await serverJson(request, IMAGES_COUNT_URL)).total;
// The concept fixture: six positives, their saved negatives and 24 linked images, all with thumbnails.
expect([overview.library.positive_prompts, overview.images.indexed, thumbnails.pending]).toEqual([6, 24, 0]);
// I2: the Overview never walks the sources for thumbnails; the row reads the status route itself.
@@ -66,7 +69,8 @@ test('overview shows real counts and Not checked', async ({ page, request }) =>
[item.querySelector('strong').textContent, item.querySelector('span').textContent]))).toEqual([
[number(overview.library.positive_prompts), 'Positive prompts'],
[number(overview.library.negative_variants), 'Negative variants'],
[number(overview.images.indexed), 'Images'],
// #555: the Images page's own count, the same number as its sidebar badge.
[number(images), 'Images'],
['Not checked', 'Database integrity'],
]);
expect(await rowsOf(layout, 'Library health')).toEqual([
@@ -79,11 +83,31 @@ test('overview shows real counts and Not checked', async ({ page, request }) =>
expect(await rowsOf(layout, 'Workspace preferences')).toEqual([
['One display configuration', 'Grid and density settings are shared across views.', 'Appearance'],
['2 output sources', 'Monitoring and enrichment share one source list.', 'Manage sources'],
['Statistics', 'Runs by category, with library totals.', 'Explore'],
['Statistics', 'Runs by category, with database record counts.', 'Explore'],
]);
expect(writes).toEqual([]);
});
test('#555: the Images metric is the sidebar Images count, not the indexed image rows', async ({ page, request }) => {
const overview = await serverJson(request, OVERVIEW_URL);
// The Images page lists files no row links (the demo library: 122 files, 110 rows); the listing says so.
const listed = overview.images.indexed + 12;
await page.route(`**${IMAGES_COUNT_URL}`, async route => {
const response = await route.fetch();
await route.fulfill({ response, json: { ...(await response.json()), total: listed } });
});
const layout = await openOverview(page);
const imagesMetric = layout.locator('.summary-metrics > .metric').filter({ has: page.locator('span', { hasText: /^Images$/ }) });
await expect(imagesMetric.locator('strong')).toHaveText(number(listed));
await expect(page.locator('#primary-nav a[data-route="images"] .nav-count')).toHaveText(number(listed));
// A failed count reads as a dash, never the row count.
await page.unroute(`**${IMAGES_COUNT_URL}`);
await page.route(`**${IMAGES_COUNT_URL}`, route => route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } }));
await page.goto(`${workspace}#/system/appearance`);
await page.locator('#workspace-main .section-nav').getByRole('button', { name: 'Overview', exact: true }).click();
await expect(imagesMetric.locator('strong')).toHaveText('–');
});
// D129 (#249 S6b): where the library lives. The hook makes the fixture's database read as a node-folder library; its
// Move library is a stand-in that touches no file (workspace_system_fixture.py).
const banner = page => page.locator('#library-banner');
@@ -364,7 +388,7 @@ test('View backups / Appearance / Manage sources switch sections', async ({ page
}
});
test('statistics dialog lists runs by category and library totals', async ({ page, request }) => {
test('statistics dialog lists runs by category and database record counts', async ({ page, request }) => {
const statistics = await serverJson(request, '/prompt_manager/system/statistics');
expect(statistics.by_category.length).toBeGreaterThan(1);
const layout = await openOverview(page);
@@ -373,13 +397,14 @@ test('statistics dialog lists runs by category and library totals', async ({ pag
const dialog = page.locator('dialog#modal[open]');
await expect(dialog.locator('#modal-title')).toHaveText('Usage statistics');
const tables = dialog.locator('#modal-body > table');
await expect(tables.locator('caption')).toHaveText(['Runs by category', 'Library totals']);
await expect(tables.locator('caption')).toHaveText(['Runs by category', 'Database records']);
// D153 (#447): no rating column and no rated-images total.
await expect(tables.first().locator('thead th')).toHaveText(['Category', 'Runs']);
const cells = table => table.locator('tbody tr').evaluateAll(rows => rows.map(row => [...row.cells].map(cell => cell.textContent)));
expect(await cells(tables.first())).toEqual(statistics.by_category.map(row => [row.category ?? 'Uncategorized', number(row.runs)]));
expect(await cells(tables.nth(1))).toEqual([['Prompts', number(statistics.totals.total_prompts)],
['Tags', number(statistics.totals.total_tags)], ['Images', number(statistics.totals.total_images)]]);
// #555: raw rows of the legacy /stats, labelled as rows so they never read as the Library or Images page counts.
expect(await cells(tables.nth(1))).toEqual([['Prompt records', number(statistics.totals.total_prompts)],
['Tags on prompts', number(statistics.totals.total_tags)], ['Image records', number(statistics.totals.total_images)]]);
await page.keyboard.press('Escape');
await expect(explore).toBeFocused();
// A statistics read that fails says so; the Overview stays.
+4 -3
View File
@@ -145,9 +145,10 @@ test('long vocabularies append and Load more is keyboard reachable',async({page,
await expect(loadMore(page)).toHaveCount(0);
await expect(rows(page).nth(200).getByRole('button',{name:'Browse',exact:true})).toBeFocused();
await expect(rows(page).locator('td:nth-child(1)')).toHaveText(manifest.tags);
// Reload with the window at its top: Firefox restores a scrolled window, whose end then appends a page before the check.
await main(page).locator('.two-col .table-wrap').evaluate(node=>new Promise(resolve=>{node.scrollTop=0;requestAnimationFrame(()=>requestAnimationFrame(resolve));}));
await page.reload();
// Open the screen as a new history entry, not a reload: Firefox restores a reloaded page's scrolled tag window, and
// its end then appends a page before the check (#567). Scrolling the window to its top first does not always stick.
await page.goto('about:blank');
await page.goto(route);
await expect(rows(page)).toHaveCount(100);
// D183: the end of the list arrives inside the tag window.
const scroll=top=>main(page).locator('.two-col .table-wrap').evaluate((node,y)=>new Promise(resolve=>{node.scrollTop=y ?? node.scrollHeight;requestAnimationFrame(()=>requestAnimationFrame(resolve));}),top);
+31 -2
View File
@@ -131,11 +131,40 @@ test('native image inspector previews and sends a pair into real visible widgets
const twinId=await page.evaluate(async()=>{const {app}=await import('/scripts/app.js');const node=LiteGraph.createNode('PromptManagerPair');app.graph.add(node);return node.id;});
await find0.click();
const targets=sender.getByRole('combobox',{name:'Destination node',exact:true});
await expect(targets.locator('option')).toHaveCount(3);
await expect(targets.locator('option')).toHaveCount(4);
await expect(targets).toHaveValue('');
await expect(apply).toBeDisabled();
await expect(targets.locator('option').nth(2)).toHaveText(new RegExp(`^#${twinId} `));
await page.evaluate(async twinId=>{const {app}=await import('/scripts/app.js');app.graph.remove(app.graph.getNodeById(twinId));},twinId);
// #542 D191: "All 2 eligible nodes" writes both real nodes, one after another; a linked third node is not offered.
const linkedId=await page.evaluate(async()=>{
const {app}=await import('/scripts/app.js');const {convertToInput}=await import('/extensions/core/widgetInputs.js');
const node=LiteGraph.createNode('PromptManagerPair');app.graph.add(node);
const widget=node.widgets.find(widget=>widget.name==='text');
if(!node.inputs.some(input=>input.name==='text'))convertToInput(node,widget);
const source=LiteGraph.createNode('PrimitiveString');app.graph.add(source);
source.connect(0,node,node.inputs.findIndex(input=>input.name==='text'));
for(const each of app.graph._nodes.filter(item=>item.type==='PromptManagerPair')){const text=each.widgets.find(widget=>widget.name==='text');if(text)text.value=`Before all ${each.id}`;}
return node.id;
});
await find0.click();
await expect(targets.locator('option')).toHaveText(['Choose a destination node',new RegExp(`^#${id} `),new RegExp(`^#${twinId} `),'All 2 eligible nodes']);
await expect(sender.getByRole('status')).toContainText('1 other Prompt Manager node cannot receive this prompt.');
await targets.selectOption({label:'All 2 eligible nodes'});
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
await expect(sender.locator('section.prompt-sender-node > h4')).toHaveText([new RegExp(`^#${id} `),new RegExp(`^#${twinId} `)]);
await apply.click();
// The tab title gains ComfyUI's unsaved mark once the workflow changed, so the window is matched loosely.
await expect(sender.getByRole('status')).toHaveText(/^Sent to 2 of 2 nodes in .*ComfyUI\.$/);
await expect(apply).toBeDisabled();
const all=await page.evaluate(async ids=>{
const {app}=await import('/scripts/app.js');const output=(await app.graphToPrompt()).output;
return ids.map(nodeId=>{const node=app.graph.getNodeById(nodeId);const serialized=node.serialize();const index=node.widgets.findIndex(widget=>widget.name==='negative_text');
return{prompt:output[String(nodeId)]?.inputs.negative_text,serialized:serialized.widgets_values[index],text:node.widgets.find(widget=>widget.name==='text')?.value};});
},[id,twinId,linkedId]);
expect(all.slice(0,2)).toEqual([{prompt:negative,serialized:negative,text:positive},{prompt:negative,serialized:negative,text:positive}]);
expect(all[2].text).toBe(`Before all ${linkedId}`);
expect(await page.evaluate(()=>window.senderEffects.queue-window.senderSendBaseline.queue)).toBe(0);
await page.evaluate(async ids=>{const {app}=await import('/scripts/app.js');for(const nodeId of ids)app.graph.remove(app.graph.getNodeById(nodeId));},[twinId,linkedId]);
// #535: the status line says why a window cannot receive.
const find=sender.getByRole('button',{name:'Find ComfyUI windows',exact:true});
const preview=sender.getByRole('button',{name:'Preview changes',exact:true});
+138
View File
@@ -225,3 +225,141 @@ test('acknowledgement cannot claim an extra role beyond approved changes',async(
const {createSendSession}=await source;const s=setup(createSendSession,{apply:async()=>({targetId:'one',values:{positive:'new',negative:'also changed'}})});
await s.session.discover();s.session.selectHost('host');s.session.selectTarget('one');await s.session.preview();await s.session.apply();assert.equal(s.session.state.phase,'error');assert.match(s.session.state.message,/unexpected acknowledgement/);s.session.dispose();
});
// #542: "All N eligible nodes" sends to every eligible node of one window, previewing every node first (Q542-2 C).
function allHost({nodes=['10','9','2'],revision,preview,apply,discover}={}) {
const calls=[];const sent=new Set();
const targets=nodes.map(id=>pm(id,['positive'],revision?{revision}:{}));
const client={
discover:discover ?? (async()=>[window1(targets)]),
preview:async(host,id,roles)=>{calls.push(['preview',id]);if(preview)return preview(id,calls,sent);return{ticket:`t-${id}-${calls.length}`,revision,target:{id,label:`Node ${id}`},fields:[{role:'positive',before:sent.has(id)?'new':'old',after:roles.positive}]};},
apply:async(host,ticket)=>{const id=ticket.split('-')[1];calls.push(['apply',id,ticket]);if(apply)return apply(id,calls);sent.add(id);return{targetId:id,values:{positive:'new'}};},
dispose:()=>calls.push(['dispose']),
};
return {client,calls,sent};
}
async function allSession(createSendSession,options) {
const host=allHost(options);const session=createSendSession({client:host.client,roles:{positive:'new'},onChange:()=>{}});
await session.discover();return {...host,session};
}
const order=calls=>calls.filter(call=>call[0]!=='dispose').map(call=>`${call[0]} ${call[1]}`);
test('All is offered only with two or more eligible nodes and is never selected for you',async()=>{
const {createSendSession,ALL_NODES}=await source;
const one=await allSession(createSendSession,{nodes:['1']});
one.session.selectTarget(ALL_NODES);assert.equal(one.session.state.targetId,'');assert.equal(one.calls.length,0);one.session.dispose();
const two=await allSession(createSendSession,{nodes:['1','2']});
assert.equal(two.session.state.targetId,'');
two.session.selectTarget(ALL_NODES);assert.equal(two.session.state.targetId,ALL_NODES);
assert.match(two.session.state.message,/all 2 eligible nodes in Flow - ComfyUI/);
await two.session.discover();assert.equal(two.session.state.targetId,'');two.session.dispose();
});
test('Apply to All previews every node first, then sends one after another in node id order',async()=>{
const {createSendSession,ALL_NODES}=await source;const s=await allSession(createSendSession);
s.session.selectTarget(ALL_NODES);await s.session.apply();
assert.deepEqual(order(s.calls),['preview 2','preview 9','preview 10','apply 2','preview 9','apply 9','preview 10','apply 10']);
assert.equal(s.session.state.phase,'success');assert.equal(s.session.state.message,'Sent to 3 of 3 nodes in Flow - ComfyUI.');
await s.session.apply();assert.equal(s.calls.filter(call=>call[0]==='apply').length,3);s.session.dispose();
});
test('a double click on Apply to All sends to each node once',async()=>{
const {createSendSession,ALL_NODES}=await source;const s=await allSession(createSendSession,{nodes:['1','2']});
s.session.selectTarget(ALL_NODES);await Promise.all([s.session.apply(),s.session.apply()]);
assert.deepEqual(s.calls.filter(call=>call[0]==='apply').map(call=>call[1]),['1','2']);s.session.dispose();
});
test('a node refusing its preview stops All before anything is written and names the node',async()=>{
const {createSendSession,ALL_NODES}=await source;
const s=await allSession(createSendSession,{preview:id=>{if(id==='9')throw Error('positive is connected to an upstream input.');return{ticket:`t-${id}`,target:{id},fields:[{role:'positive',before:'old',after:'new'}]};}});
s.session.selectTarget(ALL_NODES);await s.session.apply();
assert.equal(s.calls.filter(call=>call[0]==='apply').length,0);assert.equal(s.session.state.phase,'error');
assert.match(s.session.state.message,/^#9 Node 9: positive is connected to an upstream input\. Nothing was sent\./);
assert.equal(s.session.state.outcomeUnknown,false);s.session.dispose();
});
test('a refusal while sending to All is reported by node and the other nodes are still sent',async()=>{
const {createSendSession,ALL_NODES}=await source;
const s=await allSession(createSendSession,{apply:id=>{if(id==='9')throw Error('Workflow or prompt changed. Preview again.');return{targetId:id,values:{positive:'new'}};}});
s.session.selectTarget(ALL_NODES);await s.session.apply();
assert.deepEqual(s.calls.filter(call=>call[0]==='apply').map(call=>call[1]),['2','9','10']);
assert.equal(s.session.state.phase,'error');assert.equal(s.session.state.outcomeUnknown,false);
assert.equal(s.session.state.message,'Sent to 2 of 3 nodes in Flow - ComfyUI. #9 Node 9: Workflow or prompt changed. Preview again.');s.session.dispose();
});
test('a lost reply on one node of All leaves the later nodes unsent and that outcome unknown',async()=>{
const {createSendSession,ALL_NODES}=await source;
const s=await allSession(createSendSession,{apply:id=>{if(id==='9')throw Error('Bridge request timed out');return{targetId:id,values:{positive:'new'}};}});
s.session.selectTarget(ALL_NODES);await s.session.apply();
assert.deepEqual(s.calls.filter(call=>call[0]==='apply').map(call=>call[1]),['2','9']);
assert.equal(s.session.state.outcomeUnknown,true);
assert.match(s.session.state.message,/^Sent to 1 of 3 nodes in Flow - ComfyUI\. No confirmation received from #9 Node 9; the update may have applied\. Not sent: #10 Node 10\. Check ComfyUI, then preview again\. Bridge request timed out$/);
await s.session.apply();assert.equal(s.calls.filter(call=>call[0]==='apply').length,2);s.session.dispose();
});
test('closing the dialog during All stops after the current node',async()=>{
const {createSendSession,ALL_NODES}=await source;let release;
const s=await allSession(createSendSession,{apply:id=>id==='2'?new Promise(done=>release=()=>done({targetId:id,values:{positive:'new'}})):{targetId:id,values:{positive:'new'}}});
s.session.selectTarget(ALL_NODES);const pending=s.session.apply();
for(let i=0;i<50 && !release;i++)await tick();assert.ok(release,'the first node was sent');
s.session.dispose();release();await pending;
assert.deepEqual(s.calls.filter(call=>call[0]==='apply').map(call=>call[1]),['2']);
});
test('All accepts the revision our own write moved, but not a workflow changed before the run',async()=>{
const {createSendSession,ALL_NODES}=await source;
// The host moves the graph version on every write: later nodes preview with the new revision.
let version=4;
const bumped=await allSession(createSendSession,{revision:'1:4',preview:id=>({ticket:`t-${id}`,revision:`1:${version}`,target:{id},fields:[{role:'positive',before:'old',after:'new'}]}),apply:id=>{version++;return{targetId:id,values:{positive:'new'}};}});
bumped.session.selectTarget(ALL_NODES);await bumped.session.apply();
assert.equal(bumped.session.state.message,'Sent to 3 of 3 nodes in Flow - ComfyUI.');bumped.session.dispose();
const moved=await allSession(createSendSession,{revision:'1:4',preview:id=>({ticket:`t-${id}`,revision:'1:5',target:{id},fields:[{role:'positive',before:'old',after:'new'}]})});
moved.session.selectTarget(ALL_NODES);await moved.session.apply();
assert.match(moved.session.state.message,/workflow changed since this window was found/i);assert.equal(moved.calls.filter(call=>call[0]==='apply').length,0);moved.session.dispose();
});
test('a node whose text changed after the preview pass is refused, not overwritten',async()=>{
const {createSendSession,ALL_NODES}=await source;let edited=false;
const s=await allSession(createSendSession,{preview:id=>({ticket:`t-${id}`,target:{id},fields:[{role:'positive',before:id==='10'&&edited?'typed in ComfyUI':'old',after:'new'}]}),apply:id=>{edited=true;return{targetId:id,values:{positive:'new'}};}});
s.session.selectTarget(ALL_NODES);await s.session.apply();
assert.deepEqual(s.calls.filter(call=>call[0]==='apply').map(call=>call[1]),['2','9']);
assert.match(s.session.state.message,/^Sent to 2 of 3 nodes in Flow - ComfyUI\. #10 Node 10: Its prompt changed since the preview\./);s.session.dispose();
});
test('Preview with All shows every node in order, and Apply sends fresh tickets that match the review',async()=>{
const {createSendSession,ALL_NODES}=await source;const s=await allSession(createSendSession);
s.session.selectTarget(ALL_NODES);await s.session.preview();
assert.equal(s.session.state.phase,'ready');assert.equal(s.session.state.preview,null);
assert.deepEqual(s.session.state.previews.map(item=>item.label),['#2 Node 2','#9 Node 9','#10 Node 10']);
assert.match(s.session.state.message,/Sending to all 3 eligible nodes in Flow - ComfyUI\. Review the complete changes, then apply\./);
const first=s.session.state.previews[0].preview.ticket;
await s.session.apply();
// A held review may have outlived the host's 30 s tickets, so even the first node takes a fresh one.
assert.deepEqual(order(s.calls),['preview 2','preview 9','preview 10','preview 2','apply 2','preview 9','apply 9','preview 10','apply 10']);
assert.notEqual(s.calls.find(call=>call[0]==='apply')[2],first);assert.equal(s.session.state.phase,'success');
s.session.selectTarget('2');assert.deepEqual(s.session.state.previews,[]);s.session.dispose();
});
test('the dialog offers All for two or more nodes and reviews each node under its name',async()=>{
const {createPromptSender,ALL_NODES}=await source;const {createScope}=await import('../../web/js/shared/toolkit/core.js');
const dom=fakeDOM();const scope=createScope();
const {client}=allHost({nodes:['1','2']});
try {
createPromptSender({scope,clientFactory:()=>client}).open({positive:'new'});await tick();
const dialog=dom.body.children.at(-1);const target=dom.find(dialog,node=>node.id==='prompt-sender-target');
assert.deepEqual(target.children.map(option=>[option.value,option.textContent]),[['','Choose a destination node'],['1','#1 Node 1'],['2','#2 Node 2'],[ALL_NODES,'All 2 eligible nodes']]);
assert.equal(target.value,'');target.value=ALL_NODES;target.dispatchEvent(new Event('change'));
dom.find(dialog,node=>node.textContent==='Preview changes').dispatchEvent(new Event('click'));for(let i=0;i<5;i++)await tick();
const sections=[];(function walk(node){if(node.className==='prompt-sender-node')sections.push(node);node.children.forEach(walk);})(dialog);
assert.deepEqual(sections.map(section=>section.children[0].textContent),['#1 Node 1','#2 Node 2']);
dom.find(dialog,node=>node.textContent==='Apply to ComfyUI').dispatchEvent(new Event('click'));for(let i=0;i<10;i++)await tick();
assert.ok(dom.find(dialog,node=>node.textContent==='Sent to 2 of 2 nodes in Flow - ComfyUI.'));
} finally {scope.dispose();dom.restore();}
});
test('an expired review still sends every node, and a held review checks the discovered revision first',async()=>{
const {createSendSession,ALL_NODES}=await source;let reviewing=true;
// The host refuses every ticket taken during the review, as after its 30 s expiry.
const s=await allSession(createSendSession,{preview:(id,calls)=>({ticket:`t-${id}-${reviewing?'old':calls.length}`,target:{id},fields:[{role:'positive',before:'old',after:'new'}]}),apply:id=>({targetId:id,values:{positive:'new'}})});
const realApply=s.client.apply;s.client.apply=async(host,ticket,options)=>{if(ticket.endsWith('-old'))throw Error('Preview expired or unavailable; preview again');return realApply(host,ticket,options);};
s.session.selectTarget(ALL_NODES);await s.session.preview();reviewing=false;await s.session.apply();
assert.equal(s.session.state.message,'Sent to 3 of 3 nodes in Flow - ComfyUI.');s.session.dispose();
const moved=await allSession(createSendSession,{revision:'1:4',preview:(id,calls)=>({ticket:`t-${id}`,revision:calls.length>3?'1:5':'1:4',target:{id},fields:[{role:'positive',before:'old',after:'new'}]})});
moved.session.selectTarget(ALL_NODES);await moved.session.preview();assert.equal(moved.session.state.phase,'ready');await moved.session.apply();
assert.match(moved.session.state.message,/^Sent to 0 of 3 nodes in Flow - ComfyUI\. #2 Node 2: The workflow changed since this window was found/);
assert.equal(moved.calls.filter(call=>call[0]==='apply').length,0);moved.session.dispose();
});
test('a changed prepend or append after the review refuses that node',async()=>{
const {createSendSession,ALL_NODES}=await source;let edited=false;
const s=await allSession(createSendSession,{preview:id=>({ticket:`t-${id}`,target:{id},fields:[{role:'positive',before:'old',after:'new'}],composition:{prepend:id==='9'&&edited?'typed':'trigger',append:'',before:'trigger old',after:'trigger new'}}),apply:id=>{edited=true;return{targetId:id,values:{positive:'new'}};}});
s.session.selectTarget(ALL_NODES);await s.session.apply();
assert.deepEqual(s.calls.filter(call=>call[0]==='apply').map(call=>call[1]),['2','10']);
assert.match(s.session.state.message,/^Sent to 2 of 3 nodes in Flow - ComfyUI\. #9 Node 9: Its prompt changed since the preview\./);s.session.dispose();
});
+12 -2
View File
@@ -16,12 +16,22 @@ const OVERVIEW = Object.freeze({
test('metrics are the server counts and Not checked before any health check', async () => {
const { metricValues } = await overviewModule;
assert.deepEqual(metricValues(OVERVIEW), [
assert.deepEqual(metricValues(OVERVIEW, 24), [
[(1234).toLocaleString(), 'Positive prompts'], ['7', 'Negative variants'], ['24', 'Images'],
['Not checked', 'Database integrity'],
]);
});
test('#555: the Images metric is the Images page count (the sidebar badge), not the indexed rows', async () => {
const { metricValues } = await overviewModule;
// The demo library: 110 indexed rows, 122 files on the Images page (unlinked ones included).
const overview = { ...OVERVIEW, images: { indexed: 110, rated: 0 } };
assert.deepEqual(metricValues(overview, 122)[2], [(122).toLocaleString(), 'Images']);
// Not read yet (undefined) or failed (null): a dash, as on the workspace Overview, never the row count.
assert.deepEqual(metricValues(overview, undefined)[2], ['–', 'Images']);
assert.deepEqual(metricValues(overview, null)[2], ['–', 'Images']);
});
test('metric 4 is the last health result', async () => {
const { integrityText } = await overviewModule;
assert.equal(integrityText(null), 'Not checked');
@@ -150,7 +160,7 @@ test('statistics rows are the real runs, then the library totals, without rating
rated_images: 20,
};
assert.deepEqual(categoryRows(statistics), [['Landscapes', (1200).toLocaleString()], ['Uncategorized', '3']]);
assert.deepEqual(totalsRows(statistics), [['Prompts', '12'], ['Tags', '15'], ['Images', '24']]);
assert.deepEqual(totalsRows(statistics), [['Prompt records', '12'], ['Tags on prompts', '15'], ['Image records', '24']]);
});
/** A fake api answering each `get`/`post` from a queue per URL, recording the calls. */
+11 -2
View File
@@ -390,7 +390,16 @@ class TestConsoleBanner(unittest.TestCase):
stream.flush()
banner = stream.buffer.getvalue().decode("cp1252")
self.assertIn("Loaded:", banner)
lines = [
line
for line in banner.splitlines()
if "PromptManager" in line or "loaded" in line
]
self.assertEqual(len(lines), 1, banner)
self.assertRegex(
lines[0], r"\[ComfyUI-PromptManager\] Version:.* \S+ - \d+ tools loaded$"
)
self.assertNotIn("Loaded:", banner)
class PriorFileHandler(logging.FileHandler):
@@ -553,7 +562,7 @@ class TestPackageLoggerIsolation(unittest.TestCase):
original_print = print
def fail_banner(*args, **kwargs):
if any("Total:" in str(arg) for arg in args):
if any("tools loaded" in str(arg) for arg in args):
captured["package"] = sys.modules[name]
captured["handlers"] = [
h
+9 -22
View File
@@ -129,11 +129,11 @@ test('Firefox/WebKit page workflows run only outside pull requests; coverage run
}
});
test('coverage runs as three shards that each upload their raw coverage', () => {
test('coverage runs as four 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/);
assert.deepEqual(matrixList(coverage, 'shard'), ['1', '2', '3', '4']);
assert.match(coverage, /^ {4}name: frontend coverage \(shard \$\{\{ matrix\.shard \}\}\/4\)$/m);
assert.match(coverage, /npm run --prefix tools\/vendor test:coverage:shard -- \$\{\{ matrix\.shard \}\}\/4/);
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);
@@ -331,7 +331,7 @@ const testJobs = () => jobs(readWorkflow('test.yml'));
test('the Python matrix is Linux 3.12 on a pull request and Linux 3.12-3.14 on every other event', () => {
const body = testJobs().get('test');
assert.deepEqual(matrixList(body, 'os'), ['ubuntu-latest'], 'Windows and macOS never gate a merge');
assert.deepEqual(matrixList(body, 'os'), ['ubuntu-latest'], 'Windows never gates a merge');
const match = /^ {8}python-version: \$\{\{ github\.event_name == 'pull_request' && fromJSON\('(\[[^\]]*\])'\) \|\| fromJSON\('(\[[^\]]*\])'\) \}\}$/m.exec(body);
assert.ok(match, 'python-version is chosen by event');
assert.deepEqual({ pr: JSON.parse(match[1]), other: JSON.parse(match[2]) }, { pr: ['3.12'], other: ['3.12', '3.13', '3.14'] });
@@ -339,34 +339,21 @@ test('the Python matrix is Linux 3.12 on a pull request and Linux 3.12-3.14 on e
assert.match(body, /^ {4}name: py\$\{\{ matrix\.python-version \}\} on \$\{\{ matrix\.os \}\}$/m);
});
test('Windows and macOS run 3.12-3.14 on main, nightly and on demand, never on a PR or in the queue', () => {
test('Windows runs 3.12-3.14 on main, nightly and on demand, never on a PR or in the queue', () => {
const text = readWorkflow('portability.yml');
const triggers = text.slice(text.indexOf('\non:'), text.indexOf('\nconcurrency:'));
for (const event of ['push', 'schedule', 'workflow_dispatch']) assert.match(triggers, new RegExp(`^ {2}${event}:`, 'm'));
for (const event of ['pull_request', 'merge_group']) assert.doesNotMatch(triggers, new RegExp(`^ {2}${event}`, 'm'));
assert.match(triggers, /^ {4}branches: \[main\]$/m, 'push runs only for main');
const body = jobs(text).get('test');
assert.deepEqual(matrixList(body, 'os'), ['windows-latest', 'macos-latest']);
assert.deepEqual(matrixList(body, 'os'), ['windows-latest'], 'macOS is not tested in CI (#571)');
assert.doesNotMatch(text, /macos-/, 'no macOS runner is used');
assert.deepEqual(matrixList(body, 'python-version'), ['"3.12"', '"3.13"', '"3.14"']);
assert.match(body, /^ {6}fail-fast: false$/m);
assert.match(body, /^ {8}run: python -m tests\.run_suite -v$/m);
assert.doesNotMatch(text, /cancel-in-progress: true|cancel-in-progress: \$/, 'no portability run is cancelled');
});
// #499: the native trash proof runs on both macOS CPUs, apart from the suite so a red suite never skips it.
test('portability runs the native trash proof on arm64 and Intel macOS', () => {
const body = jobs(readWorkflow('portability.yml')).get('native-trash');
assert.ok(body, 'portability.yml has a native-trash job');
assert.deepEqual(matrixList(body, 'os'), ['macos-latest', 'macos-15-intel']);
assert.match(body, /^ {6}fail-fast: false$/m);
assert.doesNotMatch(body, /^ {4}needs:/m, 'it does not wait for the suite job');
assert.match(body, /^ {10}PM_NATIVE_TRASH: macos$/m);
assert.match(body, /^ {8}run: python -m unittest tests\.test_trash_macos_native tests\.test_trash_macos -v$/m);
const jobMinutes = Number(/^ {4}timeout-minutes: (\d+)$/m.exec(body)[1]);
const stepMinutes = Number(/^ {8}timeout-minutes: (\d+)$/m.exec(body)?.[1]);
assert.ok(stepMinutes > 0 && jobMinutes - stepMinutes >= 2, 'the proof step stops before the job limit');
});
// #493: the portability limit follows the rule in its comment: ceil(1.5 x the slowest of three
// green main runs) + 3 minutes, never under 5, with those runs and times written above jobs:.
// The times are whole-job wall times. That the runs were green and on main is the author's word.
@@ -427,5 +414,5 @@ test('every required check reports on a pull request and the gates carry the ful
].sort());
assert.equal(contexts.filter(context => /^py/.test(context)).length, 1,
'a dropped matrix leg never reports on a pull request, so it cannot be required');
assert.ok(!contexts.some(context => /windows|macos/.test(context)), 'Windows and macOS never gate a merge');
assert.ok(!contexts.some(context => /windows|macos/.test(context)), 'Windows never gates a merge');
});
+7 -1
View File
@@ -176,6 +176,12 @@ function mountWorkspace() {
function notifyCounts() { for (const listener of countListeners) listener(); }
/** Re-read the given counts unless a read is already in flight. */
function freshenCounts(ids) { refreshCounts(ids.filter(id => !countRequests.has(id))); }
/** #555: the counts as System › Overview reads them, so its Images metric and the Images badge cannot differ. */
const sharedCounts = Object.freeze({
get: id => knownCounts.get(id),
subscribe(listener) { countListeners.add(listener); return () => countListeners.delete(listener); },
freshen: freshenCounts,
});
/** A count a page has just read itself: it replaces the badge, and an older read in flight no longer lands. */
function setCount(id, count) {
countRequests.get(id)?.abort();
@@ -488,7 +494,7 @@ function mountWorkspace() {
if (route.id === 'system') {
// The concept's System (app.js renderSystem): its heading description, section nav and Save row.
description.textContent = route.description;
system = mountSystem(main, api, view, { section: route.section, activity });
system = mountSystem(main, api, view, { section: route.section, activity, counts: sharedCounts });
return;
}
if (route.id === 'inspect') {
+150 -38
View File
@@ -11,6 +11,13 @@ const linkedTo = (target,roles) => Object.keys(roles).filter(role=>Array.isArray
const nodeLabel = target => `#${target.id} ${target.label}`;
// A lost reply leaves the outcome unknown; anything else the host said is a refusal, and nothing was written.
const LOST_REPLY = /timed out|aborted|disposed/i;
const outcomeUnknown = (posted,error) => posted && (LOST_REPLY.test(error.message) || /unexpected acknowledgement/.test(error.message));
const UNKNOWN_ADVICE = 'Check ComfyUI, then preview again.';
/** The Destination node value that sends to every eligible node of the window (#542, D191). Node ids never take it. */
export const ALL_NODES = '*';
// "All" sends in node id order: 2 before 10, and a subgraph id "3:1" after "3".
const byNodeId = (a,b) => a.id.localeCompare(b.id,'en',{numeric:true});
const allMessage = (count,where) => `Sending to all ${count} eligible nodes in ${where}.`;
/** Why nothing in the discovered windows can receive these roles, most specific first (D173). */
function unavailableMessage(found,roles) {
@@ -40,23 +47,90 @@ function distinctLabels(items,suffix) {
export function createSendSession({client, roles:rawRoles, onChange}) {
const roles = Object.freeze(validateRoles(rawRoles));
let state = {phase:'idle',hosts:[],hostId:'',targetId:'',preview:null,outcomeUnknown:false,message:''};
// `previews` holds the reviewed nodes of "All", in send order; `preview` the one node otherwise.
let state = {phase:'idle',hosts:[],hostId:'',targetId:'',preview:null,previews:[],outcomeUnknown:false,message:''};
let operation, generation = 0, disposed = false;
const emit = patch => { if (!disposed) { state = Object.freeze({...state,...patch}); onChange(state); } };
function invalidate() { generation++; operation?.abort(); operation = null; }
function begin(phase) { invalidate(); operation = new AbortController(); emit({phase,preview:null,outcomeUnknown:false,message:''}); return {signal:operation.signal,version:generation}; }
function begin(phase) { invalidate(); operation = new AbortController(); emit({phase,preview:null,previews:[],outcomeUnknown:false,message:''}); return {signal:operation.signal,version:generation}; }
const current = request => !disposed && generation === request.version;
const selected = () => state.hosts.find(host=>host.hostId===state.hostId)?.targets.find(target=>target.id===state.targetId);
async function fetchPreview(request) {
const preview=await client.preview(state.hostId,state.targetId,roles,{signal:request.signal});
const chosenHost = () => state.hosts.find(host=>host.hostId===state.hostId);
/** The nodes the current choice sends to: one, every eligible node of the window for "All", or none. */
function chosen() {
const host=chosenHost();
if (!host) return [];
if (state.targetId===ALL_NODES) return host.targets.length>1 ? host.targets.toSorted(byNodeId) : [];
const target=host.targets.find(item=>item.id===state.targetId);
return target ? [target] : [];
}
// checkRevision is off only for a later node of "All", whose revision our own earlier write may have moved.
async function fetchPreview(request,hostId,target,checkRevision=true) {
const preview=await client.preview(hostId,target.id,roles,{signal:request.signal});
if (!current(request)) return null;
const fields=preview?.fields;
if(typeof preview?.ticket!=='string' || !preview.ticket || preview.target?.id!==state.targetId || !Array.isArray(fields) || fields.length!==Object.keys(roles).length || new Set(fields.map(field=>field?.role)).size!==fields.length || Array.from(fields).some(field=>!field || !Object.hasOwn(roles,field.role) || typeof field.before!=='string' || field.after!==roles[field.role])) throw Error('ComfyUI returned an unexpected preview. Find the destination and preview again.');
if(typeof preview?.ticket!=='string' || !preview.ticket || preview.target?.id!==target.id || !Array.isArray(fields) || fields.length!==Object.keys(roles).length || new Set(fields.map(field=>field?.role)).size!==fields.length || Array.from(fields).some(field=>!field || !Object.hasOwn(roles,field.role) || typeof field.before!=='string' || field.after!==roles[field.role])) throw Error('ComfyUI returned an unexpected preview. Find the destination and preview again.');
// Undefined on a tab still running older code, which has nothing to compare.
const found=selected()?.revision;
if(found!==undefined && preview.revision!==found) throw Error('The workflow changed since this window was found. Nothing was sent. Find windows again.');
const found=target.revision;
if(checkRevision && found!==undefined && preview.revision!==found) throw Error('The workflow changed since this window was found. Nothing was sent. Find windows again.');
return preview;
}
function checkAcknowledgement(acknowledgement,targetId) {
if(acknowledgement?.targetId!==targetId || !acknowledgement.values || Object.keys(acknowledgement.values).length!==Object.keys(roles).length || Object.keys(roles).some(role=>!Object.hasOwn(acknowledgement.values,role) || acknowledgement.values[role]!==roles[role])) throw Error('ComfyUI returned an unexpected acknowledgement.');
}
const reviewedText = change => JSON.stringify([change.fields.map(field=>[field.role,field.before]).sort(),change.composition ?? null]);
const sameChanges = (reviewed,fresh) => reviewedText(reviewed)===reviewedText(fresh);
/** Q542-2 C: every node of "All" previews before anything is written; the first refusal names its node. */
async function previewEach(request,hostId,targets) {
const reviewed=[];
for (const target of targets) {
try {
const preview=await fetchPreview(request,hostId,target);
if (!preview) return null;
reviewed.push({target,label:target.label,preview});
} catch(error) {
if (!current(request)) return null;
throw Error(`${target.label}: ${error.message}${/Nothing was sent/.test(error.message) ? '' : ' Nothing was sent.'}`);
}
}
return reviewed;
}
/**
* Sends the reviewed nodes one after another, best effort past refusals. Only the first node of a pass taken in this
* click keeps its ticket: a held review may have expired at the host (30 s), and our own writes may make later
* tickets stale, so every other node takes a fresh ticket that must show exactly the reviewed text.
*/
async function applyEach(request,hostId,reviewed,where,held) {
const sent=[],refused=[];let written=false;
for (const [index,{target,preview}] of reviewed.entries()) {
let posted=false;
try {
let ticket=preview.ticket;
if (index>0 || held) {
// Until something was posted the revision is still the discovered one; after that our own write may move it.
const fresh=await fetchPreview(request,hostId,target,!written);
if (!fresh) return;
if (!sameChanges(preview,fresh)) throw Error('Its prompt changed since the preview. Preview again.');
ticket=fresh.ticket;
}
posted=written=true;
const acknowledgement=await client.apply(hostId,ticket,{signal:request.signal});
if (!current(request)) return;
checkAcknowledgement(acknowledgement,target.id);
sent.push(target);
} catch(error) {
if (!current(request)) return;
const summary=[`Sent to ${sent.length} of ${reviewed.length} nodes in ${where}.`,...refused];
if (outcomeUnknown(posted,error)) {
const unsent=reviewed.slice(index+1).map(item=>item.label);
emit({phase:'error',outcomeUnknown:true,message:[...summary,`No confirmation received from ${target.label}; the update may have applied.`,...(unsent.length ? [`Not sent: ${unsent.join(', ')}.`] : []),UNKNOWN_ADVICE,error.message].join(' ')});
return;
}
refused.push(`${target.label}: ${error.message}`);
}
}
const message=[`Sent to ${sent.length} of ${reviewed.length} nodes in ${where}.`,...refused].join(' ');
emit(refused.length ? {phase:'error',message} : {phase:'success',message});
}
function hostChoice(host) {
if (host.targets.length>1) return {hostId:host.hostId,targetId:'',message:`Choose a destination node in ${host.label}.`};
const [target] = host.targets;
@@ -79,38 +153,61 @@ export function createSendSession({client, roles:rawRoles, onChange}) {
else { const choice=hostChoice(hosts[0]); emit({phase:'select',hosts,...choice,message:choice.message+note}); }
} catch (error) { if (current(request)) emit({phase:'error',message:error.message}); }
},
selectHost(hostId) { if (disposed) return; invalidate(); const host=state.hosts.find(host=>host.hostId===hostId); emit({phase:'select',preview:null,outcomeUnknown:false,...(host ? hostChoice(host) : {hostId:'',targetId:'',message:'Choose a ComfyUI window.'})}); },
selectTarget(targetId) { if (disposed) return; invalidate(); const host=state.hosts.find(host=>host.hostId===state.hostId); const chosen=host?.targets.some(target=>target.id===targetId); emit({phase:'select',outcomeUnknown:false,targetId:chosen?targetId:'',preview:null,message:chosen?'Apply to send it, or preview the changes first.':'Choose a destination node, then preview changes.'}); },
selectHost(hostId) { if (disposed) return; invalidate(); const host=state.hosts.find(host=>host.hostId===hostId); emit({phase:'select',preview:null,previews:[],outcomeUnknown:false,...(host ? hostChoice(host) : {hostId:'',targetId:'',message:'Choose a ComfyUI window.'})}); },
selectTarget(targetId) {
if (disposed) return; invalidate(); const host=chosenHost();
// D173: "All" is a choice only where there are two or more nodes, and it is never made for you.
const all=targetId===ALL_NODES && host?.targets.length>1;
const chosenOne=all || Boolean(host?.targets.some(target=>target.id===targetId));
const message=all ? `${allMessage(host.targets.length,host.label)} Apply to send it, or preview the changes first.` : chosenOne ? 'Apply to send it, or preview the changes first.' : 'Choose a destination node, then preview changes.';
emit({phase:'select',outcomeUnknown:false,targetId:chosenOne?targetId:'',preview:null,previews:[],message});
},
async preview() {
if (disposed || !selected() || ['previewing','applying'].includes(state.phase)) return;
const targets=chosen();
if (disposed || !targets.length || ['previewing','applying'].includes(state.phase)) return;
const all=state.targetId===ALL_NODES; const hostId=state.hostId; const windowLabel=chosenHost()?.label;
const request=begin('previewing');
try {
const preview=await fetchPreview(request);
if (all) {
const previews=await previewEach(request,hostId,targets);
if (previews) emit({phase:'ready',previews,message:`${allMessage(targets.length,windowLabel)} Review the complete changes, then apply.`});
return;
}
const preview=await fetchPreview(request,hostId,targets[0]);
if (!preview) return;
const windowLabel=state.hosts.find(host=>host.hostId===state.hostId)?.label;
emit({phase:'ready',preview,message:`Sending to ${nodeLabel({id:preview.target.id,label:preview.target.label ?? ''})} in ${windowLabel}. Review the complete changes, then apply.`});
} catch(error) { if(current(request)) emit({phase:'error',preview:null,message:error.message}); }
} catch(error) { if(current(request)) emit({phase:'error',preview:null,previews:[],message:error.message}); }
},
async apply() {
// Success stays closed: the second click of a double click must not send again.
if(disposed || !selected() || !['select','ready','error'].includes(state.phase) || state.outcomeUnknown) return;
const held=state.phase==='ready' ? state.preview : null;
const targetId=state.targetId; const hostId=state.hostId;
const targets=chosen();
if(disposed || !targets.length || !['select','ready','error'].includes(state.phase) || state.outcomeUnknown) return;
const all=state.targetId===ALL_NODES;
const held=state.phase==='ready' ? (all ? state.previews : state.preview) : null;
const hostId=state.hostId; const windowLabel=chosenHost()?.label;
const request=begin('applying');
emit({message:'Waiting for ComfyUI confirmation. Closing this dialog does not undo a sent update.'});
if (all) {
let reviewed;
try { reviewed=held?.length ? held : await previewEach(request,hostId,targets); }
catch(error) { if(current(request)) emit({phase:'error',message:error.message}); return; }
if (reviewed) await applyEach(request,hostId,reviewed,windowLabel,Boolean(held?.length));
return;
}
const [target]=targets;
let posted=false;
try {
const preview=held ?? await fetchPreview(request);
const preview=held ?? await fetchPreview(request,hostId,target);
if(!preview || !current(request)) return;
posted=true;
const acknowledgement=await client.apply(hostId,preview.ticket,{signal:request.signal});
if(!current(request)) return;
if(acknowledgement?.targetId!==targetId || !acknowledgement.values || Object.keys(acknowledgement.values).length!==Object.keys(roles).length || Object.keys(roles).some(role=>!Object.hasOwn(acknowledgement.values,role) || acknowledgement.values[role]!==roles[role])) throw Error('ComfyUI returned an unexpected acknowledgement.');
checkAcknowledgement(acknowledgement,target.id);
emit({phase:'success',message:'Sent to ComfyUI.'});
} catch(error) {
if(!current(request)) return;
const unknown=posted && (LOST_REPLY.test(error.message) || /unexpected acknowledgement/.test(error.message));
emit({phase:'error',preview:null,outcomeUnknown:unknown,message:unknown ? `No confirmation received. The update may have applied. Check ComfyUI, then preview again. ${error.message}` : error.message});
const unknown=outcomeUnknown(posted,error);
emit({phase:'error',preview:null,outcomeUnknown:unknown,message:unknown ? `No confirmation received. The update may have applied. ${UNKNOWN_ADVICE} ${error.message}` : error.message});
}
},
dispose() { if(disposed) return; disposed=true; invalidate(); client.dispose(); },
@@ -159,7 +256,7 @@ export function createPromptSender({scope,baseUrl=window.ApiClient.resolveUrl('/
inspect.disabled=true;apply.disabled=true;
const actions=element('footer',undefined,'prompt-sender-actions');actions.append(find,inspect,apply);
dialog.append(header,description,controls,status,preview,actions);document.body.append(dialog);
let session,closed=false;
let session,closed=false,headingIds=0;
function dispose() {
if(closed) return;closed=true;local.dispose();session?.dispose();
if(dialog.open) dialog.close();dialog.remove();
@@ -176,8 +273,8 @@ export function createPromptSender({scope,baseUrl=window.ApiClient.resolveUrl('/
function options(select,placeholder,entries,value) {
select.replaceChildren(...[["",placeholder],...entries].map(([value,label])=>{const option=element('option',label);option.value=value;return option;}));select.value=value;
}
function textBlock(parent,label,value) {
parent.append(element('h4',label));const text=element('pre');text.tabIndex=0;text.append(element('code',value === '' ? '(empty)' : value));parent.append(text);
function textBlock(parent,label,value,level=4) {
parent.append(element(`h${Math.min(level,6)}`,label));const text=element('pre');text.tabIndex=0;text.append(element('code',value === '' ? '(empty)' : value));parent.append(text);
}
function render(state) {
if(closed) return;
@@ -187,28 +284,43 @@ export function createPromptSender({scope,baseUrl=window.ApiClient.resolveUrl('/
dialog.setAttribute('aria-busy',String(busy));
options(host,'Choose a ComfyUI window',state.hosts.map(item=>[item.hostId,item.label]),state.hostId);
const selected=state.hosts.find(item=>item.hostId===state.hostId);
options(target,'Choose a destination node',(selected?.targets || []).map(item=>[item.id,item.label]),state.targetId);
const nodes=(selected?.targets || []).map(item=>[item.id,item.label]);
// Q542-4 A: the last option sends to every eligible node of this window.
if(nodes.length>1) nodes.push([ALL_NODES,`All ${nodes.length} eligible nodes`]);
options(target,'Choose a destination node',nodes,state.targetId);
host.disabled=busy;target.disabled=busy || !selected;
find.disabled=busy;inspect.disabled=busy || !state.targetId;apply.disabled=busy || !state.targetId || state.phase==='success' || state.outcomeUnknown;
preview.replaceChildren();
if(state.preview) {
preview.append(element('h3','Review changes'));
for(const field of state.preview.fields || []) {
const section=element('section',undefined,`prompt-sender-field prompt-role-${field.role}`);
section.append(element('h3',field.role==='positive'?'Positive prompt':'Negative prompt'));
textBlock(section,'Before',field.before);textBlock(section,'After',field.after);preview.append(section);
}
const composition=state.preview.composition;
if(composition) {
const section=element('section',undefined,'prompt-sender-composition');section.append(element('h3','Positive composition'));
textBlock(section,'Prepend (preserved)',composition.prepend || '');textBlock(section,'Append (preserved)',composition.append || '');
textBlock(section,'Combined before',composition.before ?? 'Resolved at execution from connected inputs.');
textBlock(section,'Combined after',composition.after ?? 'Resolved at execution from connected inputs.');
if(composition.prependConnected || composition.appendConnected) section.append(element('p','Connected prepend and append inputs are preserved and resolved when the workflow runs.'));
preview.append(section);
changes(preview,state.preview,3);
}
if(state.previews?.length) {
preview.append(element('h3','Review changes'));
// One section per node of "All", in the order they will be sent, named by its heading.
for(const item of state.previews) {
const node=element('section',undefined,'prompt-sender-node');const heading=element('h4',item.label);
heading.id=`prompt-sender-node-${++headingIds}`;node.setAttribute('aria-labelledby',heading.id);
node.append(heading);changes(node,item.preview,5);preview.append(node);
}
}
}
function changes(parent,change,level) {
for(const field of change.fields || []) {
const section=element('section',undefined,`prompt-sender-field prompt-role-${field.role}`);
section.append(element(`h${level}`,field.role==='positive'?'Positive prompt':'Negative prompt'));
textBlock(section,'Before',field.before,level+1);textBlock(section,'After',field.after,level+1);parent.append(section);
}
const composition=change.composition;
if(composition) {
const section=element('section',undefined,'prompt-sender-composition');section.append(element(`h${level}`,'Positive composition'));
textBlock(section,'Prepend (preserved)',composition.prepend || '',level+1);textBlock(section,'Append (preserved)',composition.append || '',level+1);
textBlock(section,'Combined before',composition.before ?? 'Resolved at execution from connected inputs.',level+1);
textBlock(section,'Combined after',composition.after ?? 'Resolved at execution from connected inputs.',level+1);
if(composition.prependConnected || composition.appendConnected) section.append(element('p','Connected prepend and append inputs are preserved and resolved when the workflow runs.'));
parent.append(section);
}
}
dialog.showModal();
try {
const url=new URL(baseUrl,window.location.href);
+48 -17
View File
@@ -11,8 +11,10 @@
* D99: its Review thumbnails opens the same "Thumbnail maintenance" dialog as Sources (thumbnail-review.mjs).
* #317: it reads "N missing · M outdated · K failed"; while previews are outdated (another format than the
* configured one) a second button, "Convert outdated", opens that dialog on its Convert outdated scope (D131).
* - D96: the Statistics dialog lists the real runs per category, then "Library totals" (D153: no rating column or
* rated-images total).
* - #555: metric 3, "Images", is the shell's Images count (the Images page's total, its sidebar badge), not the
* overview's indexed rows, which leave out files no row links: a dash until it is read or when its read failed.
* - D96: the Statistics dialog lists the real runs per category, then "Database records" (D153: no rating column or
* rated-images total; #555: the legacy row counts, labelled as records).
* - D95: the "Health report" lists every check the server ran, Missing files per image source, with a chip per real
* result and when it was checked. The check runs from Maintenance's notice ("Run health checks", D123) as a
* read job of the registry, tracked in the Activity store (its event stream); the report opens when it ends.
@@ -61,12 +63,20 @@ export function integrityText(health) {
return health.status === 'passed' ? 'Passed' : 'Incomplete';
}
/** The concept's four metrics `[value, label]`, in its order. */
export function metricValues(overview) {
/** #555: the Images metric's value from the shell's Images count; a dash while unread (undefined) or failed (null). */
export function imagesText(images) {
return Number.isSafeInteger(images) && images >= 0 ? number(images) : '–';
}
/**
* The concept's four metrics `[value, label]`, in its order. `images` is the shell's Images count (#555): the concept's
* `images.length`, the same number as the Images sidebar badge.
*/
export function metricValues(overview, images) {
return [
[number(overview.library.positive_prompts), 'Positive prompts'],
[number(overview.library.negative_variants), 'Negative variants'],
[number(overview.images.indexed), 'Images'],
[imagesText(images), 'Images'],
[integrityText(overview.health), 'Database integrity'],
];
}
@@ -174,10 +184,15 @@ export function categoryRows(statistics) {
return statistics.by_category.map(row => [row.category ?? 'Uncategorized', number(row.runs)]);
}
/** D96: the added "Library totals" rows `[label, count]` (the legacy statistics; D153 drops the rated images). */
/**
* D96: the added "Database records" rows `[label, count]` (the legacy statistics; D153 drops the rated images). #555:
* they count raw rows (every prompt row, tags on a prompt, every image row), so they say so and never read as the
* Library or Images page counts.
*/
export function totalsRows(statistics) {
const { totals } = statistics;
return [['Prompts', totals.total_prompts], ['Tags', totals.total_tags], ['Images', totals.total_images]]
return [['Prompt records', totals.total_prompts], ['Tags on prompts', totals.total_tags],
['Image records', totals.total_images]]
.map(([label, value]) => [label, number(value)]);
}
@@ -208,11 +223,11 @@ function table(caption, head, rows, empty) {
return node;
}
/** The Statistics dialog: "Runs by category" (the concept's table), then "Library totals" (D96). */
/** The Statistics dialog: "Runs by category" (the concept's table), then "Database records" (D96, #555). */
export function statisticsNodes(statistics) {
return [
table('Runs by category', ['Category', 'Runs'], categoryRows(statistics), 'No prompts in the library yet.'),
table('Library totals', ['Total', 'Count'], totalsRows(statistics), ''),
table('Database records', ['Total', 'Count'], totalsRows(statistics), ''),
];
}
@@ -332,9 +347,9 @@ export function healthNotice({ api, activity, scope, navigate = navigateTo, noti
return notice;
}
function metricsNode(overview) {
function metricsNode(overview, images) {
const metrics = el('div', undefined, 'summary-metrics');
for (const [value, label] of metricValues(overview)) {
for (const [value, label] of metricValues(overview, images)) {
const metric = el('div', undefined, 'metric');
metric.append(el('strong', value), el('span', label));
metrics.append(metric);
@@ -349,7 +364,7 @@ function surface(heading, rows) {
}
/** The Overview's nodes, and its thumbnail row (painted by its own read). */
function overviewNodes(overview, { api, activity, notify, scope, navigate, openStatistics, openThumbnails }) {
function overviewNodes(overview, { api, activity, notify, scope, navigate, openStatistics, openThumbnails, images }) {
const go = section => () => navigate(`#/system/${section}`);
const review = operationRow({ scope, title: ambiguousTitle(overview.needs_review),
description: Number.isSafeInteger(overview.needs_review?.pending)
@@ -374,26 +389,36 @@ function overviewNodes(overview, { api, activity, notify, scope, navigate, openS
operationRow({ scope, title: 'One display configuration', description: 'Grid and density settings are shared across views.',
label: 'Appearance', onClick: go('appearance') }),
operationRow({ scope, title: sourcesTitle, description: sourcesDescription, label: 'Manage sources', onClick: go('sources') }),
operationRow({ scope, title: 'Statistics', description: 'Runs by category, with library totals.', label: 'Explore',
operationRow({ scope, title: 'Statistics', description: 'Runs by category, with database record counts.', label: 'Explore',
onClick: openStatistics }),
]);
const columns = el('div', undefined, 'two-col');
columns.append(health, preferences);
return { nodes: [metricsNode(overview), columns], thumbnails, backup: backupRow };
const metrics = metricsNode(overview, images);
return { nodes: [metrics, columns], thumbnails, backup: backupRow, metrics };
}
/**
* The Overview section's mount (`mountSystem`'s `sections.overview`): returns its loading line, which the server's
* answer replaces in place (the concept's metrics and columns sit directly in `.settings-layout`). A read that a newer
* one superseded, or one answering after the section left, paints nothing.
*
* `counts` is the shell's sidebar counts (`{get(id), subscribe(listener), freshen(ids)}`, index.mjs): the Images metric
* shows its `images` count and follows it, and a visit re-reads it as the workspace Overview does (#555).
*/
export function mountOverview(container, { api, activity, scope, navigate = navigateTo, notify = showWorkspaceToast,
latestOnly = window.ApiClient.latestOnly, STALE = window.ApiClient.STALE }) {
export function mountOverview(container, { api, activity, scope, counts = null, navigate = navigateTo,
notify = showWorkspaceToast, latestOnly = window.ApiClient.latestOnly, STALE = window.ApiClient.STALE }) {
const overviewRead = latestOnly({ lifetime: scope.signal });
const statisticsRead = latestOnly({ lifetime: scope.signal });
const thumbnailsRead = latestOnly({ lifetime: scope.signal });
const backupRead = latestOnly({ lifetime: scope.signal });
let shown = [];
// #555: the Images metric's value node, repainted whenever the shell's Images count changes.
let imagesValue = null;
const imagesCount = () => counts?.get('images');
const paintImages = () => {
if (imagesValue) imagesValue.textContent = imagesText(imagesCount());
};
// The backup status (undefined while read, null when it failed), painted into its row once both are there (S9).
let backupStatus;
let backupRow = null;
@@ -439,8 +464,10 @@ export function mountOverview(container, { api, activity, scope, navigate = navi
try {
const overview = await overviewRead(signal => api.get(OVERVIEW_URL, { signal, cache: 'no-store' }));
if (overview === STALE || scope.signal.aborted) return;
const { nodes, thumbnails, backup } = overviewNodes(overview, { api, activity, notify, scope, navigate, openStatistics,
const { nodes, thumbnails, backup, metrics } = overviewNodes(overview, { api, activity, notify, scope, navigate,
openStatistics, images: imagesCount(),
openThumbnails: trigger => openThumbnailReview({ api, store: activity, scope, trigger, navigate }) });
imagesValue = metrics.children[2].querySelector('strong');
thumbnailRow = thumbnails;
backupRow = backup;
sources = overview.sources;
@@ -488,6 +515,10 @@ export function mountOverview(container, { api, activity, scope, navigate = navi
const first = swap(loading());
// D129: a finished Move library changes where the library lives.
scope.cleanup(onMoveEnded(activity, load));
if (counts) {
scope.cleanup(counts.subscribe(paintImages));
counts.freshen(['images']);
}
load();
loadThumbnails();
loadBackup();
+4 -3
View File
@@ -135,7 +135,7 @@ export const DELIVERED_SECTIONS = Object.freeze({
/**
* Mount System under the heading `renderHeading` wrote in `main`. Returns `{show(section)}`, which shows another
* section in place. `scope` is the page's view scope; `sections` maps a section id to a delivered section's mount
* (`(container, {api, activity, scope, drafts}) => nodes`) for every section. `activity` is the page's Activity store, which follows the jobs a section starts.
* (`(container, {api, activity, scope, drafts, counts}) => nodes`; `counts` is the shell's sidebar counts, #555) for every section. `activity` is the page's Activity store, which follows the jobs a section starts.
*
* The host values are read on mount (and by the store again after a conflict), so a save carries the current revision;
* until a read returned one, a host save fails without a request (system-state.mjs). The browser values are read on
@@ -159,7 +159,8 @@ function legacyPagesLine() {
return line;
}
export function mountSystem(main, api, scope, { section, activity, sections = DELIVERED_SECTIONS, navigate = navigateTo }) {
export function mountSystem(main, api, scope, { section, activity, counts = null, sections = DELIVERED_SECTIONS,
navigate = navigateTo }) {
const store = systemDrafts(api);
jobs = activity;
store.reloadHost();
@@ -208,7 +209,7 @@ export function mountSystem(main, api, scope, { section, activity, sections = DE
for (const button of nav.children) button.setAttribute('aria-pressed', String(button.dataset.value === next));
while (layout.firstChild !== saveRow) layout.firstChild.remove();
// Every section is delivered (S12), and the router only names known sections (state.mjs).
const nodes = sections[next](layout, { api, activity, scope: sectionScope, drafts: store });
const nodes = sections[next](layout, { api, activity, scope: sectionScope, drafts: store, counts });
layout.prepend(...nodes);
if (next === 'overview') main.append(legacyPages);
else legacyPages.remove();
+3
View File
@@ -416,6 +416,9 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc
.prompt-sender-preview { margin-block: 20px; min-inline-size: 0; /* D48 */ }
.prompt-sender-field { border: 1px solid var(--ca-line); border-radius: 7px; padding: 14px; margin-block: 12px; min-inline-size: 0; /* D48 */ }
.prompt-sender-field :where(pre, textarea), .prompt-sender-composition { white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; font-size: 12px; line-height: 1.65; max-inline-size: 100%; /* D48 */ }
/* D191 (#542): each node of "All eligible nodes" reviews under its own heading. */
.prompt-sender-node { border-block-start: 1px solid var(--ca-line); padding-block-start: 14px; margin-block: 18px; min-inline-size: 0; /* D191 */ }
.prompt-sender-node > h4 { font-size: 15px; /* D191 */ }
.prompt-sender-field textarea { inline-size: 100%; min-block-size: 100px; resize: vertical; /* D48 */ }
.prompt-sender-composition { padding: 12px; background: var(--ca-field); border-radius: 6px; /* D48 */ }
.prompt-sender-actions { justify-content: flex-end; margin-block-start: 18px; /* D48 */ }