Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94e8dcb73d | ||
|
|
611249526e | ||
|
|
e29c23de8d | ||
|
|
b82e68a9d0 | ||
|
|
fb02066cda | ||
|
|
ab17818792 | ||
|
|
ef97d01dbd | ||
|
|
b7a43755ae | ||
|
|
2c7e84dedd | ||
|
|
cc65d15d14 |
@@ -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:
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,5 +1,5 @@
|
||||
# Runtime dependencies
|
||||
watchdog>=6.0.0
|
||||
watchdog>=6.0.0,<7
|
||||
Pillow>=12.3.0
|
||||
aiohttp>=3.14.5
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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();}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+9
-22
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 */ }
|
||||
|
||||
Reference in New Issue
Block a user