Compare commits
109
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
190afca720 | ||
|
|
4c6dbc92fe | ||
|
|
8462ecbb03 | ||
|
|
b1bfce702d | ||
|
|
08d57b408a | ||
|
|
f93412ab90 | ||
|
|
c5846306f6 | ||
|
|
6e3fa65914 | ||
|
|
672eb6a18c | ||
|
|
85d1031179 | ||
|
|
8c45209af1 | ||
|
|
23c43dd5bf | ||
|
|
b2ea2597e6 | ||
|
|
ce983af09f | ||
|
|
b98c716b48 | ||
|
|
dc192d5836 | ||
|
|
13d1115406 | ||
|
|
b127bbeba1 | ||
|
|
675168deff | ||
|
|
5dfe7a8409 | ||
|
|
40dde002a4 | ||
|
|
2b3cd5cbfb | ||
|
|
b6f1e0a2c0 | ||
|
|
8413f9e240 | ||
|
|
8e2578ceb6 | ||
|
|
5b7fa1476a | ||
|
|
4400444619 | ||
|
|
9ba6c0ca63 | ||
|
|
efcbd5a8aa | ||
|
|
2381958fc7 | ||
|
|
f0e7561fb2 | ||
|
|
9db5fc6acc | ||
|
|
cd209131ee | ||
|
|
e720aeb73a | ||
|
|
1c37f0ab56 | ||
|
|
2dbb21edc2 | ||
|
|
d6cef849a1 | ||
|
|
507c8a8993 | ||
|
|
f9da6488d6 | ||
|
|
d83383b9e0 | ||
|
|
1d44d574cb | ||
|
|
b792da53ba | ||
|
|
4ab0214f09 | ||
|
|
0f9569cdd8 | ||
|
|
e709a158e6 | ||
|
|
a3877b9200 | ||
|
|
d7be0df380 | ||
|
|
ea7696bb9a | ||
|
|
c8613bdb66 | ||
|
|
3ad10404dc | ||
|
|
1f422d2c43 | ||
|
|
0393d01746 | ||
|
|
11aea013ca | ||
|
|
247abc3475 | ||
|
|
de194af8bc | ||
|
|
b2fb630f61 | ||
|
|
7b6b7ecf3d | ||
|
|
38fd581f09 | ||
|
|
edc0ee3d00 | ||
|
|
f99e76a135 | ||
|
|
9d6eb728fc | ||
|
|
78a12b67fd | ||
|
|
7128957d2c | ||
|
|
cdd5fd6b5e | ||
|
|
e282f6ea50 | ||
|
|
879247f1ef | ||
|
|
38f4c0af43 | ||
|
|
b0e60ec4b8 | ||
|
|
e7fa993a42 | ||
|
|
c855a1e23b | ||
|
|
5110ff1aff | ||
|
|
3a67c4b952 | ||
|
|
b05225490e | ||
|
|
ae11e81203 | ||
|
|
9234dac211 | ||
|
|
b5720904cd | ||
|
|
43d2352fff | ||
|
|
1a957b8fb3 | ||
|
|
0ab510f9a5 | ||
|
|
0ff59b5f5b | ||
|
|
3e1e15a629 | ||
|
|
580063cf5c | ||
|
|
c001f94213 | ||
|
|
ee80a344e2 | ||
|
|
205cf153ea | ||
|
|
3df88df583 | ||
|
|
7d65ee5516 | ||
|
|
59ad23201b | ||
|
|
5464e3295c | ||
|
|
729190309c | ||
|
|
0a46420385 | ||
|
|
248d5880e6 | ||
|
|
2cf6bdd393 | ||
|
|
5c592e4a87 | ||
|
|
114f8c779d | ||
|
|
a6d4facfbf | ||
|
|
2d3fc1cd08 | ||
|
|
b836e7cef9 | ||
|
|
22d23020fe | ||
|
|
bcb2713baa | ||
|
|
4a8dd545d8 | ||
|
|
027fdc09bb | ||
|
|
5fddf56eee | ||
|
|
3228824161 | ||
|
|
191d02eb6c | ||
|
|
d8e9f290ef | ||
|
|
2fdef18355 | ||
|
|
9ffaa1e6eb | ||
|
|
5093cb213f |
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"required_status_checks": {
|
||||
"strict": true,
|
||||
"contexts": [
|
||||
"py3.10 on ubuntu-latest",
|
||||
"py3.11 on ubuntu-latest",
|
||||
"py3.12 on ubuntu-latest",
|
||||
"py3.13 on ubuntu-latest",
|
||||
"py3.10 on windows-latest",
|
||||
"py3.11 on windows-latest",
|
||||
"py3.12 on windows-latest",
|
||||
"py3.13 on windows-latest",
|
||||
"py3.10 on macos-latest",
|
||||
"py3.11 on macos-latest",
|
||||
"py3.12 on macos-latest",
|
||||
"py3.13 on macos-latest",
|
||||
"coverage gate (85%)",
|
||||
"black and flake8",
|
||||
"frontend modules",
|
||||
"viewerjs",
|
||||
"real ComfyUI compatibility"
|
||||
]
|
||||
},
|
||||
"enforce_admins": true,
|
||||
"required_pull_request_reviews": null,
|
||||
"restrictions": null,
|
||||
"allow_force_pushes": false,
|
||||
"allow_deletions": false
|
||||
}
|
||||
@@ -46,15 +46,3 @@ updates:
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
|
||||
# Maintainer browser test dependencies; Viewer.js itself has an asset-aware updater.
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/tools/vendor"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
allow:
|
||||
- dependency-name: "@playwright/test"
|
||||
labels:
|
||||
- "dependencies"
|
||||
commit-message:
|
||||
prefix: "deps(vendor-tools)"
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -37,62 +37,6 @@ jobs:
|
||||
- name: Run tests
|
||||
run: python -m unittest discover -s tests -v
|
||||
|
||||
comfyui:
|
||||
name: real ComfyUI compatibility
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
cache-dependency-path: tools/vendor/package-lock.json
|
||||
- name: Read the reviewed runtime pins
|
||||
id: runtime
|
||||
run: |
|
||||
node --input-type=module <<'JS' >> "$GITHUB_OUTPUT"
|
||||
import runtime from './tools/vendor/comfyui-runtime.json' with { type: 'json' };
|
||||
for (const [key, value] of Object.entries(runtime)) console.log(`${key}=${value}`);
|
||||
JS
|
||||
- name: Check out pinned ComfyUI
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: ${{ steps.runtime.outputs.repository }}
|
||||
ref: ${{ steps.runtime.outputs.revision }}
|
||||
path: .ci-comfyui
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: ${{ steps.runtime.outputs.pythonVersion }}
|
||||
cache: pip
|
||||
cache-dependency-path: |
|
||||
requirements-dev.txt
|
||||
.ci-comfyui/requirements.txt
|
||||
tools/vendor/comfyui-runtime.json
|
||||
- name: Install CPU runtime and test dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install "torch==${{ steps.runtime.outputs.torchVersion }}" "torchvision==${{ steps.runtime.outputs.torchvisionVersion }}" --index-url https://download.pytorch.org/whl/cpu
|
||||
pip install -r .ci-comfyui/requirements.txt -r requirements-dev.txt "torch==${{ steps.runtime.outputs.torchVersion }}" "torchvision==${{ steps.runtime.outputs.torchvisionVersion }}"
|
||||
- run: npm ci --prefix tools/vendor --ignore-scripts
|
||||
- run: npx --prefix tools/vendor playwright install --with-deps chromium
|
||||
- name: Classic and Nodes 2.0 startup and compatibility
|
||||
env:
|
||||
PM_COMFY_ROOT: ${{ github.workspace }}/.ci-comfyui
|
||||
PM_TEST_PYTHON: python
|
||||
run: npm run --prefix tools/vendor test:comfyui
|
||||
- name: Upload real-host reports and startup logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: real-comfyui-reports
|
||||
path: tools/vendor/test-results/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
coverage:
|
||||
name: coverage gate (85%)
|
||||
runs-on: ubuntu-latest
|
||||
@@ -134,16 +78,6 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
- name: Check shipped extension and page modules parse
|
||||
run: |
|
||||
shopt -s globstar nullglob
|
||||
for f in web/comfy/**/*.js web/comfy/**/*.mjs web/js/**/*.js web/js/**/*.mjs; do
|
||||
node --input-type=module --check < "$f" || exit 1
|
||||
done
|
||||
|
||||
# Pure-JS frontend modules, tested with the runner's preinstalled Node
|
||||
- name: Run JS tests
|
||||
run: node --test tests/js/*.test.js
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
name: Viewer.js vendor and compatibility
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
viewerjs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: pip
|
||||
cache-dependency-path: requirements-dev.txt
|
||||
- run: python -m pip install -r requirements-dev.txt
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
cache-dependency-path: tools/vendor/package-lock.json
|
||||
- run: npm ci --prefix tools/vendor --ignore-scripts
|
||||
- run: npm run --prefix tools/vendor check
|
||||
- run: npm run --prefix tools/vendor test
|
||||
- run: npx --prefix tools/vendor playwright install --with-deps chromium firefox webkit
|
||||
- name: Frontend unit, viewer, workflow, and coverage checks
|
||||
run: npm run --prefix tools/vendor test:coverage
|
||||
- name: Firefox and WebKit page workflows
|
||||
run: npm run --prefix tools/vendor test:workflows -- --project firefox --project webkit
|
||||
- name: Upload browser traces and frontend coverage
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: frontend-test-reports
|
||||
path: |
|
||||
tools/vendor/test-results/
|
||||
coverage/js/
|
||||
!coverage/js/raw/**
|
||||
retention-days: 14
|
||||
@@ -1,83 +0,0 @@
|
||||
name: Update vendored Viewer.js
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 7 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Stable version to vendor; leave blank for the latest npm release'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: viewerjs-update
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
update:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
actions: write
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
cache-dependency-path: tools/vendor/package-lock.json
|
||||
- run: npm ci --prefix tools/vendor --ignore-scripts
|
||||
- name: Update the dependency and regenerate assets
|
||||
env:
|
||||
VIEWER_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
if [ -n "$VIEWER_VERSION" ]; then
|
||||
npm run --prefix tools/vendor update -- --version "$VIEWER_VERSION"
|
||||
else
|
||||
npm run --prefix tools/vendor update
|
||||
fi
|
||||
- name: Detect changes
|
||||
id: changes
|
||||
run: |
|
||||
if git diff --quiet -- tools/vendor/package.json tools/vendor/package-lock.json web/lib/viewerjs; then
|
||||
echo 'changed=false' >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo 'changed=true' >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- if: steps.changes.outputs.changed == 'true'
|
||||
run: |
|
||||
npm run --prefix tools/vendor check
|
||||
npm run --prefix tools/vendor test
|
||||
node --test tests/js/*.test.js
|
||||
npx --prefix tools/vendor playwright install --with-deps chromium
|
||||
npm run --prefix tools/vendor test:browser
|
||||
- name: Open the update PR
|
||||
if: steps.changes.outputs.changed == 'true'
|
||||
env:
|
||||
BASE_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
run: |
|
||||
version=$(node -p "require('./web/lib/viewerjs/provenance.json').version")
|
||||
branch="deps/viewerjs-$version"
|
||||
existing=$(gh pr list --repo "$GITHUB_REPOSITORY" --state all --head "$branch" --json number --jq 'length')
|
||||
if [ "$existing" != '0' ]; then
|
||||
echo "A PR already exists for Viewer.js $version; leaving it for review."
|
||||
exit 0
|
||||
fi
|
||||
remote=$(git ls-remote origin "refs/heads/$branch")
|
||||
if [ -z "$remote" ]; then
|
||||
git switch -c "$branch"
|
||||
git add tools/vendor/package.json tools/vendor/package-lock.json web/lib/viewerjs
|
||||
git -c user.name='github-actions[bot]' -c user.email='41898282+github-actions[bot]@users.noreply.github.com' commit -m "deps: update vendored Viewer.js to $version"
|
||||
git push origin "HEAD:refs/heads/$branch"
|
||||
fi
|
||||
gh pr create --repo "$GITHUB_REPOSITORY" --base "$BASE_BRANCH" --head "$branch" --title "deps: update vendored Viewer.js to $version" --body-file tools/vendor/update-pr.md
|
||||
gh workflow run viewerjs-check.yml --repo "$GITHUB_REPOSITORY" --ref "$branch"
|
||||
-11
@@ -200,14 +200,3 @@ cython_debug/
|
||||
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
# Maintainer-only JavaScript tooling and browser test artifacts
|
||||
node_modules/
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
# Combined frontend coverage reports
|
||||
coverage/
|
||||
|
||||
# Disposable upstream checkout for real-host CI
|
||||
.ci-comfyui/
|
||||
|
||||
@@ -139,17 +139,6 @@ The comprehensive gallery system provides complete management of all ComfyUI out
|
||||
|
||||
_Powered by [ViewerJS](https://github.com/fengyuanchen/viewerjs) for professional image viewing capabilities._
|
||||
|
||||
Viewer.js is vendored locally. See [Viewer.js maintenance](tools/vendor/README.md)
|
||||
for reproducible updates, verification, and automated update PRs.
|
||||
|
||||
Prompt Manager appears as **Prompt Manager (Classic)** in the Add Node menu.
|
||||
It keeps the existing layout and behavior while using shared
|
||||
[ComfyAssets Toolkit](https://github.com/ComfyAssets/ComfyAssets-Toolkit) controls
|
||||
that follow ComfyUI's light/dark theme. Existing workflows keep the same internal
|
||||
node identifier and sockets. The reusable architecture is inspired by
|
||||
[Pixaroma](https://github.com/pixaroma/ComfyUI-Pixaroma); no implementation or assets
|
||||
were copied. See the [toolkit maintenance notes](tools/vendor/README.md#shared-comfyassets-browser-controls).
|
||||
|
||||
### 🔬 ComfyUI Workflow Analysis
|
||||
|
||||
- **PNG Metadata Parsing**: Advanced PNG chunk analysis to extract embedded ComfyUI data
|
||||
@@ -926,11 +915,6 @@ MIT License - see LICENSE file for details.
|
||||
|
||||
## Changelog
|
||||
|
||||
### v3.3.1 (Proxy Routing Fix)
|
||||
|
||||
- **🌐 Proxy support**: the dashboard, gallery, tag page and metadata viewer now work when ComfyUI is reached through a proxy mount such as SwarmUI's `/ComfyBackendDirect/`. Scripts, images, API calls, progress streams and gallery folder filters stay under the mount, and the canvas node's buttons use ComfyUI's `fileURL()`. Direct ComfyUI URLs are unchanged. Thanks to @ghosttigerllc-bit ([#164](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/164))
|
||||
- **📦 Dependencies**: the runtime minimums stay at `Pillow>=8.0.0` and `watchdog>=2.1.0`, so updating Prompt Manager never forces upgrades in the shared ComfyUI environment
|
||||
|
||||
### v3.3.0 (Hardening, Background Jobs & Worker Threads)
|
||||
|
||||
- **🔒 Security**: gallery roots restricted to ComfyUI directories and validated wherever they are read; media-only, fail-closed image serving; Windows path escape in static routes closed; quote-safe HTML escaping with inline handlers moved to data attributes; the CivitAI key is never returned and only sent to HTTPS CivitAI hosts; auto-tag paths contained; side-effect routes are POST-only; backups include the WAL and restores are size-capped, integrity-checked and applied through SQLite's online backup API
|
||||
|
||||
-130
@@ -1,130 +0,0 @@
|
||||
# Testing and merge confidence
|
||||
|
||||
Routine changes should merge after the required checks pass and the diff is
|
||||
reviewed. Add a regression test for each bug; prove it fails before the fix.
|
||||
|
||||
## What runs
|
||||
|
||||
| Layer | Real components | Replaced boundaries |
|
||||
| --- | --- | --- |
|
||||
| Python tests | Domain logic, SQLite, API handlers, filesystem checks | ComfyUI host and ML runtimes where needed |
|
||||
| JavaScript tests | Frontend modules and controller methods | DOM/network fixtures for focused edge cases |
|
||||
| Viewer checks | Production Viewer.js and frontend methods in Chromium desktop/touch | Controlled images and page fixtures |
|
||||
| Real ComfyUI | Pinned CPU host, actual extension, node definitions, widgets, routes and SQLite | Inference is not queued; browser startup settings and host preference writes are isolated |
|
||||
| Page workflows | Production HTML, startup, click handlers, HTTP routes, SQLite, PNG files, thumbnails | ComfyUI host services and the tagging model |
|
||||
|
||||
Page workflows cover save/edit/reload, duplicate-edit rejection, gallery image
|
||||
identity, Tags selection and viewer cleanup, delayed responses, auto-tag review
|
||||
and persistence, and infinite scrolling without replacing existing cards.
|
||||
Data safety workflows download and upload actual SQLite backups, verify restored
|
||||
prompts/tags/image links, reject corrupt uploads without changing live data, and
|
||||
exercise cancellation and network-failure recovery for restores and deletion.
|
||||
Duplicate cleanup verifies only the selected file disappears; prompt deletion
|
||||
verifies the other prompt and original image files survive.
|
||||
They run in Chromium, Firefox, WebKit, and Chromium touch emulation. No GPU,
|
||||
model downloads, running ComfyUI instance, or personal configuration is required.
|
||||
The server binds to localhost and creates disposable directories and a database;
|
||||
its reset endpoint exists only in the test harness.
|
||||
|
||||
## Run locally
|
||||
|
||||
Use a Python environment containing `requirements-dev.txt` and Node.js 24:
|
||||
|
||||
```sh
|
||||
python -m unittest discover -s tests -q
|
||||
npm ci --prefix tools/vendor --ignore-scripts
|
||||
npx --prefix tools/vendor playwright install --with-deps chromium firefox webkit
|
||||
npm run --prefix tools/vendor test
|
||||
npm run --prefix tools/vendor test:coverage
|
||||
npm run --prefix tools/vendor test:workflows -- --project firefox --project webkit
|
||||
```
|
||||
|
||||
`PM_TEST_PYTHON` selects the Python executable; `PM_TEST_PORT` selects the local
|
||||
port (default 8765). Set these environment variables when Python is outside PATH
|
||||
or the port is occupied. Test servers are never reused. On unsupported Linux
|
||||
distributions, browser system dependencies may need manual installation; CI uses
|
||||
Ubuntu. For a quick browser run, use `test:workflows -- --project chromium`.
|
||||
|
||||
## Managed real ComfyUI
|
||||
|
||||
The shared engine comes from an exact commit of
|
||||
[ComfyAssets Toolkit](https://github.com/ComfyAssets/ComfyAssets-Toolkit), recorded
|
||||
in `tools/vendor/package.json` and its integrity-checked lockfile. The local
|
||||
adapter sets PromptManager's database/configuration and monitoring policy;
|
||||
source discovery, coverage mapping/gates and process lifecycle are shared code.
|
||||
KikoTools remains the reference and is unchanged by this integration.
|
||||
|
||||
Use a **dedicated disposable** ComfyUI checkout at the revision in
|
||||
`tools/vendor/comfyui-runtime.json`. With a separate Python environment, install
|
||||
that checkout's requirements, this repo's `requirements-dev.txt`, and the exact
|
||||
CPU torch/torchvision pins from the JSON (using PyTorch's CPU wheel index).
|
||||
Install Chromium using the Playwright command above, then run:
|
||||
|
||||
```sh
|
||||
PM_COMFY_ROOT=/path/to/disposable/ComfyUI \
|
||||
PM_TEST_PYTHON=/path/to/cpu-env/bin/python \
|
||||
npm run --prefix tools/vendor test:comfyui
|
||||
```
|
||||
|
||||
CI performs this setup automatically. `PM_COMFY_URL` can select another
|
||||
`http://127.0.0.1:PORT/` address (default port 8189). This is an owned managed
|
||||
server: a running host is never reused. Do not point it at your live checkout.
|
||||
The launcher verifies the upstream SHA and clean tracked files, snapshots only
|
||||
tracked PromptManager files, and creates temporary user/input/output/database/
|
||||
configuration/log directories. Personal untracked files are excluded. It runs
|
||||
CPU/offline with only PromptManager enabled and removes owned state on shutdown.
|
||||
The upstream checkout must also be disposable: ComfyUI may initialize local
|
||||
runtime files there. Browser host-settings writes are intercepted; actual
|
||||
PromptManager routes use the disposable host and database.
|
||||
|
||||
`real-comfyui-reports` retains startup logs, version attachments, screenshots and
|
||||
traces for 14 days. Update upstream/frontend/CPU pins together after reviewing
|
||||
and running both startup projects; never replace the SHA with a moving branch.
|
||||
Update the toolkit via an exact reviewed commit archive and commit both npm
|
||||
manifest and lockfile. No toolkit or npm installation is needed by end users.
|
||||
|
||||
## Coverage and failures
|
||||
|
||||
Python CI enforces 85% combined statement/branch coverage. Frontend coverage merges
|
||||
Node V8 unit coverage with Chromium page-workflow coverage through c8. Every
|
||||
`web/js/**/*.js` and `web/js/**/*.mjs` file appears, including untouched files at zero coverage. The
|
||||
controller tests provide V8 filenames so their executed code is counted too.
|
||||
|
||||
`tools/vendor/frontend-coverage-baseline.json` holds a floor for every file and
|
||||
metric. CI rejects regressions, missing measurements, and new files without a
|
||||
reviewed baseline. These floors reflect current coverage, with two percentage
|
||||
points of allowance for runtime/timing differences; they do not claim 80% coverage
|
||||
of the large controllers. New files need at least 80% line coverage when added via
|
||||
the baseline updater. Raise floors with `npm run --prefix tools/vendor
|
||||
coverage:update`; it reruns the suites and cannot lower an existing floor.
|
||||
Review baseline changes like code changes.
|
||||
|
||||
The `viewerjs` check runs on every PR, including backend-only changes. GitHub
|
||||
artifacts named `frontend-test-reports` contain HTML coverage, a JSON summary,
|
||||
and failure screenshots/traces. Open traces with Playwright's `show-trace` tool.
|
||||
|
||||
The real-host suite covers both Classic and Nodes 2.0 startup, switching both
|
||||
ways, actual database prompt selection, execution-text serialization, workflow
|
||||
reload, widget/style cleanup, node schemas and standalone-page opening. It does
|
||||
not queue inference or certify every existing feature, arbitrary host versions,
|
||||
GPU/model changes, cross-pack installation or visual design changes. Those still
|
||||
need targeted validation.
|
||||
|
||||
## Required merge checks
|
||||
|
||||
`.github/branch-protection.json` records the required checks: all twelve Python/OS
|
||||
combinations, Python coverage, formatting, frontend modules, `viewerjs`, and `real ComfyUI compatibility`.
|
||||
The branch must be current with main, and administrators follow the same checks.
|
||||
It does not require an additional human approval.
|
||||
|
||||
A repository administrator applies the configuration after the new workflow is
|
||||
passing, then verifies it:
|
||||
|
||||
```sh
|
||||
gh api --method PUT repos/ComfyAssets/ComfyUI_PromptManager/branches/main/protection \
|
||||
--input .github/branch-protection.json
|
||||
gh api repos/ComfyAssets/ComfyUI_PromptManager/branches/main/protection/required_status_checks
|
||||
```
|
||||
|
||||
Update the manifest whenever required job names change. Keep the frontend check
|
||||
free of path filters so a required check is never silently skipped.
|
||||
+1
-1
@@ -49,7 +49,7 @@ NODE_CLASS_MAPPINGS = {
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"PromptManager": "Prompt Manager (Classic)",
|
||||
"PromptManager": "Prompt Manager",
|
||||
"PromptManagerText": "Prompt Manager Text",
|
||||
"PromptSearchList": "Prompt Search List",
|
||||
}
|
||||
|
||||
+6
-17
@@ -13,14 +13,12 @@ from .models import PromptModel, normalize_image_path, utc_now_iso
|
||||
# Import logging system
|
||||
try:
|
||||
from ..utils.logging_config import get_logger
|
||||
from ..utils.hashing import generate_prompt_hash
|
||||
except ImportError:
|
||||
import sys
|
||||
|
||||
current_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, current_dir)
|
||||
from utils.logging_config import get_logger
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
|
||||
# Subquery to fetch tags from junction table, embedded in SELECT statements
|
||||
@@ -1856,20 +1854,16 @@ class PromptDatabase:
|
||||
}
|
||||
|
||||
def update_prompt_text(self, prompt_id: int, new_text: str) -> bool:
|
||||
"""Update prompt text and its deduplication hash atomically.
|
||||
"""Update only the text of a prompt.
|
||||
|
||||
Returns:
|
||||
True if a row was updated.
|
||||
|
||||
Raises:
|
||||
sqlite3.IntegrityError: Another prompt already has the new hash.
|
||||
"""
|
||||
with self.model.get_connection() as conn:
|
||||
cursor = conn.execute(
|
||||
"UPDATE prompts SET text = ?, hash = ?, updated_at = ? WHERE id = ?",
|
||||
"UPDATE prompts SET text = ?, updated_at = ? WHERE id = ?",
|
||||
(
|
||||
new_text,
|
||||
generate_prompt_hash(new_text),
|
||||
datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
prompt_id,
|
||||
),
|
||||
@@ -1973,27 +1967,23 @@ class PromptDatabase:
|
||||
conn.commit()
|
||||
return count
|
||||
|
||||
def get_image_prompt_info(
|
||||
self, image_path: str, allow_filename_fallback: bool = True
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
def get_image_prompt_info(self, image_path: str) -> Optional[Dict[str, Any]]:
|
||||
"""Look up prompt + image metadata for a given image path.
|
||||
|
||||
Returns a dict with prompt_id, text, category, tags, rating, notes,
|
||||
workflow_data, prompt_metadata, generation_time, parameters — or None.
|
||||
workflow_data, prompt_metadata, generation_time — or None.
|
||||
"""
|
||||
with self.model.get_connection() as conn:
|
||||
cursor = conn.execute(
|
||||
"""SELECT gi.prompt_id, p.text, p.category, p.rating, p.notes,
|
||||
gi.workflow_data, gi.prompt_metadata, gi.generation_time,
|
||||
gi.parameters
|
||||
gi.workflow_data, gi.prompt_metadata, gi.generation_time
|
||||
FROM generated_images gi
|
||||
JOIN prompts p ON gi.prompt_id = p.id
|
||||
WHERE gi.image_path = ? OR gi.file_path = ?
|
||||
OR (? AND gi.image_path LIKE ? ESCAPE '\\')""",
|
||||
OR gi.image_path LIKE ? ESCAPE '\\'""",
|
||||
(
|
||||
image_path,
|
||||
normalize_image_path(image_path),
|
||||
allow_filename_fallback,
|
||||
f"%{escape_like(os.path.basename(image_path))}",
|
||||
),
|
||||
)
|
||||
@@ -2019,7 +2009,6 @@ class PromptDatabase:
|
||||
"workflow_data": workflow,
|
||||
"prompt_metadata": prompt_meta,
|
||||
"generation_time": row[7],
|
||||
"parameters": row[8] if len(row) > 8 else None,
|
||||
}
|
||||
|
||||
def get_prompt_id_for_image(self, image_path: str) -> Optional[int]:
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 75 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 74 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 76 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 75 KiB |
@@ -1,71 +0,0 @@
|
||||
# Shared ComfyAssets UI adoption
|
||||
|
||||
Status: approved direction; toolkit/testing foundation in progress.
|
||||
Product UI brainstorming comes before node changes.
|
||||
|
||||
Tracking: [PromptManager #186](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/186)
|
||||
and [KikoTools #81](https://github.com/ComfyAssets/ComfyUI-KikoTools/issues/81).
|
||||
|
||||
KikoTools is the validated model. The
|
||||
[ComfyAssets Toolkit](https://github.com/ComfyAssets/ComfyAssets-Toolkit) is the
|
||||
shared source; PromptManager proves the package boundaries before a later
|
||||
KikoTools adoption PR. Other custom nodes can become additional consumers.
|
||||
|
||||
## Approved sequence
|
||||
|
||||
1. Extract reusable controls and test infrastructure from the working KikoTools
|
||||
reference into the toolkit. Keep routes, node schemas and controllers local.
|
||||
2. Bring PromptManager's automated tests up to parity using the pinned toolkit.
|
||||
3. Brainstorm PromptManager node layouts, controls and behavior with the maintainer
|
||||
before changing its production UI. Select one bounded first consumer together.
|
||||
4. Validate shared distribution, themes, cleanup and compatibility through that
|
||||
consumer, then migrate incrementally. Test both packs together as appropriate.
|
||||
5. Backport toolkit adoption to KikoTools after PromptManager proves it, preserving
|
||||
the existing KikoTools appearance, behavior and workflow compatibility.
|
||||
|
||||
KikoTools is not being migrated again during this phase. The original
|
||||
[foundation mockup](https://github.com/ComfyAssets/ComfyUI-KikoTools/pull/82)
|
||||
records the approved visual direction and inspiration.
|
||||
|
||||
## PromptManager requirements
|
||||
|
||||
- Preserve existing node names, inputs/outputs, API routes, saved workflows,
|
||||
gallery behavior, and existing test/coverage gates.
|
||||
- Use shared control factories, editor slots, settings/help/window behavior and
|
||||
the ComfyUI adapter where applicable. Keep PromptManager controllers and HTTP
|
||||
behavior in this repository.
|
||||
- Both projects must look identical: one shared set of controls, typography,
|
||||
spacing, colors and interaction states. Follow ComfyUI light/dark mode with
|
||||
our own styling, a blue primary accent and strong red errors/destructive actions.
|
||||
Use compact buttons, pill switches, segmented selections and responsive rows.
|
||||
Review the pilot mockup before production styling. Existing `--pm-*` tokens
|
||||
are migration context, not an approved design to retain.
|
||||
- Theme standalone pages explicitly: `window.open` pages do not inherit the
|
||||
host's CSS variables. Test the agreed palette policy, live changes, fallbacks
|
||||
and persistence, including errors when host settings are unavailable.
|
||||
- Ship a pinned, reproducible browser distribution. Installation must work
|
||||
without npm or KikoTools, including when both packs ship different core versions.
|
||||
- Attribute [Pixaroma](https://github.com/pixaroma/ComfyUI-Pixaroma) as architectural
|
||||
inspiration. Do not copy/vendor his code, styles, or assets.
|
||||
|
||||
## First migration
|
||||
|
||||
With the maintainer, inventory the PromptManager nodes and gallery dialogs and
|
||||
choose one bounded first UI consumer after brainstorming. Record its current behavior with tests before changing it.
|
||||
Verify keyboard opening/closing, focus restoration, accessible labels, confirm
|
||||
and cancel behavior, network failures, and light/dark appearances. Keep the
|
||||
existing gallery and destructive-action browser workflows passing.
|
||||
|
||||
Build shared-source extraction and distribution checks before importing the core.
|
||||
Test both packs alone and together, scoped styling, isolated settings/storage,
|
||||
and independent cleanup. ComfyUI-facing changes require the real-host Classic,
|
||||
Nodes 2.0 and live-switching matrix from the foundation proposal. Standalone
|
||||
dialogs additionally run through PromptManager's existing browser harness.
|
||||
|
||||
## Delivery
|
||||
|
||||
Use small issue-linked PRs and TDD: verify each new behavioral test fails before
|
||||
implementing it, then run relevant suites and required CI checks. Include visual
|
||||
evidence for appearance changes. Migrate additional nodes/dialogs/editors only after
|
||||
the first consumer validates the package boundaries; do not rewrite all pages
|
||||
or change backend schemas as part of this initiative.
|
||||
+8
-33
@@ -703,43 +703,18 @@ class PromptManagerAPI(
|
||||
"negative_prompt": None,
|
||||
}
|
||||
|
||||
# Check for A1111/Forge style parameters first.
|
||||
# The chunk is one block: a possibly multi-line positive prompt, then a
|
||||
# "Negative prompt:" section, then a trailing "Steps: ..." settings line.
|
||||
# Taking lines[0] as the positive prompt truncates any multi-line prompt
|
||||
# to one line, so split on the settings line instead.
|
||||
# Check for A1111 style parameters first (like parse-metadata.py)
|
||||
if "parameters" in metadata:
|
||||
import re as _re
|
||||
|
||||
params = metadata["parameters"]
|
||||
body = params
|
||||
settings = ""
|
||||
_sm = _re.search(r"\nSteps:\s", body)
|
||||
if _sm:
|
||||
settings = body[_sm.start() + 1 :].strip()
|
||||
body = body[: _sm.start()]
|
||||
negative = None
|
||||
_nm = _re.search(r"(?im)^\s*negative prompt:\s*", body)
|
||||
if _nm:
|
||||
negative = body[_nm.end() :].strip()
|
||||
body = body[: _nm.start()]
|
||||
elif body.lower().lstrip().startswith("negative prompt:"):
|
||||
negative = body.lower().lstrip()[len("negative prompt:") :].strip()
|
||||
body = ""
|
||||
result["positive_prompt"] = body.strip() or None
|
||||
if negative:
|
||||
result["negative_prompt"] = negative
|
||||
lines = params.splitlines()
|
||||
if lines:
|
||||
result["positive_prompt"] = lines[0].strip()
|
||||
for line in lines:
|
||||
if line.lower().startswith("negative prompt:"):
|
||||
result["negative_prompt"] = line.split(":", 1)[1].strip()
|
||||
break
|
||||
# Store raw parameters too
|
||||
result["parameters"]["parameters"] = params
|
||||
if settings:
|
||||
for _f in ("Steps", "Sampler", "Schedule type", "CFG scale", "Seed"):
|
||||
_fm = _re.search(
|
||||
r"(?i)(?:^|,\s*)" + _re.escape(_f) + r":\s*([^,]+)", settings
|
||||
)
|
||||
if _fm:
|
||||
result["parameters"][_f.lower().replace(" ", "_")] = _fm.group(
|
||||
1
|
||||
).strip()
|
||||
|
||||
# If no A1111 format found, proceed with ComfyUI parsing
|
||||
if result["positive_prompt"] is None:
|
||||
|
||||
@@ -484,22 +484,6 @@ class AutotagRoutesMixin:
|
||||
if not raw_path or not isinstance(raw_path, str):
|
||||
return None, None, self._bad_request("image_id or path is required")
|
||||
|
||||
if "root_index" in data:
|
||||
root_index = data["root_index"]
|
||||
if isinstance(root_index, bool) or not isinstance(root_index, int):
|
||||
return None, None, self._bad_request("root_index must be an integer")
|
||||
roots = self._get_all_output_dirs()
|
||||
if not 0 <= root_index < len(roots):
|
||||
return None, None, self._forbidden(raw_path)
|
||||
root = roots[root_index]
|
||||
if Path(raw_path).is_absolute() or ".." in raw_path.replace(
|
||||
"\\", "/"
|
||||
).split("/"):
|
||||
return None, None, self._forbidden(raw_path)
|
||||
raw_path = str(Path(root) / raw_path)
|
||||
if not path_is_within(raw_path, root):
|
||||
return None, None, self._forbidden(raw_path)
|
||||
|
||||
resolved = self._resolve_allowed_image_path(raw_path)
|
||||
if resolved is None:
|
||||
return None, None, self._forbidden(raw_path)
|
||||
|
||||
+114
-173
@@ -214,9 +214,9 @@ def _thumbnail_complete_payload(total, stats, start, remaining=0, processed=None
|
||||
|
||||
|
||||
def _thumbnail_is_fresh(src, dst):
|
||||
"""True when ``dst`` exists and is at least as new as ``src`` (blocking stat)."""
|
||||
"""True when ``dst`` exists and is newer than ``src`` (blocking stat)."""
|
||||
try:
|
||||
return dst.stat().st_mtime >= src.stat().st_mtime
|
||||
return dst.stat().st_mtime > src.stat().st_mtime
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
@@ -316,9 +316,6 @@ def _output_image_entry(media_path, output_path, root_index, public_root=None):
|
||||
f"{'.jpg' if is_video else extension}"
|
||||
)
|
||||
thumbnail_url = thumbnail_url_for(thumb_rel, output_path / thumb_rel)
|
||||
if thumbnail_url is not None:
|
||||
thumbnail_url += f"&root={root_index}"
|
||||
encoded_path = urllib.parse.quote(rel_path.as_posix(), safe="/")
|
||||
|
||||
return {
|
||||
"id": hashlib.sha1(str(media_path).encode("utf-8")).hexdigest()[:16],
|
||||
@@ -327,7 +324,7 @@ def _output_image_entry(media_path, output_path, root_index, public_root=None):
|
||||
"relative_path": str(rel_path),
|
||||
"root_dir": public_root,
|
||||
"root_index": root_index,
|
||||
"url": f"/prompt_manager/images/serve/{encoded_path}?root={root_index}",
|
||||
"url": f"/prompt_manager/images/serve/{rel_path.as_posix()}",
|
||||
"thumbnail_url": thumbnail_url,
|
||||
"size": stat.st_size,
|
||||
"modified_time": stat.st_mtime,
|
||||
@@ -385,10 +382,6 @@ class ImageRoutesMixin:
|
||||
async def delete_image_route(request):
|
||||
return await self.delete_image(request)
|
||||
|
||||
@routes.get("/prompt_manager/images/thumbnails/status")
|
||||
async def thumbnail_status_route(request):
|
||||
return await self.get_thumbnail_status(request)
|
||||
|
||||
@routes.post("/prompt_manager/images/generate-thumbnails")
|
||||
async def generate_thumbnails_route(request):
|
||||
return await self.generate_thumbnails(request)
|
||||
@@ -706,10 +699,10 @@ class ImageRoutesMixin:
|
||||
try:
|
||||
idx = int(root_param)
|
||||
except ValueError:
|
||||
return []
|
||||
return output_dirs
|
||||
if 0 <= idx < len(output_dirs):
|
||||
return [output_dirs[idx]]
|
||||
return []
|
||||
return output_dirs
|
||||
|
||||
async def serve_output_image(self, request):
|
||||
"""Serve a media file from the ComfyUI output folder(s)."""
|
||||
@@ -822,15 +815,19 @@ class ImageRoutesMixin:
|
||||
return error
|
||||
thumbnail_size = self._thumbnail_size(data.get("quality", "medium"))
|
||||
|
||||
output_dirs = self._get_all_output_dirs()
|
||||
if not output_dirs:
|
||||
output_dir = self._find_comfyui_output_dir()
|
||||
if not output_dir:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "ComfyUI output directory not found"},
|
||||
status=404,
|
||||
)
|
||||
|
||||
output_path = Path(output_dir)
|
||||
result = await self._run_in_executor(
|
||||
self._generate_gallery_thumbnails_sync, output_dirs, thumbnail_size
|
||||
self._generate_thumbnails_sync,
|
||||
output_path,
|
||||
output_path / "thumbnails",
|
||||
thumbnail_size,
|
||||
)
|
||||
self.invalidate_gallery_cache()
|
||||
return web.json_response(result)
|
||||
@@ -845,22 +842,6 @@ class ImageRoutesMixin:
|
||||
"""Blocking thumbnail generation loop (run in executor)."""
|
||||
start = _time.monotonic()
|
||||
plan = self._plan_thumbnails(output_path, thumbnails_dir)
|
||||
payload = self._execute_thumbnail_plan(plan, thumbnail_size, start)
|
||||
payload["thumbnails_path"] = _public_path(thumbnails_dir)
|
||||
return payload
|
||||
|
||||
def _generate_gallery_thumbnails_sync(
|
||||
self, output_dirs: list[Path], thumbnail_size: tuple[int, int]
|
||||
) -> dict:
|
||||
start = _time.monotonic()
|
||||
plan = self._plan_gallery_thumbnails(output_dirs)
|
||||
payload = self._execute_thumbnail_plan(plan, thumbnail_size, start)
|
||||
payload["thumbnails_path"] = _public_path(output_dirs[0] / "thumbnails")
|
||||
return payload
|
||||
|
||||
def _execute_thumbnail_plan(
|
||||
self, plan: dict, thumbnail_size: tuple[int, int], start: float
|
||||
) -> dict:
|
||||
targets = plan["targets"]
|
||||
stats = _new_thumbnail_stats()
|
||||
stats["skipped"] = plan["skipped"]
|
||||
@@ -874,50 +855,10 @@ class ImageRoutesMixin:
|
||||
processed=len(targets),
|
||||
)
|
||||
payload["success"] = True
|
||||
payload["thumbnails_path"] = _public_path(thumbnails_dir)
|
||||
self.logger.info(payload["message"])
|
||||
return payload
|
||||
|
||||
def _plan_gallery_thumbnails(self, output_dirs: list[Path]) -> dict:
|
||||
"""Plan every gallery root with one shared per-run generation cap."""
|
||||
combined = {"targets": [], "skipped": 0, "remaining": 0}
|
||||
for root in output_dirs:
|
||||
root = Path(root)
|
||||
plan = self._plan_thumbnails(root, root / "thumbnails")
|
||||
available = max(0, MAX_THUMBNAIL_FILES - len(combined["targets"]))
|
||||
combined["targets"].extend(plan["targets"][:available])
|
||||
combined["skipped"] += plan["skipped"]
|
||||
combined["remaining"] += plan["remaining"] + max(
|
||||
0, len(plan["targets"]) - available
|
||||
)
|
||||
return combined
|
||||
|
||||
async def get_thumbnail_status(self, request: web.Request) -> web.Response:
|
||||
"""Count pending thumbnails off-loop without generating files."""
|
||||
try:
|
||||
output_dirs = self._get_all_output_dirs()
|
||||
plan = await self._run_in_executor(
|
||||
self._plan_gallery_thumbnails, output_dirs
|
||||
)
|
||||
pending = len(plan["targets"]) + plan["remaining"]
|
||||
collection = json.dumps(sorted(str(_canonical(p)) for p in output_dirs))
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"total_images": pending + plan["skipped"],
|
||||
"pending": pending,
|
||||
"ready": plan["skipped"],
|
||||
"collection_id": hashlib.sha256(collection.encode()).hexdigest()[
|
||||
:16
|
||||
],
|
||||
},
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
except Exception as exc:
|
||||
self.logger.exception("Thumbnail status failed")
|
||||
return web.json_response(
|
||||
{"success": False, "error": _safe_error(exc)}, status=500
|
||||
)
|
||||
|
||||
def _plan_thumbnails(self, output_path, thumbnails_dir):
|
||||
"""Decide which media files need a thumbnail (blocking walk).
|
||||
|
||||
@@ -982,7 +923,7 @@ class ImageRoutesMixin:
|
||||
try:
|
||||
if not src.is_file():
|
||||
return {**result, "action": "error", "error": "File no longer exists"}
|
||||
if _thumbnail_is_fresh(src, dst):
|
||||
if dst.exists() and dst.stat().st_mtime > src.stat().st_mtime:
|
||||
return {**result, "action": "skipped"}
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
if is_video:
|
||||
@@ -1016,20 +957,21 @@ class ImageRoutesMixin:
|
||||
)
|
||||
await response.prepare(request)
|
||||
try:
|
||||
output_dirs = self._get_all_output_dirs()
|
||||
if not output_dirs:
|
||||
output_dir = self._find_comfyui_output_dir()
|
||||
if not output_dir:
|
||||
await self._emit_sse(
|
||||
response, "error", {"error": "ComfyUI output directory not found"}
|
||||
)
|
||||
return response
|
||||
|
||||
output_path = Path(output_dir)
|
||||
await self._emit_sse(
|
||||
response,
|
||||
"status",
|
||||
{"phase": "scanning", "message": "Scanning for images and videos..."},
|
||||
)
|
||||
plan = await self._run_in_executor(
|
||||
self._plan_gallery_thumbnails, output_dirs
|
||||
self._plan_thumbnails, output_path, output_path / "thumbnails"
|
||||
)
|
||||
await self._stream_thumbnails(
|
||||
response,
|
||||
@@ -1254,109 +1196,119 @@ class ImageRoutesMixin:
|
||||
)
|
||||
return False
|
||||
|
||||
async def clear_thumbnails(self, request: web.Request) -> web.Response:
|
||||
"""Clear generated thumbnails across gallery roots, preserving originals.
|
||||
|
||||
Invalid thumbnail directories return 400; unavailable gallery roots
|
||||
return 404, and unexpected filesystem failures return 500.
|
||||
"""
|
||||
async def clear_thumbnails(self, request):
|
||||
"""Safely clear only our generated thumbnails, never touch original images."""
|
||||
try:
|
||||
output_dirs = self._get_all_output_dirs()
|
||||
if not output_dirs:
|
||||
|
||||
# Find ComfyUI output directory
|
||||
output_dir = self._find_comfyui_output_dir()
|
||||
if not output_dir:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "ComfyUI output directory not found"},
|
||||
status=404,
|
||||
)
|
||||
cleared_count, cleared_size = await self._run_in_executor(
|
||||
self._clear_gallery_thumbnails_sync, output_dirs
|
||||
)
|
||||
self.invalidate_gallery_cache()
|
||||
|
||||
def format_size(bytes_size: int) -> str:
|
||||
size = float(bytes_size)
|
||||
output_path = Path(output_dir)
|
||||
thumbnails_dir = output_path / "thumbnails"
|
||||
|
||||
if not thumbnails_dir.exists():
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"message": "No thumbnails directory found - nothing to clear",
|
||||
"cleared_files": 0,
|
||||
}
|
||||
)
|
||||
|
||||
# Verify this is actually our thumbnails directory: a real child
|
||||
# of the output root (symlinks resolved), not merely a path that
|
||||
# shares its prefix such as "<root>2/thumbnails".
|
||||
try:
|
||||
if (
|
||||
not _is_within(thumbnails_dir, output_path)
|
||||
or thumbnails_dir.name != "thumbnails"
|
||||
):
|
||||
self.logger.error(
|
||||
"Safety check failed: thumbnails directory path invalid: "
|
||||
f"{thumbnails_dir}"
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Safety check failed: invalid thumbnails "
|
||||
"directory path",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Path validation failed: {e}")
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Path validation failed"}, status=500
|
||||
)
|
||||
|
||||
# Count files before deletion
|
||||
cleared_count = 0
|
||||
cleared_size = 0
|
||||
|
||||
for root, dirs, files in os.walk(thumbnails_dir):
|
||||
for file in files:
|
||||
file_path = Path(root) / file
|
||||
|
||||
if "_thumb" in file.lower() and any(
|
||||
file.lower().endswith(ext)
|
||||
for ext in [".png", ".jpg", ".jpeg", ".webp", ".gif"]
|
||||
):
|
||||
try:
|
||||
file_size = file_path.stat().st_size
|
||||
file_path.unlink()
|
||||
cleared_count += 1
|
||||
cleared_size += file_size
|
||||
self.logger.debug(f"Cleared thumbnail: {file_path}")
|
||||
except Exception as e:
|
||||
self.logger.warning(
|
||||
f"Failed to delete thumbnail {file_path}: {e}"
|
||||
)
|
||||
|
||||
# Remove empty directories within thumbnails folder
|
||||
try:
|
||||
for root, dirs, files in os.walk(thumbnails_dir, topdown=False):
|
||||
if root != str(thumbnails_dir):
|
||||
try:
|
||||
Path(root).rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Directory cleanup info: {e}")
|
||||
|
||||
def format_size(bytes_size):
|
||||
for unit in ["B", "KB", "MB", "GB"]:
|
||||
if size < 1024.0:
|
||||
return f"{size:.1f} {unit}"
|
||||
size /= 1024.0
|
||||
return f"{size:.1f} TB"
|
||||
if bytes_size < 1024.0:
|
||||
return f"{bytes_size:.1f} {unit}"
|
||||
bytes_size /= 1024.0
|
||||
return f"{bytes_size:.1f} TB"
|
||||
|
||||
size_formatted = format_size(cleared_size)
|
||||
self.logger.info(
|
||||
f"Thumbnail cleanup: cleared {cleared_count} files ({size_formatted})"
|
||||
f"Thumbnail cleanup: cleared {cleared_count} files "
|
||||
f"({format_size(cleared_size)})"
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"cleared_files": cleared_count,
|
||||
"cleared_size": cleared_size,
|
||||
"cleared_size_formatted": size_formatted,
|
||||
"cleared_size_formatted": format_size(cleared_size),
|
||||
"message": f"Cleared {cleared_count} thumbnail files "
|
||||
f"({size_formatted})",
|
||||
"({format_size(cleared_size)})",
|
||||
}
|
||||
)
|
||||
except ValueError as exc:
|
||||
self.logger.error(f"Thumbnail path validation failed: {exc}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Clear thumbnails error: {e}")
|
||||
return web.json_response(
|
||||
{"success": False, "error": _safe_error(exc)}, status=400
|
||||
{"success": False, "error": _safe_error(e)}, status=500
|
||||
)
|
||||
except Exception as exc:
|
||||
self.logger.error(f"Clear thumbnails error: {exc}")
|
||||
return web.json_response(
|
||||
{"success": False, "error": _safe_error(exc)}, status=500
|
||||
)
|
||||
|
||||
def _clear_gallery_thumbnails_sync(
|
||||
self, output_dirs: list[Path]
|
||||
) -> tuple[int, int]:
|
||||
"""Validate all thumbnail directories before deleting generated files.
|
||||
|
||||
Raises ValueError for symlinked or out-of-root thumbnail directories.
|
||||
Filesystem errors during path validation propagate to the route.
|
||||
"""
|
||||
directories = []
|
||||
for output_path in output_dirs:
|
||||
output_path = Path(output_path)
|
||||
thumbnails_dir = output_path / "thumbnails"
|
||||
if not thumbnails_dir.exists() and not thumbnails_dir.is_symlink():
|
||||
continue
|
||||
if (
|
||||
thumbnails_dir.is_symlink()
|
||||
or not _is_within(thumbnails_dir, output_path)
|
||||
or _canonical(thumbnails_dir) == _canonical(output_path)
|
||||
):
|
||||
raise ValueError(
|
||||
"Safety check failed: invalid thumbnails directory path"
|
||||
)
|
||||
directories.append(thumbnails_dir)
|
||||
|
||||
cleared_count = 0
|
||||
cleared_size = 0
|
||||
for thumbnails_dir in directories:
|
||||
for root, _dirs, files in os.walk(thumbnails_dir):
|
||||
for name in files:
|
||||
path = Path(root) / name
|
||||
if (
|
||||
"_thumb" not in name.lower()
|
||||
or path.suffix.lower() not in GALLERY_IMAGE_EXTENSIONS
|
||||
or path.is_symlink()
|
||||
):
|
||||
continue
|
||||
try:
|
||||
size = path.stat().st_size
|
||||
path.unlink()
|
||||
cleared_count += 1
|
||||
cleared_size += size
|
||||
except OSError as exc:
|
||||
self.logger.warning(f"Failed to delete thumbnail {path}: {exc}")
|
||||
for root, _dirs, _files in os.walk(thumbnails_dir, topdown=False):
|
||||
if Path(root) != thumbnails_dir:
|
||||
try:
|
||||
Path(root).rmdir()
|
||||
except OSError as exc:
|
||||
self.logger.debug(
|
||||
"Keeping thumbnail directory %s: %s", root, exc
|
||||
)
|
||||
return cleared_count, cleared_size
|
||||
|
||||
@staticmethod
|
||||
def _resolve_client_media_path(raw_path, allowed_dirs):
|
||||
@@ -1437,7 +1389,7 @@ class ImageRoutesMixin:
|
||||
try:
|
||||
# Get the image path from URL
|
||||
raw_image_path = request.match_info.get("image_path", "")
|
||||
image_path = raw_image_path
|
||||
image_path = urllib.parse.unquote(raw_image_path)
|
||||
|
||||
if not image_path:
|
||||
return web.json_response(
|
||||
@@ -1445,26 +1397,15 @@ class ImageRoutesMixin:
|
||||
)
|
||||
|
||||
# Convert relative path to absolute if needed
|
||||
if "root" in request.query:
|
||||
roots = self._select_root(
|
||||
self._get_all_output_dirs(), request.query["root"]
|
||||
)
|
||||
if not roots:
|
||||
return _forbidden()
|
||||
if not os.path.isabs(image_path):
|
||||
image_path = str(Path(roots[0]) / image_path)
|
||||
if not _is_within(image_path, roots[0]):
|
||||
return _forbidden()
|
||||
elif not os.path.isabs(image_path):
|
||||
if not os.path.isabs(image_path):
|
||||
output_dir = self._find_comfyui_output_dir()
|
||||
if output_dir:
|
||||
image_path = str(Path(output_dir) / image_path)
|
||||
|
||||
# Look up the image in generated_images table
|
||||
try:
|
||||
args = (image_path, False) if "root" in request.query else (image_path,)
|
||||
prompt_data = await self._run_in_executor(
|
||||
self.db.get_image_prompt_info, *args
|
||||
self.db.get_image_prompt_info, image_path
|
||||
)
|
||||
if prompt_data:
|
||||
prompt_data["image_path"] = _public_path(image_path)
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
|
||||
from aiohttp import web
|
||||
from urllib.parse import quote
|
||||
@@ -840,11 +839,6 @@ class PromptRoutesMixin:
|
||||
{"success": False, "error": "Prompt not found"}, status=404
|
||||
)
|
||||
|
||||
except sqlite3.IntegrityError:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Another prompt already has this text"},
|
||||
status=409,
|
||||
)
|
||||
except ValueError:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Invalid prompt ID"}, status=400
|
||||
|
||||
+2
-2
@@ -1,13 +1,13 @@
|
||||
[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.3.1"
|
||||
version = "3.3.0"
|
||||
license = {file = "LICENSE"}
|
||||
requires-python = ">=3.10"
|
||||
dependencies = ["watchdog>=2.1.0", "Pillow>=8.0.0"]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=84.0.0"]
|
||||
requires = ["setuptools>=61"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project.urls]
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
# Runtime dependencies
|
||||
watchdog>=6.0.0
|
||||
Pillow>=12.3.0
|
||||
aiohttp>=3.14.5
|
||||
watchdog>=2.1.0
|
||||
Pillow>=8.0.0
|
||||
aiohttp>=3.8.0
|
||||
|
||||
# Tests and coverage
|
||||
coverage>=7.16.2
|
||||
numpy>=2.2.6 # WD14 engine tests
|
||||
coverage>=7.0
|
||||
numpy>=1.24 # WD14 engine tests
|
||||
|
||||
# Code quality
|
||||
black>=26.10.1
|
||||
flake8>=7.4.1
|
||||
black>=24.0.0
|
||||
flake8>=7.0.0
|
||||
|
||||
# Security scanning
|
||||
bandit[toml]>=1.9.4
|
||||
bandit[toml]>=1.7.0
|
||||
|
||||
# Pre-commit hooks
|
||||
pre-commit>=4.6.2
|
||||
pre-commit>=4.0.0
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""Browser workflow fixtures; never imported by production code."""
|
||||
@@ -1,150 +0,0 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
|
||||
test.beforeEach(async ({page, browserName}) => {
|
||||
await startCoverage(page, browserName);
|
||||
page.classicErrors = [];
|
||||
page.on('pageerror', error => page.classicErrors.push(error.message));
|
||||
await page.route('**/scripts/app.js', route => route.fulfill({contentType:'text/javascript', body:'export const app = window.fixtureApp;'}));
|
||||
await page.route('**/scripts/api.js', route => route.fulfill({contentType:'text/javascript', body:'export const api = {fileURL: path => path, queuePrompt: async (...args) => args};'}));
|
||||
await page.route('**/extensions/promptmanager/prompt_manager.js', route => route.fulfill({contentType:'text/javascript', body:readFileSync(new URL('../../web/comfy/prompt_manager.js', import.meta.url),'utf8')}));
|
||||
await page.goto('/prompt_manager/test');
|
||||
await page.evaluate(() => {
|
||||
document.body.replaceChildren();
|
||||
document.body.style.setProperty('--bg-color','#222');
|
||||
window.fixtureApp={extensions:[],registerExtension(extension){this.extensions.push(extension);},ui:{settings:new EventTarget()},canvas:{},graph:{setDirtyCanvas(){}}};
|
||||
});
|
||||
await page.evaluate(async () => {
|
||||
await import('/extensions/promptmanager/prompt_manager.js');
|
||||
class Node {
|
||||
constructor(){
|
||||
this.widgets=['text','category','tags','search_text'].map(name=>({name,value:'',inputEl:document.createElement('textarea')}));
|
||||
this.widgets_values=[]; this.inputs=[];
|
||||
}
|
||||
addDOMWidget(name,type,element){document.body.append(element);return {element};}
|
||||
setSize(){} setDirtyCanvas(){} serialize(){return {properties:{...this.properties}};}
|
||||
}
|
||||
await fixtureApp.extensions[0].beforeRegisterNodeDef(Node,{name:'PromptManager'},fixtureApp);
|
||||
window.node=new Node();node.onNodeCreated();
|
||||
});
|
||||
await expect(page.locator('.pm-classic')).toBeVisible();
|
||||
});
|
||||
|
||||
test.afterEach(async ({page,browserName}) => {
|
||||
await saveCoverage(page,browserName);
|
||||
expect(page.classicErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test('Classic keeps immediate selection, search query and metadata serialization',async({page})=>{
|
||||
await page.route('**/prompt_manager/search?**', route=>route.fulfill({json:{success:true,results:[{text:'A new portrait',category:'Portrait',tags:['film grain'],rating:5}]}}));
|
||||
await page.evaluate(()=>{node.widgets.find(w=>w.name==='search_text').value='tag:"film grain" portrait';});
|
||||
const response=page.waitForRequest('**/prompt_manager/search?**');
|
||||
await page.getByRole('button',{name:'🔍 Search',exact:true}).click();
|
||||
expect(new URL((await response).url()).searchParams.get('tags')).toBe('film grain');
|
||||
const result=page.getByRole('button',{name:/A new portrait/});
|
||||
await result.focus();await result.press('Enter');
|
||||
expect(await page.evaluate(()=>node.serialize().widgets_values.slice(0,3))).toEqual(['A new portrait','Portrait','film grain']);
|
||||
await expect(result).toHaveAttribute('aria-pressed','true');
|
||||
const released=await page.evaluate(()=>{
|
||||
const previous=node.resultsSection.querySelector('button');
|
||||
node.displayResults([{text:'Replacement',tags:[]}]);
|
||||
previous.click();
|
||||
return node.widgets.find(widget=>widget.name==='text').value;
|
||||
});
|
||||
expect(released).toBe('A new portrait');
|
||||
await expect(page.getByRole('button',{name:'🌐 Web UI',exact:true})).toBeVisible();
|
||||
});
|
||||
|
||||
test('Classic detaches native text synchronization on removal',async({page})=>{
|
||||
await page.waitForTimeout(60);
|
||||
const values=await page.evaluate(()=>{
|
||||
const widget=node.widgets.find(item=>item.name==='text');
|
||||
widget.inputEl.value='Before removal';widget.inputEl.dispatchEvent(new Event('input'));
|
||||
const before=widget.value;node.onRemoved();
|
||||
widget.inputEl.value='After removal';widget.inputEl.dispatchEvent(new Event('input'));
|
||||
return [before,widget.value];
|
||||
});
|
||||
expect(values).toEqual(['Before removal','Before removal']);
|
||||
});
|
||||
|
||||
test('Classic follows host themes, keeps restore behavior and disposes resources',async({page})=>{
|
||||
const panel=page.locator('.pm-classic');
|
||||
await expect(panel).toHaveAttribute('data-ca-theme','dark');
|
||||
await page.evaluate(()=>document.body.style.setProperty('--bg-color','#fff'));
|
||||
await expect(panel).toHaveAttribute('data-ca-theme','light');
|
||||
await page.evaluate(()=>{node.properties.resultTimeout=0;node.displayResults([{text:'Example',tags:[]}]);node.hideResults();});
|
||||
await page.getByRole('button',{name:/Results auto-hidden/}).click();
|
||||
await expect(page.getByRole('button',{name:/Example/})).toBeVisible();
|
||||
const state=await page.evaluate(()=>{
|
||||
node.showNotification('Failure','error');node.onRemoved();
|
||||
const disposed=node._classicScope.signal.aborted;
|
||||
node.showNotification('Late response','info');
|
||||
return {disposed,notifications:document.querySelectorAll('.pm-classic-notification').length,style:node._styleSheet.isConnected};
|
||||
});
|
||||
expect(state).toEqual({disposed:true,notifications:0,style:false});
|
||||
await page.evaluate(()=>document.body.style.setProperty('--bg-color','#222'));
|
||||
await expect(panel).toHaveAttribute('data-ca-theme','light');
|
||||
});
|
||||
|
||||
test('Classic handles empty queries, empty results, failed requests and late responses',async({page})=>{
|
||||
await page.getByRole('button',{name:'🔍 Search',exact:true}).click();
|
||||
await expect(page.locator('.pm-classic-results')).toContainText('Enter search text');
|
||||
await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:[]}}));
|
||||
await page.getByRole('button',{name:'📋 Recent',exact:true}).click();
|
||||
await expect(page.locator('.pm-classic-results')).toContainText('No prompts found');
|
||||
await page.evaluate(async()=>{
|
||||
node.callNodeMethod=async()=>{throw new Error('<img src=x onerror=alert(1)>');};
|
||||
await node.loadRecentPrompts();
|
||||
});
|
||||
await expect(page.locator('.pm-classic-results [role="alert"]')).toContainText('<img src=x');
|
||||
await expect(page.locator('.pm-classic-results img')).toHaveCount(0);
|
||||
const late=await page.evaluate(async()=>{
|
||||
let finish; node.callNodeMethod=()=>new Promise(resolve=>{finish=resolve;});
|
||||
const loading=node.loadRecentPrompts();node.onRemoved();finish({results:[{text:'Late'}]});await loading;
|
||||
return {notifications:node._classicNotifications.size,late:node.resultsSection.textContent.includes('Late')};
|
||||
});
|
||||
expect(late).toEqual({notifications:0,late:false});
|
||||
});
|
||||
|
||||
test('shipped shared factories keep keyboard behavior and theme isolation across mounts',async({page})=>{
|
||||
await page.evaluate(async()=>{
|
||||
const ui=await import('/prompt_manager/js/shared/toolkit/core.js');
|
||||
const scope=ui.createScope();const panel=document.createElement('div');panel.className='ca-tools';panel.id='shared-contract';
|
||||
window.shared={ui,scope,panel,events:[]};
|
||||
document.body.append(panel);
|
||||
ui.mountTheme(panel,null,scope);
|
||||
const save=ui.createButton('Save contract','ca-primary');scope.listen(save,'click',()=>shared.events.push('save'));
|
||||
panel.append(save,ui.createSlider('Size',{id:'contract-size',min:0,max:100,step:1,value:50,unit:'px'},scope,value=>shared.events.push(value)),ui.createSwitch('Enabled','','contract-enabled'),ui.createField('Order',ui.createSelect([['name','Name'],['date','Date']],''),'contract-order'),ui.createSegments('View',[['grid','Grid'],['list','List']],'contract-view',scope,value=>shared.events.push(value)));
|
||||
});
|
||||
await page.getByRole('button',{name:'Save contract',exact:true}).press('Enter');
|
||||
await page.getByRole('slider',{name:'Size'}).press('ArrowRight');
|
||||
await expect(page.locator('#shared-contract output')).toHaveText('51 px');
|
||||
await page.getByRole('switch',{name:'Enabled'}).uncheck();
|
||||
await page.getByLabel('Order').selectOption('date');
|
||||
await page.getByRole('radio',{name:'List',exact:true}).check();
|
||||
expect(await page.evaluate(()=>shared.events)).toEqual(['save',51,'list']);
|
||||
await page.evaluate(()=>{document.body.style.removeProperty('--bg-color');fixtureApp.ui.settings.dispatchEvent(new Event('Comfy.ColorPalette.change'));});
|
||||
await page.emulateMedia({colorScheme:'light'});
|
||||
await expect(page.locator('#shared-contract')).toHaveAttribute('data-ca-theme','light');
|
||||
await page.emulateMedia({colorScheme:'dark'});
|
||||
await expect(page.locator('#shared-contract')).toHaveAttribute('data-ca-theme','dark');
|
||||
await page.evaluate(()=>{shared.scope.dispose();document.body.style.setProperty('--bg-color','#fff');});
|
||||
await expect(page.locator('#shared-contract')).toHaveAttribute('data-ca-theme','dark');
|
||||
await expect(page.locator('.pm-classic')).toHaveAttribute('data-ca-theme','light');
|
||||
});
|
||||
|
||||
test('Classic test button reports success and failure and releases expired notifications',async({page})=>{
|
||||
await page.clock.install();
|
||||
await page.evaluate(()=>{node.properties.showTestButton=true;node.createSearchUI(node._classicContainer);});
|
||||
await page.getByRole('button',{name:'🔧 Test API',exact:true}).click();
|
||||
await expect(page.locator('.pm-classic-results')).toContainText('API Connection Successful');
|
||||
await page.route('**/prompt_manager/test',route=>route.fulfill({status:503,body:'unavailable'}));
|
||||
await page.getByRole('button',{name:'🔧 Test API',exact:true}).click();
|
||||
await expect(page.locator('.pm-classic-results [role="alert"]')).toContainText('503');
|
||||
await page.route('**/prompt_manager/test',route=>route.fulfill({json:{success:false,message:'Not ready'}}));
|
||||
await page.getByRole('button',{name:'🔧 Test API',exact:true}).click();
|
||||
await expect(page.locator('.pm-classic-results [role="alert"]')).toContainText('Not ready');
|
||||
await page.clock.fastForward(3001);
|
||||
await expect(page.locator('.pm-classic-notification')).toHaveCount(0);
|
||||
});
|
||||
@@ -1,154 +0,0 @@
|
||||
"""Serve production pages and API routes with disposable state for browser tests."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import types
|
||||
|
||||
from aiohttp import web
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
|
||||
class TestModel:
|
||||
default_prompt = "Describe the image"
|
||||
wd14_general_threshold = 0.35
|
||||
wd14_character_threshold = 0.85
|
||||
custom_prompt = ""
|
||||
loaded = None
|
||||
|
||||
def get_models_status(self) -> dict:
|
||||
return {
|
||||
name: {"downloaded": True, "loaded": self.loaded == name}
|
||||
for name in ("gguf", "wd14-vit", "wd14-convnext", "wd14-swinv2")
|
||||
}
|
||||
|
||||
def is_model_loaded(self) -> bool:
|
||||
return self.loaded is not None
|
||||
|
||||
def get_loaded_model_type(self) -> str | None:
|
||||
return self.loaded
|
||||
|
||||
def load_model(self, model_type: str, use_gpu: bool = True) -> bool:
|
||||
self.loaded = model_type
|
||||
return True
|
||||
|
||||
def unload_model(self) -> None:
|
||||
self.loaded = None
|
||||
|
||||
def generate_tags(self, image_path: str, **kwargs: object) -> list[str]:
|
||||
return ["generated", Path(image_path).parent.parent.name]
|
||||
|
||||
|
||||
def create_app(root: Path) -> web.Application:
|
||||
os.environ["PROMPT_MANAGER_CONFIG_PATH"] = str(root / "config.json")
|
||||
os.chdir(root)
|
||||
app = web.Application()
|
||||
routes = web.RouteTableDef()
|
||||
server = types.ModuleType("server")
|
||||
server.PromptServer = types.SimpleNamespace(
|
||||
instance=types.SimpleNamespace(app=app, routes=routes)
|
||||
)
|
||||
sys.modules["server"] = server
|
||||
folder_paths = types.ModuleType("folder_paths")
|
||||
folder_paths.base_path = str(root)
|
||||
folder_paths.models_dir = str(root / "models")
|
||||
for name in ("output", "input", "temp", "user"):
|
||||
directory = root / name
|
||||
directory.mkdir()
|
||||
setattr(folder_paths, f"get_{name}_directory", lambda path=directory: str(path))
|
||||
sys.modules["folder_paths"] = folder_paths
|
||||
|
||||
from py.config import GalleryConfig, PromptManagerConfig
|
||||
|
||||
PromptManagerConfig.DEFAULT_DB_PATH = str(root / "browser.db")
|
||||
GalleryConfig.MONITORING_DIRECTORIES = [str(root / "output"), str(root / "input")]
|
||||
|
||||
from py.api import PromptManagerAPI
|
||||
from py import autotag
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
model = TestModel()
|
||||
autotag.get_autotag_service = lambda: model
|
||||
api = PromptManagerAPI()
|
||||
api.add_routes(routes)
|
||||
|
||||
def seed(extra_images: int = 0) -> None:
|
||||
with api.db.model.get_connection() as conn:
|
||||
for table in ("generated_images", "prompt_tags", "tags", "prompts"):
|
||||
conn.execute(f"DELETE FROM {table}")
|
||||
for name in ("output", "input"):
|
||||
shutil.rmtree(root / name)
|
||||
(root / name).mkdir()
|
||||
for index, (directory, text, tag) in enumerate(
|
||||
[("output", "Cat portrait", "cats"), ("input", "Dog landscape", "dogs")]
|
||||
):
|
||||
image_path = root / directory / "shared" / "same.png"
|
||||
image_path.parent.mkdir(exist_ok=True)
|
||||
info = PngInfo()
|
||||
info.add_text(
|
||||
"prompt",
|
||||
json.dumps(
|
||||
{"1": {"class_type": "CLIPTextEncode", "inputs": {"text": text}}}
|
||||
),
|
||||
)
|
||||
with Image.new("RGB", (64, 64), (index * 255, 64, 128)) as image:
|
||||
image.save(image_path, pnginfo=info)
|
||||
stamp = time.time() - index * 10
|
||||
os.utime(image_path, (stamp, stamp))
|
||||
pid = api.db.save_prompt(
|
||||
text, tags=[tag], prompt_hash=generate_prompt_hash(text)
|
||||
)
|
||||
api.db.link_image_to_prompt(pid, str(image_path))
|
||||
if index == 0:
|
||||
for number in range(extra_images):
|
||||
extra = root / directory / "bulk" / f"{number:04}.png"
|
||||
extra.parent.mkdir(exist_ok=True)
|
||||
shutil.copyfile(image_path, extra)
|
||||
api.db.link_image_to_prompt(pid, str(extra))
|
||||
api._generate_gallery_thumbnails_sync(api._get_all_output_dirs(), (150, 150))
|
||||
api.invalidate_gallery_cache()
|
||||
|
||||
@routes.post("/__test__/reset")
|
||||
async def reset(request: web.Request) -> web.Response:
|
||||
data = await request.json() if request.can_read_body else {}
|
||||
extra = data.get("extra_images", 0)
|
||||
if (
|
||||
isinstance(extra, bool)
|
||||
or not isinstance(extra, int)
|
||||
or not 0 <= extra <= 200
|
||||
):
|
||||
raise web.HTTPBadRequest()
|
||||
await api._run_in_executor(seed, extra)
|
||||
return web.json_response({"success": True})
|
||||
|
||||
@routes.get("/api/settings/Comfy.ColorPalette")
|
||||
async def palette(request: web.Request) -> web.Response:
|
||||
return web.json_response("dark")
|
||||
|
||||
async def cleanup(application: web.Application) -> None:
|
||||
api.db.close_all()
|
||||
|
||||
seed()
|
||||
app.on_cleanup.append(cleanup)
|
||||
app.add_routes(routes)
|
||||
return app
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--port", type=int, default=8765)
|
||||
args = parser.parse_args()
|
||||
with tempfile.TemporaryDirectory(prefix="pm-browser-") as directory:
|
||||
web.run_app(
|
||||
create_app(Path(directory).resolve()), host="127.0.0.1", port=args.port
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,344 +0,0 @@
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
|
||||
test.beforeEach(async ({ request, page, browserName }) => {
|
||||
await startCoverage(page, browserName);
|
||||
await expect(await request.post('/__test__/reset')).toBeOK();
|
||||
page.workflowErrors = [];
|
||||
page.on('pageerror', error => page.workflowErrors.push(error.message));
|
||||
page.on('response', response => {
|
||||
if (response.status() >= 500 && response.url().includes('/prompt_manager/')) {
|
||||
page.workflowErrors.push(`${response.status()} ${response.url()}`);
|
||||
}
|
||||
});
|
||||
await page.addInitScript(() => { localStorage.setItem('pm_update_notice_dismissed', '3.2.0'); });
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page, browserName }) => {
|
||||
await saveCoverage(page, browserName);
|
||||
expect(page.workflowErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test('actual gallery form saves a prompt that the admin page can edit and reload', async ({ page, request }) => {
|
||||
await page.goto('/prompt_manager/gallery');
|
||||
await page.locator('#addPromptBtn').click();
|
||||
await page.locator('#addPromptText').fill('Browser saved landscape');
|
||||
await page.locator('#addPromptCategory').fill('Browser tests');
|
||||
await page.locator('#addPromptTagInput').fill('browser-tag');
|
||||
await page.locator('#addPromptTagInput').press('Enter');
|
||||
await page.locator('#saveNewPromptBtn').click();
|
||||
await expect(page.locator('#addPromptModal')).toBeHidden();
|
||||
await page.goto('/prompt_manager/admin');
|
||||
const text = page.locator('.prompt-text').filter({ hasText: 'Browser saved landscape' });
|
||||
await expect(text).toBeVisible();
|
||||
const id = await text.getAttribute('data-id');
|
||||
await page.locator(`[data-action="edit"][data-prompt-id="${id}"]`).click();
|
||||
const editor = page.locator(`.prompt-text[data-id="${id}"]`);
|
||||
await editor.fill('Browser edited portrait');
|
||||
await editor.press('Enter');
|
||||
await expect(page.locator('.prompt-text').filter({ hasText: 'Browser edited portrait' })).toBeVisible();
|
||||
await page.reload();
|
||||
await expect(page.locator('.prompt-text').filter({ hasText: 'Browser edited portrait' })).toBeVisible();
|
||||
const saved = await request.post('/prompt_manager/save', { data: { text: 'Browser saved landscape' } });
|
||||
expect((await saved.json()).prompt_id).not.toBe(Number(id));
|
||||
});
|
||||
|
||||
test('actual gallery review generates and persists tags for the selected root', async ({ page, request }) => {
|
||||
await page.goto('/prompt_manager/gallery');
|
||||
await expect(page.locator('#galleryGrid .image-item')).toHaveCount(2);
|
||||
await page.locator('#autoTagBtn').click();
|
||||
const generated = page.waitForResponse(response => response.url().endsWith('/prompt_manager/autotag/single'));
|
||||
await page.locator('#startReviewBtn').click();
|
||||
const generation = await generated;
|
||||
expect(generation.request().postDataJSON().root_index).toBe(0);
|
||||
await expect(page.locator('#reviewTagsContainer')).toContainText('generated');
|
||||
const result = page.waitForResponse(response => response.url().endsWith('/prompt_manager/autotag/apply'));
|
||||
await page.locator('#applyReviewBtn').click();
|
||||
expect((await (await result).json()).success).toBe(true);
|
||||
const data = await (await request.get('/prompt_manager/recent?limit=20')).json();
|
||||
expect(data.results.find(prompt => prompt.text === 'Cat portrait').tags).toContain('generated');
|
||||
expect(data.results.find(prompt => prompt.text === 'Dog landscape').tags).not.toContain('generated');
|
||||
});
|
||||
|
||||
test('actual Tags page reloads the selected filter and opens its image viewer', async ({ page }) => {
|
||||
await page.goto('/prompt_manager/admin#/tags');
|
||||
await page.locator('[data-tag-name="cats"]').click();
|
||||
await expect(page.locator('#tagPromptsGrid')).toContainText('Cat portrait');
|
||||
await page.locator('[data-tag-name="dogs"]').click();
|
||||
await expect(page.locator('#tagPromptsGrid')).toContainText('Dog landscape');
|
||||
await expect(page.locator('#tagPromptsGrid')).not.toContainText('Cat portrait');
|
||||
await page.locator('#tagPromptsGrid img').first().click();
|
||||
await expect(page.locator('.viewer-canvas img')).toBeVisible();
|
||||
await page.locator('.viewer-container').getByRole('button', { name: 'Close', exact: true }).click();
|
||||
await expect(page.locator('.viewer-container')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('actual gallery serves colliding filenames and shows the matching prompt', async ({ page }) => {
|
||||
await page.goto('/prompt_manager/gallery');
|
||||
await expect(page.locator('#galleryGrid .image-item')).toHaveCount(2);
|
||||
const images = page.locator('#galleryGrid img[data-original]');
|
||||
const first = await images.nth(0).getAttribute('data-original');
|
||||
const second = await images.nth(1).getAttribute('data-original');
|
||||
expect(first).not.toBe(second);
|
||||
await page.locator('#galleryGrid .image-item[data-image-index="0"]').click();
|
||||
await expect(page.locator('#metadata-content')).toContainText('Cat portrait');
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect(page.locator('#metadata-content')).toContainText('Dog landscape');
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('#metadata-sidebar')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('an edit collision is rejected by the backend and the page restores the original text', async ({ page }) => {
|
||||
await page.goto('/prompt_manager/admin');
|
||||
const original = page.locator('.prompt-text').filter({ hasText: 'Cat portrait' });
|
||||
const id = await original.getAttribute('data-id');
|
||||
await page.locator(`[data-action="edit"][data-prompt-id="${id}"]`).click();
|
||||
const editor = page.locator(`.prompt-text[data-id="${id}"]`);
|
||||
await editor.fill('Dog landscape');
|
||||
const rejected = page.waitForResponse(response => response.request().method() === 'PUT' && response.url().endsWith(`/prompts/${id}`));
|
||||
await editor.press('Enter');
|
||||
expect((await rejected).status()).toBe(409);
|
||||
await expect(editor).toHaveText('Cat portrait');
|
||||
await page.reload();
|
||||
await expect(page.locator(`.prompt-text[data-id="${id}"]`)).toHaveText('Cat portrait');
|
||||
});
|
||||
|
||||
test('a delayed real Tags response cannot replace a newer selection', async ({ page }) => {
|
||||
let release;
|
||||
const delayed = new Promise(resolve => { release = resolve; });
|
||||
let received;
|
||||
const waiting = new Promise(resolve => { received = resolve; });
|
||||
await page.route('**/prompt_manager/tags/cats/prompts?*', async route => {
|
||||
const response = await route.fetch();
|
||||
received();
|
||||
await delayed;
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
await page.goto('/prompt_manager/admin#/tags');
|
||||
await page.locator('[data-tag-name="cats"]').click();
|
||||
await waiting;
|
||||
await page.locator('[data-tag-name="dogs"]').click();
|
||||
await expect(page.locator('#tagPromptsGrid')).toContainText('Dog landscape');
|
||||
const completed = page.waitForResponse(response => response.url().includes('/tags/cats/prompts?'));
|
||||
release();
|
||||
await completed;
|
||||
await expect(page.locator('#tagPromptsGrid')).not.toContainText('Cat portrait');
|
||||
});
|
||||
|
||||
test('gallery infinite scroll appends a real backend page while retaining existing cards', async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/reset', { data: { extra_images: 108 } })).toBeOK();
|
||||
await page.goto('/prompt_manager/gallery');
|
||||
await expect(page.locator('#galleryGrid .image-item')).toHaveCount(100);
|
||||
await page.locator('#settingsBtn').click();
|
||||
await page.locator('#infiniteScrollToggle').check();
|
||||
await page.locator('#saveSettingsBtn').click();
|
||||
await expect(page.locator('#settingsModal')).toBeHidden();
|
||||
const first = page.locator('#galleryGrid .image-item').first();
|
||||
await first.evaluate(element => { element.dataset.preserved = 'yes'; });
|
||||
await page.locator('#galleryScroll').evaluate(element => { element.scrollTop = element.scrollHeight; });
|
||||
await expect(page.locator('#galleryGrid .image-item')).toHaveCount(110);
|
||||
await expect(first).toHaveAttribute('data-preserved', 'yes');
|
||||
});
|
||||
|
||||
async function promptSnapshot(request) {
|
||||
const response = await request.get('/prompt_manager/recent?limit=100');
|
||||
await expect(response).toBeOK();
|
||||
return (await response.json()).results.map(({ id, text, tags, images }) => ({
|
||||
id, text, tags: [...tags].sort(),
|
||||
images: images.map(image => image.image_path).sort(),
|
||||
})).sort((a, b) => a.id - b.id);
|
||||
}
|
||||
|
||||
async function downloadBackup(page) {
|
||||
const downloading = page.waitForEvent('download');
|
||||
await page.locator('#backupBtn').click();
|
||||
const download = await downloading;
|
||||
expect(download.suggestedFilename()).toMatch(/^prompts_backup_.*\.db$/);
|
||||
expect(await download.failure()).toBeNull();
|
||||
const chunks = [];
|
||||
for await (const chunk of await download.createReadStream()) chunks.push(chunk);
|
||||
const buffer = Buffer.concat(chunks);
|
||||
expect(buffer.subarray(0, 16).toString()).toBe('SQLite format 3\0');
|
||||
return { name: download.suggestedFilename(), mimeType: 'application/octet-stream', buffer };
|
||||
}
|
||||
|
||||
async function chooseRestore(page, file) {
|
||||
await page.locator('#restoreBtn').click();
|
||||
await expect(page.locator('#restoreModal')).toBeVisible();
|
||||
await page.locator('#restoreFileInput').setInputFiles(file);
|
||||
}
|
||||
|
||||
function confirmNextDialog(page, accept, message) {
|
||||
page.once('dialog', async dialog => {
|
||||
expect(dialog.type()).toBe('confirm');
|
||||
expect(dialog.message()).toContain(message);
|
||||
await (accept ? dialog.accept() : dialog.dismiss());
|
||||
});
|
||||
}
|
||||
|
||||
async function deletePromptButton(page, text) {
|
||||
const prompt = page.locator('.prompt-text').filter({ hasText: text });
|
||||
await expect(prompt).toBeVisible();
|
||||
const id = await prompt.getAttribute('data-id');
|
||||
return page.locator(`[data-action="delete"][data-prompt-id="${id}"]`);
|
||||
}
|
||||
|
||||
test('downloaded backup restores deleted prompts, tags, and image links through the actual upload form', async ({ page, request }) => {
|
||||
await page.goto('/prompt_manager/admin');
|
||||
await expect(page.locator('.prompt-text')).toHaveCount(2);
|
||||
const before = await promptSnapshot(request);
|
||||
expect(before.map(prompt => prompt.tags)).toEqual([['cats'], ['dogs']]);
|
||||
expect(before.every(prompt => prompt.images.length === 1)).toBe(true);
|
||||
const backup = await downloadBackup(page);
|
||||
const button = await deletePromptButton(page, 'Cat portrait');
|
||||
confirmNextDialog(page, true, 'delete this prompt');
|
||||
await button.click();
|
||||
await expect(page.locator('.prompt-text')).toHaveCount(1);
|
||||
await expect(await request.post('/prompt_manager/save', { data: { text: 'Added after backup', tags: ['temporary'] } })).toBeOK();
|
||||
expect(await promptSnapshot(request)).not.toEqual(before);
|
||||
await chooseRestore(page, backup);
|
||||
confirmNextDialog(page, true, backup.name);
|
||||
const restored = page.waitForResponse(response => response.url().endsWith('/prompt_manager/restore'));
|
||||
await page.locator('#confirmRestore').click();
|
||||
const response = await restored;
|
||||
expect(response.ok()).toBe(true);
|
||||
expect(await response.json()).toMatchObject({ success: true, prompt_count: 2, backup_created: expect.any(String) });
|
||||
await expect(page.locator('#restoreModal')).toBeHidden();
|
||||
await expect(page.locator('.prompt-text')).toHaveCount(2);
|
||||
await page.reload();
|
||||
await expect(page.locator('.prompt-text').filter({ hasText: 'Cat portrait' })).toBeVisible();
|
||||
await expect(page.locator('.prompt-text').filter({ hasText: 'Added after backup' })).toHaveCount(0);
|
||||
expect(await promptSnapshot(request)).toEqual(before);
|
||||
await page.goto('/prompt_manager/admin#/tags');
|
||||
await page.locator('[data-tag-name="cats"]').click();
|
||||
await expect(page.locator('#tagPromptsGrid')).toContainText('Cat portrait');
|
||||
await page.locator('#tagPromptsGrid img').first().click();
|
||||
await expect(page.locator('.viewer-canvas img')).toBeVisible();
|
||||
await expect.poll(() => page.locator('.viewer-canvas img').evaluate(image => image.complete && image.naturalWidth > 0)).toBe(true);
|
||||
});
|
||||
|
||||
test('restore validation and cancellation preserve live data and allow a valid retry', async ({ page, request }) => {
|
||||
await page.goto('/prompt_manager/admin');
|
||||
const before = await promptSnapshot(request);
|
||||
const backup = await downloadBackup(page);
|
||||
let uploads = 0;
|
||||
page.on('request', request => { if (request.url().endsWith('/prompt_manager/restore')) uploads++; });
|
||||
await page.locator('#restoreBtn').click();
|
||||
await page.locator('#confirmRestore').click();
|
||||
await expect(page.getByText('Please select a database file to restore', { exact: true })).toBeVisible();
|
||||
await page.locator('#restoreFileInput').setInputFiles({ ...backup, name: 'backup.txt' });
|
||||
await page.locator('#confirmRestore').click();
|
||||
await expect(page.getByText('Please select a valid .db file', { exact: true })).toBeVisible();
|
||||
expect(uploads).toBe(0);
|
||||
await page.locator('#restoreFileInput').setInputFiles(backup);
|
||||
confirmNextDialog(page, false, 'replace your current database');
|
||||
await page.locator('#confirmRestore').click();
|
||||
await expect(page.locator('#restoreModal')).toBeVisible();
|
||||
expect(uploads).toBe(0);
|
||||
await page.locator('#restoreFileInput').setInputFiles({ name: 'corrupt.db', mimeType: 'application/octet-stream', buffer: Buffer.from('not a SQLite database') });
|
||||
confirmNextDialog(page, true, 'corrupt.db');
|
||||
const rejected = page.waitForResponse(response => response.url().endsWith('/prompt_manager/restore'));
|
||||
await page.locator('#confirmRestore').click();
|
||||
expect((await rejected).status()).toBe(400);
|
||||
await expect(page.getByText(/Restore failed:/)).toBeVisible();
|
||||
await expect(page.locator('#restoreModal')).toBeVisible();
|
||||
await expect(page.locator('#confirmRestore')).toBeEnabled();
|
||||
expect(await promptSnapshot(request)).toEqual(before);
|
||||
await page.locator('#restoreFileInput').setInputFiles(backup);
|
||||
confirmNextDialog(page, true, backup.name);
|
||||
await page.locator('#confirmRestore').click();
|
||||
await expect(page.locator('#restoreModal')).toBeHidden();
|
||||
expect(await promptSnapshot(request)).toEqual(before);
|
||||
});
|
||||
|
||||
test('backup and restore recover from interrupted network requests without losing data', async ({ page, request }) => {
|
||||
await page.goto('/prompt_manager/admin');
|
||||
const before = await promptSnapshot(request);
|
||||
await page.route('**/prompt_manager/backup', route => route.abort('failed'), { times: 1 });
|
||||
await page.locator('#backupBtn').click();
|
||||
await expect(page.getByText(/Backup failed:/)).toBeVisible();
|
||||
const backup = await downloadBackup(page);
|
||||
await chooseRestore(page, backup);
|
||||
await page.route('**/prompt_manager/restore', route => route.abort('failed'), { times: 1 });
|
||||
confirmNextDialog(page, true, backup.name);
|
||||
await page.locator('#confirmRestore').click();
|
||||
await expect(page.getByText(/Restore failed:/)).toBeVisible();
|
||||
await expect(page.locator('#confirmRestore')).toBeEnabled();
|
||||
await expect(page.locator('#restoreModal')).toBeVisible();
|
||||
expect(await promptSnapshot(request)).toEqual(before);
|
||||
confirmNextDialog(page, true, backup.name);
|
||||
await page.locator('#confirmRestore').click();
|
||||
await expect(page.locator('#restoreModal')).toBeHidden();
|
||||
expect(await promptSnapshot(request)).toEqual(before);
|
||||
});
|
||||
|
||||
test('prompt deletion requires confirmation and can retry after a network failure', async ({ page, request }) => {
|
||||
await page.goto('/prompt_manager/admin');
|
||||
const before = await promptSnapshot(request);
|
||||
const button = await deletePromptButton(page, 'Cat portrait');
|
||||
let deletes = 0;
|
||||
page.on('request', request => { if (request.method() === 'DELETE') deletes++; });
|
||||
confirmNextDialog(page, false, 'delete this prompt');
|
||||
await button.click();
|
||||
expect(deletes).toBe(0);
|
||||
expect(await promptSnapshot(request)).toEqual(before);
|
||||
await page.route('**/prompt_manager/delete/*', route => route.abort('failed'), { times: 1 });
|
||||
confirmNextDialog(page, true, 'delete this prompt');
|
||||
await button.click();
|
||||
await expect(page.getByText('Failed to delete prompt', { exact: true })).toBeVisible();
|
||||
expect(await promptSnapshot(request)).toEqual(before);
|
||||
confirmNextDialog(page, true, 'delete this prompt');
|
||||
await button.click();
|
||||
await expect(page.locator('.prompt-text')).toHaveCount(1);
|
||||
await page.reload();
|
||||
await expect(page.locator('.prompt-text')).toHaveCount(1);
|
||||
expect(await promptSnapshot(request)).toEqual(before.filter(prompt => prompt.text !== 'Cat portrait'));
|
||||
await page.goto('/prompt_manager/gallery');
|
||||
await expect(page.locator('#galleryGrid .image-item')).toHaveCount(2);
|
||||
for (const url of await page.locator('#galleryGrid img[data-original]').evaluateAll(images => images.map(image => image.dataset.original))) {
|
||||
await expect(await request.get(url)).toBeOK();
|
||||
}
|
||||
});
|
||||
|
||||
test('duplicate cleanup cancels safely and deletes only the selected file after a retry', async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/reset', { data: { extra_images: 2 } })).toBeOK();
|
||||
await page.goto('/prompt_manager/gallery');
|
||||
await expect(page.locator('#galleryGrid .image-item')).toHaveCount(4);
|
||||
const originals = await page.locator('#galleryGrid img[data-original]').evaluateAll(images => images.map(image => image.dataset.original));
|
||||
await page.locator('#settingsBtn').click();
|
||||
await page.locator('#scanDuplicatesBtn').click();
|
||||
await expect(page.locator('#duplicatesCount')).toHaveText('1');
|
||||
const candidates = page.locator('.duplicate-checkbox');
|
||||
await expect(candidates).toHaveCount(2);
|
||||
const selected = await candidates.first().getAttribute('data-image-path');
|
||||
const retained = await candidates.last().getAttribute('data-image-path');
|
||||
const serve = path => `/prompt_manager/images/serve/${path.split(/[\\/]/).map(encodeURIComponent).join('/')}`;
|
||||
await candidates.first().check();
|
||||
let deletes = 0;
|
||||
page.on('request', request => { if (request.url().endsWith('/delete_duplicate_images')) deletes++; });
|
||||
confirmNextDialog(page, false, 'permanently delete 1 duplicate image files');
|
||||
await page.locator('#removeDuplicatesBtn').click();
|
||||
expect(deletes).toBe(0);
|
||||
await expect(await request.get(serve(selected))).toBeOK();
|
||||
await page.route('**/prompt_manager/delete_duplicate_images', route => route.abort('failed'), { times: 1 });
|
||||
confirmNextDialog(page, true, 'permanently delete 1 duplicate image files');
|
||||
await page.locator('#removeDuplicatesBtn').click();
|
||||
await expect(page.getByText('Failed to delete duplicate images', { exact: true })).toBeVisible();
|
||||
await expect(page.locator('#removeDuplicatesBtn')).toBeEnabled();
|
||||
await expect(await request.get(serve(selected))).toBeOK();
|
||||
confirmNextDialog(page, true, 'permanently delete 1 duplicate image files');
|
||||
const deleted = page.waitForResponse(response => response.url().endsWith('/delete_duplicate_images'));
|
||||
await page.locator('#removeDuplicatesBtn').click();
|
||||
expect((await (await deleted).json())).toMatchObject({ success: true, deleted_count: 1, failed_count: 0 });
|
||||
await expect(page.locator('#duplicatesModal')).toBeHidden();
|
||||
expect((await request.get(serve(selected))).status()).toBe(404);
|
||||
await expect(await request.get(serve(retained))).toBeOK();
|
||||
await page.reload();
|
||||
await expect(page.locator('#galleryGrid .image-item')).toHaveCount(3);
|
||||
const remaining = await page.locator('#galleryGrid img[data-original]').evaluateAll(images => images.map(image => image.dataset.original));
|
||||
expect(remaining).toHaveLength(originals.length - 1);
|
||||
for (const url of remaining) {
|
||||
expect(originals).toContain(url);
|
||||
await expect(await request.get(url)).toBeOK();
|
||||
}
|
||||
});
|
||||
@@ -1,159 +0,0 @@
|
||||
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import runtime from '../../tools/vendor/comfyui-runtime.json' with { type: 'json' };
|
||||
|
||||
test.beforeEach(async ({ page, context, request }, testInfo) => {
|
||||
const response = await request.get('/system_stats');
|
||||
expect(response.ok()).toBe(true);
|
||||
const info = (await response.json()).system;
|
||||
expect(info.comfyui_version).toBe(runtime.serverVersion);
|
||||
expect(info.pytorch_version).toBe(`${runtime.torchVersion}+cpu`);
|
||||
expect(info.required_frontend_version).toBe(runtime.frontendVersion);
|
||||
expect(info.comfy_package_versions.find(item => item.name === 'comfyui-frontend-package')?.installed).toBe(runtime.frontendVersion);
|
||||
await testInfo.attach('host-versions.json', { body: JSON.stringify(info, null, 2), contentType: 'application/json' });
|
||||
page.hostErrors = [];
|
||||
page.on('pageerror', error => page.hostErrors.push(error.message));
|
||||
await context.route('**/*', async route => {
|
||||
const request = route.request();
|
||||
const path = new URL(request.url()).pathname.replace(/^\/api\//, '/');
|
||||
if (path === '/settings' && request.method() === 'GET') {
|
||||
const response = await route.fetch();
|
||||
return route.fulfill({ json: { ...await response.json(),
|
||||
'Comfy.VueNodes.Enabled': testInfo.project.metadata.initialRenderer,
|
||||
'Comfy.TutorialCompleted': true, 'Comfy.Workflow.Persist': false,
|
||||
'Comfy.Notification.ShowVersionUpdates': false } });
|
||||
}
|
||||
if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method()) && !path.startsWith('/prompt_manager/')) return route.fulfill({ json: {} });
|
||||
return route.continue();
|
||||
});
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.getByRole('status', { name: 'Loading ComfyUI' })).toBeHidden({ timeout: 30000 });
|
||||
await page.waitForFunction(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
return !!app.graph && ['PromptManager', 'PromptManagerText', 'PromptSearchList'].every(name => globalThis.LiteGraph?.registered_node_types?.[name]);
|
||||
});
|
||||
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(testInfo.project.metadata.initialRenderer);
|
||||
await page.evaluate(async () => { const { app } = await import('/scripts/app.js'); app.graph.clear(); });
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
expect(page.hostErrors).toEqual([]);
|
||||
});
|
||||
|
||||
for (const type of ['PromptManager', 'PromptManagerText']) {
|
||||
test(`${type} selects real database prompts and preserves text across renderers and reload`, async ({ page, request }, testInfo) => {
|
||||
const text = `Host test ${type} ${testInfo.project.name}`;
|
||||
const saved = await request.post('/prompt_manager/save', { data: { text, category: 'Host tests', tags: ['host-test'] } });
|
||||
expect(saved.ok()).toBe(true);
|
||||
expect((await saved.json()).success).toBe(true);
|
||||
const id = await page.evaluate(async type => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = LiteGraph.createNode(type);
|
||||
app.graph.add(node);
|
||||
node.setSize([650, 800]);
|
||||
app.canvas.centerOnNode(node);
|
||||
return node.id;
|
||||
}, type);
|
||||
const recent = page.getByRole('button', { name: '📋 Recent', exact: true });
|
||||
await expect(recent).toBeVisible();
|
||||
if (type === 'PromptManager') {
|
||||
await expect(page.locator('.pm-classic')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '🔍 Search', exact: true })).toHaveCSS('background-color', 'rgb(8, 119, 187)');
|
||||
}
|
||||
await recent.click();
|
||||
await page.getByText(text, { exact: true }).click();
|
||||
const state = () => page.evaluate(async id => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = app.graph.getNodeById(id);
|
||||
return Object.fromEntries(node.widgets.filter(widget => ['text', 'category', 'tags'].includes(widget.name)).map(widget => [widget.name, widget.value]));
|
||||
}, id);
|
||||
await expect.poll(state).toMatchObject({ text, category: 'Host tests', tags: 'host-test' });
|
||||
for (const renderer of [!testInfo.project.metadata.initialRenderer, testInfo.project.metadata.initialRenderer]) {
|
||||
await page.evaluate(async renderer => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);
|
||||
app.canvas.ds.scale = .8;
|
||||
app.canvas.centerOnNode(app.graph._nodes[0]);
|
||||
}, renderer);
|
||||
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
|
||||
await expect(recent).toHaveCount(1);
|
||||
await expect(recent).toBeVisible();
|
||||
expect(await state()).toMatchObject({ text });
|
||||
}
|
||||
const output = await page.evaluate(async id => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const prompt = await app.graphToPrompt();
|
||||
const workflow = app.graph.serialize();
|
||||
await app.loadGraphData(workflow);
|
||||
return prompt.output[String(id)].inputs;
|
||||
}, id);
|
||||
expect(output.text).toBe(text);
|
||||
await expect.poll(state).toMatchObject({ text, category: 'Host tests', tags: 'host-test' });
|
||||
await expect(recent).toHaveCount(1);
|
||||
await page.screenshot({ path: testInfo.outputPath(`${type}-reloaded.png`) });
|
||||
const removedStyle = await page.evaluate(async id => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = app.graph.getNodeById(id);
|
||||
const style = node._styleSheet;
|
||||
app.graph.remove(node);
|
||||
return !style.isConnected;
|
||||
}, id);
|
||||
expect(removedStyle).toBe(true);
|
||||
await expect(recent).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
|
||||
test('all node schemas and extension entrypoints load from the real host', async ({ page, request }) => {
|
||||
const definitions = await (await request.get('/object_info')).json();
|
||||
expect(definitions.PromptManager.display_name).toBe('Prompt Manager (Classic)');
|
||||
for (const name of ['PromptManager', 'PromptManagerText', 'PromptSearchList']) expect(definitions[name]?.name).toBe(name);
|
||||
const extensions = await (await request.get('/extensions')).json();
|
||||
expect(extensions.filter(path => path.startsWith('/extensions/promptmanager/'))).toEqual(['/extensions/promptmanager/prompt_manager.js']);
|
||||
const result = await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = LiteGraph.createNode('PromptSearchList');
|
||||
app.graph.add(node);
|
||||
const workflow = app.graph.serialize();
|
||||
await app.loadGraphData(workflow);
|
||||
return { type: app.graph._nodes[0].type, extension: app.extensions.some(item => item.name === 'PromptManager.UI') };
|
||||
});
|
||||
expect(result).toEqual({ type: 'PromptSearchList', extension: true });
|
||||
});
|
||||
|
||||
for (const type of ['PromptManager', 'PromptManagerText']) {
|
||||
test(`${type} Web UI button opens actual standalone pages`, async ({ page }) => {
|
||||
await page.evaluate(async type => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = LiteGraph.createNode(type);
|
||||
app.graph.add(node);
|
||||
app.canvas.centerOnNode(node);
|
||||
}, type);
|
||||
const popupPromise = page.waitForEvent('popup');
|
||||
await page.getByRole('button', { name: '🌐 Web UI', exact: true }).click();
|
||||
const popup = await popupPromise;
|
||||
await popup.waitForLoadState('domcontentloaded');
|
||||
await expect(popup).toHaveURL(/\/prompt_manager\/(web|admin)/);
|
||||
await expect(popup.locator('body')).toContainText('Prompt');
|
||||
await popup.goto(new URL('/prompt_manager/gallery', page.url()).href);
|
||||
await expect(popup.locator('#addPromptBtn')).toBeVisible();
|
||||
await popup.close();
|
||||
});
|
||||
}
|
||||
|
||||
test('Classic controls follow actual ComfyUI light and dark palettes', async ({ page }, testInfo) => {
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = LiteGraph.createNode('PromptManager');
|
||||
app.graph.add(node);
|
||||
node.setSize([650, 800]);
|
||||
app.canvas.centerOnNode(node);
|
||||
});
|
||||
for (const palette of ['light', 'dark']) {
|
||||
await page.evaluate(async palette => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
await app.ui.settings.setSettingValue('Comfy.ColorPalette', palette);
|
||||
}, palette);
|
||||
await expect(page.locator('.pm-classic')).toHaveAttribute('data-ca-theme', palette);
|
||||
await expect(page.getByRole('button', { name: '🔍 Search', exact: true })).toHaveCSS('background-color', 'rgb(8, 119, 187)');
|
||||
await page.screenshot({ path: testInfo.outputPath(`classic-${palette}.png`) });
|
||||
}
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
function fixture() {
|
||||
const panel = { innerHTML: '', isConnected: true };
|
||||
const requests = new Map();
|
||||
const context = vm.createContext({ document: { readyState: 'loading', addEventListener() {},
|
||||
getElementById: id => id === 'metadata-content' ? panel : null },
|
||||
window: {}, console: { error() {} },
|
||||
});
|
||||
const Admin = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/admin.js'), 'utf8') + '\nPromptAdmin;', context, { filename: path.join(__dirname, '../../web/js/admin.js') });
|
||||
const manager = Object.create(Admin.prototype);
|
||||
manager.extractImageMetadata = url => new Promise((resolve, reject) => requests.set(url, { resolve, reject }));
|
||||
manager.updateMetadataSidebar = metadata => { panel.innerHTML = metadata?.imagePath || 'No metadata'; };
|
||||
manager.hideModal = () => {};
|
||||
const load = url => manager.loadMetadataForImage({ getAttribute: () => url });
|
||||
return { manager, panel, requests, load };
|
||||
}
|
||||
test('admin metadata and copy actions stay on the selected image', async () => {
|
||||
const { manager, panel, requests, load } = fixture();
|
||||
const old = load('old.png');
|
||||
const current = load('current.png');
|
||||
requests.get('current.png').resolve({ imagePath: 'current.png' }); await current;
|
||||
requests.get('old.png').resolve({ imagePath: 'old.png' }); await old;
|
||||
assert.equal(panel.innerHTML, 'current.png');
|
||||
assert.equal(manager.currentMetadata.imagePath, 'current.png');
|
||||
});
|
||||
test('navigation clears old action data and late errors cannot replace current metadata', async () => {
|
||||
const { manager, panel, requests, load } = fixture();
|
||||
manager.currentMetadata = { imagePath: 'previous.png' };
|
||||
const old = load('old.png');
|
||||
assert.equal(manager.currentMetadata, null);
|
||||
const current = load('current.png');
|
||||
requests.get('current.png').resolve({ imagePath: 'current.png' }); await current;
|
||||
requests.get('old.png').reject(new Error('old error')); await old;
|
||||
assert.equal(panel.innerHTML, 'current.png');
|
||||
});
|
||||
for (const close of ['close gallery', 'detach sidebar']) {
|
||||
test(`${close} prevents pending admin metadata from restoring copy state`, async () => {
|
||||
const { manager, panel, requests, load } = fixture();
|
||||
const pending = load('old.png');
|
||||
if (close === 'close gallery') manager.closeGallery();
|
||||
else panel.isConnected = false;
|
||||
requests.get('old.png').resolve({ imagePath: 'old.png' }); await pending;
|
||||
assert.equal(manager.currentMetadata, null);
|
||||
assert.notEqual(panel.innerHTML, 'old.png');
|
||||
});
|
||||
}
|
||||
@@ -1,38 +1,7 @@
|
||||
// Run with: node --test tests/js/
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { createApiClient, resolveUrl, fetchUrl, ApiError, latestOnly, STALE } = require("../../web/js/api-client.js");
|
||||
|
||||
test("Prompt Manager URLs follow the page's proxy mount without losing query strings", () => {
|
||||
const url = "/prompt_manager/images/output?limit=100&subfolder=2025%2F04%2F02";
|
||||
assert.equal(resolveUrl(url, "/ComfyBackendDirect/prompt_manager/gallery"),
|
||||
"/ComfyBackendDirect" + url);
|
||||
assert.equal(resolveUrl(url, "/prompt_manager/gallery"), url);
|
||||
assert.equal(resolveUrl(url, "/nested/proxy/prompt_manager/admin"), "/nested/proxy" + url);
|
||||
assert.equal(resolveUrl("/other/api?x=1", "/ComfyBackendDirect/prompt_manager/gallery"), "/other/api?x=1");
|
||||
assert.equal(resolveUrl("/ComfyBackendDirect/prompt_manager/images/serve/a.png", "/ComfyBackendDirect/prompt_manager/gallery"),
|
||||
"/ComfyBackendDirect/prompt_manager/images/serve/a.png");
|
||||
assert.equal(resolveUrl("/prompt_manager/images/serve/a.png?root=1", "/ComfyBackendDirect/prompt_manager/gallery"),
|
||||
"/ComfyBackendDirect/prompt_manager/images/serve/a.png?root=1");
|
||||
});
|
||||
|
||||
test("fetchUrl sends Prompt Manager requests under the current proxy mount", async () => {
|
||||
const previousFetch = globalThis.fetch;
|
||||
const previousLocation = globalThis.location;
|
||||
const calls = [];
|
||||
globalThis.location = { pathname: "/ComfyBackendDirect/prompt_manager/admin" };
|
||||
globalThis.fetch = async (url, init) => {
|
||||
calls.push([url, init]);
|
||||
return { ok: true };
|
||||
};
|
||||
try {
|
||||
await fetchUrl("/prompt_manager/settings?show=all", { method: "GET" });
|
||||
assert.deepEqual(calls, [["/ComfyBackendDirect/prompt_manager/settings?show=all", { method: "GET" }]]);
|
||||
} finally {
|
||||
globalThis.fetch = previousFetch;
|
||||
globalThis.location = previousLocation;
|
||||
}
|
||||
});
|
||||
const { createApiClient, ApiError, latestOnly, STALE } = require("../../web/js/api-client.js");
|
||||
|
||||
/** A fetch stand-in whose responses the test resolves by hand, in any order. */
|
||||
function fakeFetch() {
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const path = require('node:path');
|
||||
|
||||
function deferred() {
|
||||
let resolve, reject;
|
||||
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
const context = vm.createContext({ document: { addEventListener() {}, getElementById() {} },
|
||||
window: {}, console: { error() {} }, escapeHtml: value => value });
|
||||
const Manager = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/gallery.js'), 'utf8') + '\nGalleryManager;', context, { filename: path.join(__dirname, '../../web/js/gallery.js') });
|
||||
const manager = Object.create(Manager.prototype);
|
||||
const requests = new Map();
|
||||
manager.extractImageMetadata = url => {
|
||||
const pending = deferred();
|
||||
requests.set(url, pending);
|
||||
return pending.promise;
|
||||
};
|
||||
manager.displayMetadata = (metadata, panel) => {
|
||||
panel.innerHTML = metadata?.positivePrompt || 'No metadata';
|
||||
manager.currentMetadata = metadata;
|
||||
};
|
||||
manager.stopViewerSlideshow = () => {};
|
||||
return { manager, requests, panel: { innerHTML: '', isConnected: true } };
|
||||
}
|
||||
|
||||
test('a late response cannot overwrite the selected image or copy state', async () => {
|
||||
const { manager, requests, panel } = fixture();
|
||||
const first = manager.loadImageMetadata('old.png', panel);
|
||||
const second = manager.loadImageMetadata('new.png', panel);
|
||||
requests.get('new.png').resolve({ positivePrompt: 'new prompt' });
|
||||
await second;
|
||||
requests.get('old.png').resolve({ positivePrompt: 'old prompt' });
|
||||
await first;
|
||||
assert.equal(panel.innerHTML, 'new prompt');
|
||||
assert.equal(manager.currentMetadata.positivePrompt, 'new prompt');
|
||||
});
|
||||
|
||||
test('navigation clears old action data while the new request is pending', async () => {
|
||||
const { manager, requests, panel } = fixture();
|
||||
manager.currentMetadata = { positivePrompt: 'old prompt' };
|
||||
const pending = manager.loadImageMetadata('new.png', panel);
|
||||
assert.equal(manager.currentMetadata, null);
|
||||
requests.get('new.png').resolve(null);
|
||||
await pending;
|
||||
assert.equal(manager.currentMetadata, null);
|
||||
});
|
||||
|
||||
test('a stale error cannot replace newer metadata', async () => {
|
||||
const { manager, requests, panel } = fixture();
|
||||
const first = manager.loadImageMetadata('old.png', panel);
|
||||
const second = manager.loadImageMetadata('new.png', panel);
|
||||
requests.get('new.png').resolve({ positivePrompt: 'new prompt' });
|
||||
await second;
|
||||
requests.get('old.png').reject(new Error('old request failed'));
|
||||
await first;
|
||||
assert.equal(panel.innerHTML, 'new prompt');
|
||||
});
|
||||
|
||||
for (const close of ['destroy viewer', 'remove modal']) {
|
||||
test(`closing via ${close} prevents pending metadata from restoring action data`, async () => {
|
||||
const { manager, requests, panel } = fixture();
|
||||
const pending = manager.loadImageMetadata('old.png', panel);
|
||||
if (close === 'destroy viewer') manager.destroyViewer();
|
||||
else panel.isConnected = false;
|
||||
requests.get('old.png').resolve({ positivePrompt: 'closed prompt' });
|
||||
await pending;
|
||||
assert.equal(manager.currentMetadata, null);
|
||||
assert.notEqual(panel.innerHTML, 'closed prompt');
|
||||
});
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const path = require('node:path');
|
||||
const { escapeHtml } = require('../../web/js/html-escape.js');
|
||||
|
||||
function fixture(count = 2000) {
|
||||
const elements = new Map();
|
||||
function element() {
|
||||
return {
|
||||
dataset: {}, children: [], listeners: {}, classList: { add() {}, remove() {} },
|
||||
set innerHTML(html) { this.html = html; this.children = [{ html }]; },
|
||||
get innerHTML() { return this.html || ''; },
|
||||
insertAdjacentHTML(position, html) { this.children.push({ html }); },
|
||||
addEventListener(type, handler) { (this.listeners[type] ||= []).push(handler); },
|
||||
querySelectorAll() { return []; },
|
||||
remove() {},
|
||||
};
|
||||
}
|
||||
const document = {
|
||||
addEventListener() {},
|
||||
getElementById(id) { if (!elements.has(id)) elements.set(id, element()); return elements.get(id); },
|
||||
createElement: element,
|
||||
};
|
||||
const instances = [];
|
||||
const timers = new Map();
|
||||
let timerId = 0;
|
||||
class Viewer {
|
||||
constructor(holder, options) {
|
||||
this.element = holder; this.options = { fullscreen: true, ...options };
|
||||
this.index = options.initialViewIndex; this.isShown = true;
|
||||
this.length = (holder.innerHTML.match(/<img /g) || []).length;
|
||||
instances.push(this);
|
||||
}
|
||||
requestFullscreen() { this.fullscreenRequested = true; }
|
||||
exitFullscreen() { this.fullscreenExited = true; }
|
||||
show() { this.shown = true; }
|
||||
view(index) { this.index = index; }
|
||||
clearEnforceFocus() { this.focusCleared = true; }
|
||||
destroy() { this.destroyed = true; }
|
||||
}
|
||||
const context = vm.createContext({ document, window: {}, Viewer, escapeHtml,
|
||||
console, setTimeout(callback) { timers.set(++timerId, callback); return timerId; },
|
||||
clearTimeout(id) { timers.delete(id); },
|
||||
ApiClient: { resolveUrl: value => value, STALE: Symbol() },
|
||||
ListState: { nextPageState: (state, next) => ({ ...state, ...next }) },
|
||||
});
|
||||
const source = fs.readFileSync(path.join(__dirname, '../../web/js/gallery.js'), 'utf8');
|
||||
const Manager = vm.runInContext(source + '\nGalleryManager;', context, { filename: path.join(__dirname, '../../web/js/gallery.js') });
|
||||
const manager = Object.create(Manager.prototype);
|
||||
Object.assign(manager, { images: Array.from({ length: count }, (_, i) => ({
|
||||
filename: `${i}.png`, url: `/original/${i}.png`, thumbnail_url: `/thumb/${i}.jpg`,
|
||||
relative_path: `${i}.png`, size: 100, modified_time: 1,
|
||||
})), viewMode: 'grid', viewer: null, currentPage: 1, limit: 100, total: count,
|
||||
getSettings: () => ({ showImageInfo: true }), });
|
||||
return { manager, document, instances, timers };
|
||||
}
|
||||
|
||||
for (const mode of ['grid', 'list']) {
|
||||
test(`${mode} append retains existing cards and adds only the new page`, () => {
|
||||
const { manager, document, timers } = fixture(100);
|
||||
manager.viewMode = mode;
|
||||
manager.renderGallery();
|
||||
const container = document.getElementById(mode === 'grid' ? 'galleryGrid' : 'galleryList');
|
||||
assert.equal(timers.size, 0);
|
||||
const first = container.children[0];
|
||||
manager.images.push({ ...manager.images[0], filename: 'new.png' });
|
||||
manager.renderGallery({ from: 100 });
|
||||
assert.equal(container.children[0], first);
|
||||
assert.equal(container.children.length, 2);
|
||||
assert.match(container.children[1].html, /new.png/);
|
||||
assert.doesNotMatch(container.children[1].html, /alt="0.png"/);
|
||||
assert.match(container.children[1].html, /data-image-index="100"/);
|
||||
});
|
||||
}
|
||||
|
||||
test('loadImages passes the previous length for append, and zero for replacement', async () => {
|
||||
const { manager } = fixture(200);
|
||||
const offsets = [];
|
||||
Object.assign(manager, { imagesRequest: async () => ({ total: 500, images: [manager.images[0]] }),
|
||||
renderGallery: options => offsets.push(options.from), updateStats() {},
|
||||
updatePagination() {}, maybeLoadMore() {}, showLoading() {}, });
|
||||
await manager.loadImages(3, { append: true });
|
||||
await manager.loadImages(1);
|
||||
assert.deepEqual(offsets, [200, 0]);
|
||||
assert.equal(manager.images.length, 1);
|
||||
});
|
||||
|
||||
test('viewer opening is bounded and selects the clicked original', () => {
|
||||
const { manager, instances } = fixture();
|
||||
manager.openViewer(1500);
|
||||
const viewer = instances[0];
|
||||
assert.ok(viewer.element.innerHTML.match(/<img /g).length <= 100);
|
||||
assert.match(viewer.element.innerHTML, /data-original="\/original\/1500.png"/);
|
||||
const urls = [...viewer.element.innerHTML.matchAll(/data-original="([^"]+)"/g)].map(m => m[1]);
|
||||
assert.equal(urls[viewer.options.initialViewIndex], '/original/1500.png');
|
||||
assert.equal(viewer.shown, true);
|
||||
});
|
||||
|
||||
test('previous/next cross window edges and wrap only at gallery edges', () => {
|
||||
const { manager, instances } = fixture();
|
||||
manager.openViewer(1500);
|
||||
const first = manager.viewer;
|
||||
first.index = 99;
|
||||
const lastUrl = [...first.element.innerHTML.matchAll(/data-original="\/original\/(\d+).png"/g)].at(-1);
|
||||
first.next(true);
|
||||
const next = manager.viewer;
|
||||
const urls = [...next.element.innerHTML.matchAll(/data-original="\/original\/(\d+).png"/g)];
|
||||
assert.equal(Number(urls[next.index][1]), Number(lastUrl[1]) + 1);
|
||||
manager.openViewer(0);
|
||||
manager.viewer.prev(true);
|
||||
const wrapped = manager.viewer;
|
||||
const wrappedUrls = [...wrapped.element.innerHTML.matchAll(/data-original="\/original\/(\d+).png"/g)];
|
||||
assert.equal(Number(wrappedUrls[wrapped.index][1]), 1999);
|
||||
assert.ok(instances.length >= 3);
|
||||
});
|
||||
|
||||
test('delegated card clicks bind once, open appended indices, and preserve video dispatch', () => {
|
||||
const { manager, document } = fixture();
|
||||
const opened = [];
|
||||
manager.openViewer = index => opened.push(index);
|
||||
manager.showVideoModal = (...args) => opened.push(args);
|
||||
manager.addFallbackClickHandlers();
|
||||
manager.addFallbackClickHandlers();
|
||||
const grid = document.getElementById('galleryGrid');
|
||||
assert.equal(grid.listeners.click.length, 1);
|
||||
const item = { dataset: { imageIndex: '1500' }, querySelector: () => ({ dataset: {
|
||||
original: '/original/1500.png', caption: 'caption', isVideo: 'false',
|
||||
} }) };
|
||||
const click = () => grid.listeners.click[0]({ target: { closest: () => item }, preventDefault() {}, stopPropagation() {} });
|
||||
click();
|
||||
item.querySelector = () => ({ dataset: { original: '/video.mp4', caption: 'video', isVideo: 'true' } });
|
||||
click();
|
||||
assert.deepEqual(opened, [1500, ['/video.mp4', 'video', 1500]]);
|
||||
});
|
||||
|
||||
test('navigation inside the window reuses the viewer and honors non-looping edges', () => {
|
||||
const { manager, instances } = fixture();
|
||||
manager.openViewer(1500);
|
||||
const viewer = manager.viewer;
|
||||
viewer.next(true);
|
||||
assert.equal(manager.viewer, viewer);
|
||||
assert.equal(viewer.index, 51);
|
||||
viewer.prev(true);
|
||||
assert.equal(viewer.index, 50);
|
||||
manager.openViewer(0);
|
||||
const edge = manager.viewer;
|
||||
edge.prev(false);
|
||||
assert.equal(manager.viewer, edge);
|
||||
assert.equal(edge.index, 0);
|
||||
assert.equal(instances.length, 2);
|
||||
});
|
||||
|
||||
test('append leaves an open viewer intact; a full render destroys it', () => {
|
||||
const { manager } = fixture();
|
||||
manager.openViewer(1500);
|
||||
const viewer = manager.viewer;
|
||||
manager.renderGallery({ from: 1900 });
|
||||
assert.equal(manager.viewer, viewer);
|
||||
manager.renderGallery();
|
||||
assert.equal(viewer.destroyed, true);
|
||||
assert.equal(manager.viewer, null);
|
||||
});
|
||||
|
||||
test('each viewed image updates sidebar metadata without waiting for DOM mutations', () => {
|
||||
const { manager, document } = fixture();
|
||||
const urls = [];
|
||||
document.querySelector = () => ({});
|
||||
const sidebar = document.getElementById('metadata-sidebar');
|
||||
sidebar.querySelector = () => ({});
|
||||
manager.loadImageMetadata = url => urls.push(url);
|
||||
manager.openViewer(1500);
|
||||
manager.viewer.options.viewed({ detail: { originalImage: { dataset: { original: '/original/1500.png' } } } });
|
||||
manager.viewer.options.viewed({ detail: { originalImage: { dataset: { original: '/original/1501.png' } } } });
|
||||
assert.deepEqual(urls, ['/original/1500.png', '/original/1501.png']);
|
||||
});
|
||||
|
||||
test('slideshow advances across windows without preloading every original and stops on hide', () => {
|
||||
const { manager, timers } = fixture();
|
||||
manager.openViewer(1500);
|
||||
let prevented = false;
|
||||
manager.viewer.options.play({ preventDefault() { prevented = true; } });
|
||||
assert.equal(prevented, true);
|
||||
assert.equal(manager.viewer.fullscreenRequested, true);
|
||||
assert.equal(timers.size, 1);
|
||||
manager.viewer.index = 99;
|
||||
const tick = timers.values().next().value;
|
||||
timers.clear();
|
||||
tick();
|
||||
const viewer = manager.viewer;
|
||||
const urls = [...viewer.element.innerHTML.matchAll(/data-original="([^"]+)"/g)];
|
||||
assert.equal(urls[viewer.index][1], '/original/1550.png');
|
||||
assert.equal(timers.size, 1);
|
||||
viewer.options.hide();
|
||||
assert.equal(viewer.fullscreenExited, true);
|
||||
assert.equal(timers.size, 0);
|
||||
});
|
||||
|
||||
test('destroying a viewer removes its focus trap and slideshow timer', () => {
|
||||
const { manager, timers } = fixture();
|
||||
manager.openViewer(1500);
|
||||
const viewer = manager.viewer;
|
||||
viewer.options.play({ preventDefault() {} });
|
||||
manager.destroyViewer();
|
||||
assert.equal(viewer.focusCleared, true);
|
||||
assert.equal(viewer.destroyed, true);
|
||||
assert.equal(timers.size, 0);
|
||||
});
|
||||
@@ -1,88 +0,0 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const ImageHelpers = require('../../web/js/image-helpers.js');
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise(yes => { resolve = yes; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
function fixture() {
|
||||
const elements = new Map();
|
||||
const requests = [];
|
||||
const document = { addEventListener() {}, getElementById(id) {
|
||||
if (!elements.has(id)) elements.set(id, {});
|
||||
return elements.get(id);
|
||||
} };
|
||||
const context = vm.createContext({ document, window: {}, ImageHelpers,
|
||||
escapeHtml: value => value, console: { error() {} },
|
||||
ApiClient: { resolveUrl: value => value, fetchUrl: (url, options) => {
|
||||
const request = deferred();
|
||||
requests.push({ url, body: JSON.parse(options.body), ...request });
|
||||
return request.promise;
|
||||
} },
|
||||
});
|
||||
const Manager = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/gallery.js'), 'utf8') + '\nGalleryManager;', context, { filename: path.join(__dirname, '../../web/js/gallery.js') });
|
||||
const manager = Object.create(Manager.prototype);
|
||||
const notifications = [];
|
||||
Object.assign(manager, { autoTagState: { reviewImages: [
|
||||
{ id: 'digest', path: 'same.png', root_index: 1, url: '/same.png' },
|
||||
{ id: 'other', path: 'next.png', root_index: 0, url: '/next.png' },
|
||||
], reviewIndex: 0, currentTags: [], modelType: 'gguf' },
|
||||
renderReviewTags() {}, showNotification: (...args) => notifications.push(args), hideModal() {}, loadImages() {},
|
||||
});
|
||||
const reply = (index, data) => requests[index].resolve({ json: async () => data });
|
||||
return { manager, requests, reply, notifications };
|
||||
}
|
||||
test('gallery review retains root identity and applies tags with the returned prompt ID', async () => {
|
||||
const { manager, requests, reply } = fixture();
|
||||
const load = manager.loadNextReviewImage();
|
||||
assert.equal(requests[0].body.root_index, 1);
|
||||
reply(0, { success: true, prompt_id: 42, tags: ['smile'] });
|
||||
await load;
|
||||
manager.loadNextReviewImage = () => {};
|
||||
const apply = manager.applyReviewTags();
|
||||
assert.deepEqual(requests[1].body, { prompt_id: 42, tags: ['smile'] });
|
||||
reply(1, { success: true });
|
||||
await apply;
|
||||
});
|
||||
test('unlinked images do not submit an invalid apply request or advance', async () => {
|
||||
const { manager, requests, reply } = fixture();
|
||||
const load = manager.loadNextReviewImage();
|
||||
reply(0, { success: true, prompt_id: null, tags: ['smile'] });
|
||||
await load;
|
||||
const apply = manager.applyReviewTags();
|
||||
if (requests[1]) reply(1, { success: false, error: "Unexpected apply" });
|
||||
await apply;
|
||||
assert.equal(requests.length, 1);
|
||||
assert.equal(manager.autoTagState.reviewIndex, 0);
|
||||
});
|
||||
test('a failed apply keeps the current image available for retry', async () => {
|
||||
const { manager, requests, reply } = fixture();
|
||||
const load = manager.loadNextReviewImage();
|
||||
reply(0, { success: true, prompt_id: 42, tags: ['smile'] });
|
||||
await load;
|
||||
const apply = manager.applyReviewTags();
|
||||
reply(1, { success: false, error: 'Try again' });
|
||||
await apply;
|
||||
assert.equal(manager.autoTagState.reviewIndex, 0);
|
||||
assert.equal(requests.length, 2);
|
||||
});
|
||||
test('late generation responses cannot restore tags from a skipped or closed image', async () => {
|
||||
const { manager, reply } = fixture();
|
||||
const first = manager.loadNextReviewImage();
|
||||
manager.autoTagState.reviewIndex = 1;
|
||||
const second = manager.loadNextReviewImage();
|
||||
reply(1, { success: true, prompt_id: 2, tags: ['current'] });
|
||||
await second;
|
||||
reply(0, { success: true, prompt_id: 1, tags: ['stale'] });
|
||||
await first;
|
||||
assert.deepEqual(Array.from(manager.autoTagState.currentTags), ['current']);
|
||||
const third = manager.loadNextReviewImage();
|
||||
manager.cancelReview();
|
||||
reply(2, { success: true, prompt_id: 2, tags: ['closed'] });
|
||||
await third;
|
||||
assert.equal(manager.autoTagState.currentTags.length, 0);
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const path = require('node:path');
|
||||
const ImageHelpers = require('../../web/js/image-helpers.js');
|
||||
|
||||
test('gallery metadata requests retain root identity without including cache parameters in filenames', async () => {
|
||||
const requests = [];
|
||||
const context = vm.createContext({ document: { addEventListener() {} }, window: {}, console,
|
||||
ImageHelpers, ApiClient: { fetchUrl: async url => {
|
||||
requests.push(url);
|
||||
return { ok: true, json: async () => ({ success: true, prompt: { text: url } }) };
|
||||
} },
|
||||
});
|
||||
const Manager = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/gallery.js'), 'utf8') + '\nGalleryManager;', context, { filename: path.join(__dirname, '../../web/js/gallery.js') });
|
||||
const manager = Object.create(Manager.prototype);
|
||||
manager.extractImageMetadata = async () => null;
|
||||
manager.displayMetadata = (metadata, panel) => { panel.prompt = metadata.positivePrompt; };
|
||||
for (const root of [0, 1]) {
|
||||
const panel = {};
|
||||
const expected = `/prompt_manager/images/prompt/sub%2Fsame.png?root=${root}`;
|
||||
await manager.loadImageMetadata(`/prompt_manager/images/serve/sub/same.png?v=123&root=${root}`, panel);
|
||||
assert.equal(panel.prompt, expected);
|
||||
}
|
||||
assert.deepEqual(requests, [
|
||||
'/prompt_manager/images/prompt/sub%2Fsame.png?root=0',
|
||||
'/prompt_manager/images/prompt/sub%2Fsame.png?root=1',
|
||||
]);
|
||||
});
|
||||
@@ -1,90 +0,0 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
function fixture(status, settings = {}) {
|
||||
const timers = [];
|
||||
const prompts = [];
|
||||
const requests = [];
|
||||
const buttons = {};
|
||||
const storage = new Map();
|
||||
const document = {
|
||||
addEventListener() {},
|
||||
createElement: () => ({ addEventListener() {} }),
|
||||
body: { appendChild() {}, removeChild() {} },
|
||||
getElementById: id => buttons[id] ||= { addEventListener(type, handler) { this[type] = handler; } },
|
||||
};
|
||||
const context = vm.createContext({ document, window: {}, console: { debug() {}, warn() {} },
|
||||
setTimeout: callback => timers.push(callback),
|
||||
localStorage: { setItem: (key, value) => storage.set(key, value) },
|
||||
ApiClient: { fetchUrl: async () => ({ json: async () => ({ success: true,
|
||||
total: 10000, images: Array.from({ length: 50 }, () => ({ thumbnail_url: null })),
|
||||
}) }) },
|
||||
});
|
||||
const Manager = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/gallery.js'), 'utf8') + '\nGalleryManager;', context, { filename: path.join(__dirname, '../../web/js/gallery.js') });
|
||||
const manager = Object.create(Manager.prototype);
|
||||
Object.assign(manager, { getSettings: () => ({ checkThumbnailsAtStartup: true, ...settings }),
|
||||
api: { get: async url => { requests.push(url); return status; } },
|
||||
});
|
||||
const originalPrompt = manager.showThumbnailGenerationPrompt.bind(manager);
|
||||
manager.showThumbnailGenerationPrompt = (...args) => prompts.push(args);
|
||||
async function check() {
|
||||
await manager.checkThumbnailsAtStartup();
|
||||
await timers.shift()();
|
||||
}
|
||||
return { manager, check, prompts, requests, settings, buttons, storage, originalPrompt };
|
||||
}
|
||||
|
||||
test('startup uses exact collection counts instead of extrapolating the newest 50 images', async () => {
|
||||
const f = fixture({ success: true, total_images: 10000, pending: 50, ready: 9950, collection_id: 'a' });
|
||||
await f.check();
|
||||
assert.deepEqual(f.requests, ['/prompt_manager/images/thumbnails/status']);
|
||||
assert.equal(f.prompts.length, 0);
|
||||
});
|
||||
|
||||
test('startup prompt reports the actual number needing generation', async () => {
|
||||
const f = fixture({ success: true, total_images: 10000, pending: 1200, collection_id: 'a' });
|
||||
await f.check();
|
||||
assert.equal(f.prompts.length, 1);
|
||||
assert.deepEqual(f.prompts[0].slice(0, 2), [10000, 1200]);
|
||||
});
|
||||
|
||||
test('a complete collection never prompts again', async () => {
|
||||
const f = fixture({ success: true, total_images: 10000, pending: 0, collection_id: 'a' });
|
||||
await f.check();
|
||||
await f.check();
|
||||
assert.equal(f.prompts.length, 0);
|
||||
});
|
||||
|
||||
test('Not Now persists the collection state and suppresses the unchanged prompt', async () => {
|
||||
const status = { success: true, total_images: 100, pending: 30, collection_id: 'a' };
|
||||
const f = fixture(status);
|
||||
await f.check();
|
||||
f.originalPrompt(...f.prompts[0]);
|
||||
f.buttons.thumbnailPromptNo.click();
|
||||
Object.assign(f.settings, JSON.parse(f.storage.get('gallerySettings')));
|
||||
f.prompts.length = 0;
|
||||
await f.check();
|
||||
assert.equal(f.prompts.length, 0);
|
||||
status.pending = 40;
|
||||
await f.check();
|
||||
assert.equal(f.prompts.length, 1);
|
||||
});
|
||||
|
||||
test('disabled checks do not request collection status', async () => {
|
||||
const f = fixture({}, { checkThumbnailsAtStartup: false });
|
||||
await f.check();
|
||||
assert.equal(f.requests.length, 0);
|
||||
assert.equal(f.prompts.length, 0);
|
||||
});
|
||||
|
||||
test('saving gallery settings retains the remembered thumbnail dismissal', () => {
|
||||
const f = fixture({}, { thumbnailPromptDismissed: 'remembered-state' });
|
||||
f.manager.showNotification = () => {};
|
||||
f.manager.hideSettings = () => {};
|
||||
f.manager.applySettings = () => {};
|
||||
f.manager.saveSettings();
|
||||
assert.equal(JSON.parse(f.storage.get('gallerySettings')).thumbnailPromptDismissed, 'remembered-state');
|
||||
});
|
||||
@@ -108,16 +108,3 @@ test("fallbackImageSource gives up when the original itself failed or was alread
|
||||
assert.equal(fallbackImageSource("/serve/t.png", {}), null);
|
||||
assert.equal(fallbackImageSource("/serve/t.png", undefined), null);
|
||||
});
|
||||
|
||||
const { metadataLookupUrl } = require('../../web/js/image-helpers.js');
|
||||
|
||||
test('metadata lookup keeps root identity and strips thumbnail cache query parameters', () => {
|
||||
assert.equal(metadataLookupUrl('/prompt_manager/images/serve/sub/a.png?root=1&v=123'), '/prompt_manager/images/prompt/sub%2Fa.png?root=1');
|
||||
assert.equal(metadataLookupUrl('/prompt_manager/images/serve/a.png?root=0'), '/prompt_manager/images/prompt/a.png?root=0');
|
||||
});
|
||||
|
||||
test('metadata lookup preserves encoded filename characters and works behind a proxy', () => {
|
||||
assert.equal(metadataLookupUrl('https://example.test/ComfyBackendDirect/prompt_manager/images/serve/space%20%23%3F%2520%20caf%C3%A9.png?root=1'), '/prompt_manager/images/prompt/space%20%23%3F%2520%20caf%C3%A9.png?root=1');
|
||||
assert.equal(metadataLookupUrl('/prompt_manager/images/serve/a.png?v=123'), '/prompt_manager/images/prompt/a.png');
|
||||
assert.equal(metadataLookupUrl('/elsewhere/a.png'), null);
|
||||
});
|
||||
|
||||
@@ -64,28 +64,6 @@ test("connect POSTs and delivers each event to onmessage, then onclose", async (
|
||||
assert.equal(source.readyState, source.CLOSED);
|
||||
});
|
||||
|
||||
test("connect keeps streaming requests under the current proxy mount", async () => {
|
||||
const previousApiClient = globalThis.ApiClient;
|
||||
const previousLocation = globalThis.location;
|
||||
globalThis.ApiClient = require("../../web/js/api-client.js");
|
||||
globalThis.location = { pathname: "/ComfyBackendDirect/prompt_manager/gallery" };
|
||||
let requestedUrl;
|
||||
try {
|
||||
const source = connect("/prompt_manager/images/generate-thumbnails/progress?quality=80", {
|
||||
method: "POST",
|
||||
fetchImpl: async (url) => {
|
||||
requestedUrl = url;
|
||||
return new Response(streamOf([]), { status: 200 });
|
||||
},
|
||||
});
|
||||
await until(() => source.readyState === source.CLOSED);
|
||||
assert.equal(requestedUrl, "/ComfyBackendDirect/prompt_manager/images/generate-thumbnails/progress?quality=80");
|
||||
} finally {
|
||||
globalThis.ApiClient = previousApiClient;
|
||||
globalThis.location = previousLocation;
|
||||
}
|
||||
});
|
||||
|
||||
test("connect reports a non-2xx response through onerror and never onmessage", async () => {
|
||||
let error = null;
|
||||
const fetchImpl = async () => new Response("nope", { status: 405 });
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
function fixture() {
|
||||
const requests = [];
|
||||
const grid = { replaceChildren() {}, appendChild() {} };
|
||||
const context = vm.createContext({ window: {}, URLSearchParams, console: { error() {} },
|
||||
requestAnimationFrame() {}, document: { getElementById: id => id === 'tagPromptsGrid' ? grid : null,
|
||||
querySelector: () => null, createElement: () => ({ appendChild() {} }) },
|
||||
});
|
||||
const Manager = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/tags-page.js'), 'utf8') + '\nTagsPageManager;', context, { filename: path.join(__dirname, '../../web/js/tags-page.js') });
|
||||
const manager = Object.create(Manager.prototype);
|
||||
Object.assign(manager, { selectedTags: ['cats'], filterMode: 'and', promptLimit: 20,
|
||||
promptOffset: 0, isLoadingPrompts: false, prompts: [], hasMorePrompts: false,
|
||||
fetchJson(url) { return new Promise((resolve, reject) => requests.push({ url, resolve, reject })); },
|
||||
renderTagPrompts() {}, appendTagPrompts() {}, updatePromptCountBadge() {}, updateLoadMoreButton() {},
|
||||
});
|
||||
const reply = (index, text, more = false) => requests[index].resolve({ success: true,
|
||||
prompts: [{ text }], pagination: { has_more: more, total: more ? 40 : 1 } });
|
||||
return { manager, requests, reply };
|
||||
}
|
||||
test('changing a filter starts a new request and late results cannot overwrite it', async () => {
|
||||
const { manager, requests, reply } = fixture();
|
||||
const old = manager.loadTagPrompts();
|
||||
manager.selectedTags = ['dogs'];
|
||||
const current = manager.loadTagPrompts();
|
||||
assert.equal(requests.length, 2);
|
||||
assert.match(requests[1].url, /dogs/);
|
||||
reply(1, 'dog prompt'); await current;
|
||||
reply(0, 'cat prompt', true); await old;
|
||||
assert.equal(manager.prompts[0].text, 'dog prompt');
|
||||
assert.equal(manager.promptTotal, 1);
|
||||
assert.equal(manager.hasMorePrompts, false);
|
||||
});
|
||||
test('clearing selection invalidates pending results', async () => {
|
||||
const { manager, reply } = fixture();
|
||||
const old = manager.loadTagPrompts();
|
||||
manager.selectedTags = [];
|
||||
manager.clearPrompts();
|
||||
reply(0, 'cat prompt'); await old;
|
||||
assert.equal(manager.prompts.length, 0);
|
||||
assert.equal(manager.promptTotal, 0);
|
||||
assert.equal(manager.isLoadingPrompts, false);
|
||||
});
|
||||
test('a stale pagination response cannot append rows from the previous filter', async () => {
|
||||
const { manager, requests, reply } = fixture();
|
||||
Object.assign(manager, { prompts: [{ text: 'cat first page' }], hasMorePrompts: true, promptOffset: 20 });
|
||||
const old = manager.loadMoreTagPrompts();
|
||||
manager.selectedTags = ['dogs'];
|
||||
const current = manager.loadTagPrompts();
|
||||
assert.equal(requests.length, 2);
|
||||
reply(1, 'dog prompt'); await current;
|
||||
reply(0, 'cat second page', true); await old;
|
||||
assert.equal(manager.prompts.length, 1);
|
||||
assert.equal(manager.prompts[0].text, 'dog prompt');
|
||||
assert.equal(manager.promptOffset, 20);
|
||||
});
|
||||
test('a stale failure cannot finish the new request or clear its loading state', async () => {
|
||||
const { manager, requests, reply } = fixture();
|
||||
const old = manager.loadTagPrompts();
|
||||
manager.selectedTags = ['dogs'];
|
||||
const current = manager.loadTagPrompts();
|
||||
assert.equal(requests.length, 2);
|
||||
requests[0].reject(new Error('old failure')); await old;
|
||||
assert.equal(manager.isLoadingPrompts, true);
|
||||
reply(1, 'dog prompt'); await current;
|
||||
assert.equal(manager.isLoadingPrompts, false);
|
||||
assert.equal(manager.prompts[0].text, 'dog prompt');
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createScope, themeFromColor } from '../../web/js/shared/toolkit/core.js';
|
||||
|
||||
test('host background luminance determines mode without assuming palette names', () => {
|
||||
for (const color of ['#fff', '#F4F4F6', 'rgb(240, 240, 240)']) assert.equal(themeFromColor(color), 'light');
|
||||
for (const color of ['#222', '#202020', 'rgb(30, 32, 34)']) assert.equal(themeFromColor(color), 'dark');
|
||||
assert.equal(themeFromColor(''), null);
|
||||
assert.equal(themeFromColor('transparent'), null);
|
||||
});
|
||||
|
||||
test('scope cancels requests and callbacks, removes listeners and disposes once', () => {
|
||||
const scope = createScope();
|
||||
const target = new EventTarget();
|
||||
let events = 0;
|
||||
let cleanups = 0;
|
||||
scope.listen(target, 'change', () => events++);
|
||||
scope.cleanup(() => cleanups++);
|
||||
target.dispatchEvent(new Event('change'));
|
||||
assert.equal(events, 1);
|
||||
scope.dispose();
|
||||
scope.dispose();
|
||||
target.dispatchEvent(new Event('change'));
|
||||
assert.equal(events, 1);
|
||||
assert.equal(cleanups, 1);
|
||||
assert.equal(scope.signal.aborted, true);
|
||||
});
|
||||
|
||||
test('debounced work runs once with the latest value and stops after disposal', async () => {
|
||||
const scope = createScope();
|
||||
const values = [];
|
||||
const change = scope.debounce(value => values.push(value), 1);
|
||||
change('old');
|
||||
change('new');
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
assert.deepEqual(values, ['new']);
|
||||
change('cancelled');
|
||||
scope.dispose();
|
||||
change('after removal');
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
assert.deepEqual(values, ['new']);
|
||||
});
|
||||
@@ -1867,7 +1867,7 @@ class TestScanReadsVideos(AdminAPITestCase):
|
||||
(self.output_dir / "bad.png").write_bytes(b"\x89PNG not really")
|
||||
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.output_dir)]
|
||||
|
||||
with self.assertLogs("prompt_manager", level="WARNING") as cm:
|
||||
with self.assertLogs(level="WARNING") as cm:
|
||||
body = await (
|
||||
await self.client.request("POST", "/prompt_manager/scan")
|
||||
).text()
|
||||
|
||||
@@ -1049,72 +1049,3 @@ class TestScanOutputDir(AutotagAPITestCase):
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class TestGalleryReviewContract(AutotagAPITestCase):
|
||||
async def test_root_relative_gallery_targets_generate_and_apply_to_their_prompt(
|
||||
self,
|
||||
):
|
||||
second = self.tmp_root / "second-output"
|
||||
second.mkdir()
|
||||
roots = [self.output_dir, second]
|
||||
self.api._get_all_output_dirs = lambda: roots
|
||||
for index, root in enumerate(roots):
|
||||
pid = self._save_prompt(f"root {index}")
|
||||
self._link_image(pid, self._make_image(root, "same.png"))
|
||||
response = await self._post_json(
|
||||
"/prompt_manager/autotag/single",
|
||||
{"path": "same.png", "root_index": index, "model_type": "gguf"},
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
data = await response.json()
|
||||
self.assertEqual(data["prompt_id"], pid)
|
||||
self.assertEqual(
|
||||
self.service.generate_calls[-1][0], str((root / "same.png").resolve())
|
||||
)
|
||||
response = await self._post_json(
|
||||
"/prompt_manager/autotag/apply",
|
||||
{"prompt_id": data["prompt_id"], "tags": data["tags"]},
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual(
|
||||
set(self.api.db.get_prompt_by_id(pid)["tags"]), set(data["tags"])
|
||||
)
|
||||
|
||||
async def test_gallery_unlinked_image_returns_no_prompt_id(self):
|
||||
self._make_image(self.output_dir)
|
||||
response = await self._post_json(
|
||||
"/prompt_manager/autotag/single", {"path": "img.png", "root_index": 0}
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertIsNone((await response.json())["prompt_id"])
|
||||
|
||||
async def test_gallery_root_selection_rejects_invalid_roots_and_escapes(self):
|
||||
self._make_image(self.output_dir)
|
||||
for root, path, expected in [
|
||||
(9, "img.png", 403),
|
||||
(-1, "img.png", 403),
|
||||
(True, "img.png", 400),
|
||||
([], "img.png", 400),
|
||||
(0, "../secret/img.png", 403),
|
||||
(0, str(self.secret_dir / "img.png"), 403),
|
||||
]:
|
||||
with self.subTest(root=root, path=path):
|
||||
response = await self._post_json(
|
||||
"/prompt_manager/autotag/single", {"path": path, "root_index": root}
|
||||
)
|
||||
self.assertEqual(response.status, expected)
|
||||
|
||||
async def test_gallery_root_selection_rejects_symlink_into_another_root(self):
|
||||
second = self.tmp_root / "second-output"
|
||||
second.mkdir()
|
||||
target = self._make_image(second)
|
||||
try:
|
||||
(self.output_dir / "link.png").symlink_to(target)
|
||||
except (OSError, NotImplementedError):
|
||||
self.skipTest("symlinks unavailable")
|
||||
self.api._get_all_output_dirs = lambda: [self.output_dir, second]
|
||||
response = await self._post_json(
|
||||
"/prompt_manager/autotag/single", {"path": "link.png", "root_index": 0}
|
||||
)
|
||||
self.assertEqual(response.status, 403)
|
||||
|
||||
+11
-260
@@ -224,7 +224,7 @@ class TestServeOutputImage(ImageAPITestCase):
|
||||
|
||||
self.assertEqual(hit.status, 200)
|
||||
self.assertEqual(miss.status, 404)
|
||||
self.assertEqual(bad_index.status, 403)
|
||||
self.assertEqual(bad_index.status, 200)
|
||||
for resp in (hit, miss, bad_index):
|
||||
await resp.read() # release the served file before the directory goes
|
||||
|
||||
@@ -486,7 +486,7 @@ class TestThumbnailProgressStream(ThumbnailTestCase):
|
||||
# PIL work ran in the executor in batches (never on the loop), plus the scan.
|
||||
self.assertGreaterEqual(calls.count("_generate_batch"), 1)
|
||||
self.assertNotIn("_generate_one", calls)
|
||||
self.assertIn("_plan_gallery_thumbnails", calls)
|
||||
self.assertIn("_plan_thumbnails", calls)
|
||||
|
||||
async def test_capped_run_reports_remaining_and_the_next_run_finishes(self):
|
||||
for name in ("a", "b", "c"):
|
||||
@@ -573,7 +573,7 @@ class TestThumbnailProgressStream(ThumbnailTestCase):
|
||||
self.assertEqual(events[-1][1]["total_images"], 0)
|
||||
|
||||
async def test_missing_output_dir_sends_error_event(self):
|
||||
self.api._get_all_output_dirs = lambda: []
|
||||
self.api._find_comfyui_output_dir = lambda: None
|
||||
|
||||
resp = await self.client.request("POST", self.PROGRESS_URL)
|
||||
|
||||
@@ -616,7 +616,7 @@ class TestGenerateThumbnailsPost(ThumbnailTestCase):
|
||||
self.assertEqual(data2["count"], 0)
|
||||
|
||||
async def test_missing_output_dir_is_404(self):
|
||||
self.api._get_all_output_dirs = lambda: []
|
||||
self.api._find_comfyui_output_dir = lambda: None
|
||||
|
||||
resp = await self.client.request(
|
||||
"POST", "/prompt_manager/images/generate-thumbnails", json={}
|
||||
@@ -849,7 +849,7 @@ class TestThumbnailFilesAreNeverSeenHalfWritten(ThumbnailTestCase):
|
||||
)
|
||||
self.assertRegex(
|
||||
entry["thumbnail_url"],
|
||||
r"^/prompt_manager/images/serve/thumbnails/a_thumb\.png\?v=\d+&root=0$",
|
||||
r"^/prompt_manager/images/serve/thumbnails/a_thumb\.png\?v=\d+$",
|
||||
)
|
||||
first = int(re.search(r"v=(\d+)", entry["thumbnail_url"]).group(1))
|
||||
self.assertEqual(first, int(thumb.stat().st_mtime))
|
||||
@@ -1189,12 +1189,12 @@ class TestServeErrorBranches(ImageRouteCoverageCase):
|
||||
status, _ = await self._json("GET", "/prompt_manager/images/serve/x.png")
|
||||
self.assertEqual(status, 500)
|
||||
|
||||
async def test_out_of_range_root_index_is_rejected(self):
|
||||
async def test_out_of_range_root_index_searches_all_roots(self):
|
||||
make_png(self.output_dir / "r.png")
|
||||
resp = await self.client.request(
|
||||
"GET", "/prompt_manager/images/serve/r.png?root=7"
|
||||
)
|
||||
self.assertEqual(resp.status, 403)
|
||||
self.assertEqual(resp.status, 200)
|
||||
|
||||
async def test_directory_named_like_media_is_404(self):
|
||||
(self.output_dir / "dir.png").mkdir()
|
||||
@@ -1420,7 +1420,7 @@ class TestThumbnailUnits(ImageRouteCoverageCase):
|
||||
self.assertEqual(events[-1][0], "error")
|
||||
|
||||
async def test_post_generate_reports_internal_failure(self):
|
||||
self.api._get_all_output_dirs = _raise
|
||||
self.api._find_comfyui_output_dir = _raise
|
||||
status, _ = await self._json(
|
||||
"POST", "/prompt_manager/images/generate-thumbnails", json={}
|
||||
)
|
||||
@@ -1536,7 +1536,7 @@ class TestClearThumbnails(ImageRouteCoverageCase):
|
||||
URL = "/prompt_manager/images/clear-thumbnails"
|
||||
|
||||
async def test_missing_output_dir_is_404(self):
|
||||
self.api._get_all_output_dirs = lambda: []
|
||||
self.api._find_comfyui_output_dir = lambda: None
|
||||
status, _ = await self._json("POST", self.URL)
|
||||
self.assertEqual(status, 404)
|
||||
|
||||
@@ -1569,7 +1569,7 @@ class TestClearThumbnails(ImageRouteCoverageCase):
|
||||
self.assertEqual(data["cleared_files"], 0)
|
||||
|
||||
async def test_internal_failure_is_500(self):
|
||||
self.api._get_all_output_dirs = _raise
|
||||
self.api._find_comfyui_output_dir = _raise
|
||||
status, _ = await self._json("POST", self.URL)
|
||||
self.assertEqual(status, 500)
|
||||
|
||||
@@ -1581,7 +1581,7 @@ class TestClearThumbnails(ImageRouteCoverageCase):
|
||||
out.mkdir()
|
||||
sibling = make_png(self.comfy_dir / "out2" / "thumbnails" / "a_thumb.png")
|
||||
self._make_symlink(sibling.parent, out / "thumbnails")
|
||||
self.api._get_all_output_dirs = lambda: [out]
|
||||
self.api._find_comfyui_output_dir = lambda: str(out)
|
||||
|
||||
status, data = await self._json("POST", self.URL)
|
||||
|
||||
@@ -1589,79 +1589,6 @@ class TestClearThumbnails(ImageRouteCoverageCase):
|
||||
self.assertFalse(data["success"])
|
||||
self.assertTrue(sibling.is_file())
|
||||
|
||||
async def test_clears_all_roots_and_combines_counts_without_touching_originals(
|
||||
self,
|
||||
):
|
||||
second = self.comfy_dir / "extra-output"
|
||||
roots = [self.output_dir, second]
|
||||
self.api._get_all_output_dirs = lambda: roots
|
||||
expected_size = 0
|
||||
for root in roots:
|
||||
make_png(root / "original_thumb.png")
|
||||
thumb = make_png(root / "thumbnails" / "sub" / "generated_thumb.png")
|
||||
expected_size += thumb.stat().st_size
|
||||
(root / "thumbnails" / "notes.txt").write_text("keep")
|
||||
status, data = await self._json("POST", self.URL)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(data["cleared_files"], 2)
|
||||
self.assertEqual(data["cleared_size"], expected_size)
|
||||
self.assertNotIn("{format_size", data["message"])
|
||||
for root in roots:
|
||||
self.assertTrue((root / "original_thumb.png").is_file())
|
||||
self.assertTrue((root / "thumbnails" / "notes.txt").is_file())
|
||||
self.assertFalse((root / "thumbnails" / "sub").exists())
|
||||
|
||||
async def test_missing_thumbnails_in_first_root_does_not_skip_later_roots(self):
|
||||
second = self.comfy_dir / "extra-output"
|
||||
thumb = make_png(second / "thumbnails" / "a_thumb.png")
|
||||
self.api._get_all_output_dirs = lambda: [self.output_dir, second]
|
||||
status, data = await self._json("POST", self.URL)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(data["cleared_files"], 1)
|
||||
self.assertFalse(thumb.exists())
|
||||
|
||||
async def test_invalid_later_root_is_rejected_before_deleting_any_files(self):
|
||||
first_thumb = make_png(self.output_dir / "thumbnails" / "a_thumb.png")
|
||||
second = self.comfy_dir / "extra-output"
|
||||
second.mkdir()
|
||||
outside = make_png(self.comfy_dir / "elsewhere" / "a_thumb.png")
|
||||
self._make_symlink(outside.parent, second / "thumbnails")
|
||||
self.api._get_all_output_dirs = lambda: [self.output_dir, second]
|
||||
status, data = await self._json("POST", self.URL)
|
||||
self.assertEqual(status, 400)
|
||||
self.assertFalse(data["success"])
|
||||
self.assertTrue(first_thumb.is_file())
|
||||
self.assertTrue(outside.is_file())
|
||||
|
||||
async def test_thumbnail_symlink_back_to_root_cannot_delete_originals(self):
|
||||
original = make_png(self.output_dir / "original_thumb.png")
|
||||
self._make_symlink(self.output_dir, self.output_dir / "thumbnails")
|
||||
status, data = await self._json("POST", self.URL)
|
||||
self.assertEqual(status, 400)
|
||||
self.assertFalse(data["success"])
|
||||
self.assertTrue(original.is_file())
|
||||
|
||||
async def test_cleanup_runs_off_loop_and_invalidates_gallery_cache(self):
|
||||
import threading
|
||||
|
||||
make_png(self.output_dir / "thumbnails" / "a_thumb.png")
|
||||
loop_thread = threading.get_ident()
|
||||
deletion_threads = []
|
||||
original_unlink = Path.unlink
|
||||
|
||||
def unlink(path, *args, **kwargs):
|
||||
deletion_threads.append(threading.get_ident())
|
||||
return original_unlink(path, *args, **kwargs)
|
||||
|
||||
self.api._gallery_cache = {"previous": object()}
|
||||
with patch.object(Path, "unlink", unlink):
|
||||
status, data = await self._json("POST", self.URL)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(data["cleared_files"], 1)
|
||||
self.assertTrue(deletion_threads)
|
||||
self.assertNotIn(loop_thread, deletion_threads)
|
||||
self.assertEqual(self.api._gallery_cache, {})
|
||||
|
||||
|
||||
class TestImagePromptLookup(ImageRouteCoverageCase):
|
||||
|
||||
@@ -1892,179 +1819,3 @@ class TestOutputScanHelpers(ImageAPITestCase):
|
||||
targets = [(Path(f"/x/{i}.png"), Path(f"/x/t{i}.jpg"), False) for i in range(3)]
|
||||
await self.api._stream_thumbnails(GoneResponse(), targets, (256, 256))
|
||||
self.assertEqual(generated, [])
|
||||
|
||||
|
||||
class TestThumbnailStartupStatus(ThumbnailTestCase):
|
||||
"""Startup counts and generation cover the same configured collection."""
|
||||
|
||||
STATUS_URL = "/prompt_manager/images/thumbnails/status"
|
||||
|
||||
async def test_status_reports_exact_pending_count_without_generating(self):
|
||||
for name in ("ready", "missing", "outdated"):
|
||||
make_png(self.output_dir / f"{name}.png")
|
||||
thumbs = self.output_dir / "thumbnails"
|
||||
ready = make_png(thumbs / "ready_thumb.png")
|
||||
outdated = make_png(thumbs / "outdated_thumb.png")
|
||||
source_time = (self.output_dir / "ready.png").stat().st_mtime
|
||||
os.utime(ready, (source_time + 10, source_time + 10))
|
||||
os.utime(outdated, (source_time - 10, source_time - 10))
|
||||
calls = self._spy_executor()
|
||||
|
||||
response = await self.client.get(self.STATUS_URL)
|
||||
|
||||
self.assertEqual(response.status, 200)
|
||||
data = await response.json()
|
||||
self.assertTrue(data["success"])
|
||||
self.assertEqual(data["total_images"], 3)
|
||||
self.assertEqual(data["pending"], 2)
|
||||
self.assertEqual(data["ready"], 1)
|
||||
self.assertTrue(data["collection_id"])
|
||||
self.assertIn("_plan_gallery_thumbnails", calls)
|
||||
self.assertFalse((thumbs / "missing_thumb.png").exists())
|
||||
|
||||
async def test_both_generation_routes_cover_all_gallery_roots(self):
|
||||
second = self.comfy_dir / "another-output"
|
||||
make_png(self.output_dir / "a.png")
|
||||
make_png(second / "b.png")
|
||||
self.api._get_all_output_dirs = lambda: [self.output_dir, second]
|
||||
for route in (
|
||||
"/prompt_manager/images/generate-thumbnails",
|
||||
self.PROGRESS_URL,
|
||||
):
|
||||
with self.subTest(route=route):
|
||||
for root in (self.output_dir, second):
|
||||
for thumb in (root / "thumbnails").glob("*_thumb.png"):
|
||||
thumb.unlink()
|
||||
response = await self.client.post(route, json={})
|
||||
if route == self.PROGRESS_URL:
|
||||
data = dict(parse_sse(await response.text()))["complete"]
|
||||
else:
|
||||
data = await response.json()
|
||||
self.assertEqual(data["count"], 2)
|
||||
self.assertTrue((second / "thumbnails" / "b_thumb.png").exists())
|
||||
status = await (await self.client.get(self.STATUS_URL)).json()
|
||||
self.assertEqual(status["pending"], 0)
|
||||
self.assertEqual(status["total_images"], 2)
|
||||
|
||||
async def test_pending_count_includes_files_beyond_the_generation_cap(self):
|
||||
for name in ("a", "b", "c"):
|
||||
make_png(self.output_dir / f"{name}.png")
|
||||
with patch.object(images_module, "MAX_THUMBNAIL_FILES", 1):
|
||||
response = await self.client.get(self.STATUS_URL)
|
||||
self.assertEqual(response.status, 200)
|
||||
data = await response.json()
|
||||
self.assertEqual(data["pending"], 3)
|
||||
|
||||
async def test_generation_cap_is_shared_across_roots(self):
|
||||
second = self.comfy_dir / "another-output"
|
||||
make_png(self.output_dir / "a.png")
|
||||
make_png(second / "b.png")
|
||||
self.api._get_all_output_dirs = lambda: [self.output_dir, second]
|
||||
with patch.object(images_module, "MAX_THUMBNAIL_FILES", 1):
|
||||
response = await self.client.post(self.PROGRESS_URL)
|
||||
data = dict(parse_sse(await response.text()))["complete"]
|
||||
self.assertEqual(data["count"], 1)
|
||||
self.assertEqual(data["remaining"], 1)
|
||||
self.assertEqual(data["total_images"], 2)
|
||||
|
||||
async def test_equal_timestamps_do_not_regenerate_existing_thumbnails(self):
|
||||
source = make_png(self.output_dir / "ready.png")
|
||||
thumb = make_png(self.output_dir / "thumbnails" / "ready_thumb.png")
|
||||
timestamp = int(source.stat().st_mtime)
|
||||
os.utime(source, (timestamp, timestamp))
|
||||
os.utime(thumb, (timestamp, timestamp))
|
||||
response = await self.client.post(self.PROGRESS_URL)
|
||||
data = dict(parse_sse(await response.text()))["complete"]
|
||||
self.assertEqual(data["count"], 0)
|
||||
self.assertEqual(data["skipped"], 1)
|
||||
|
||||
|
||||
class TestGalleryRootIdentity(ThumbnailTestCase):
|
||||
async def test_colliding_original_and_thumbnail_urls_serve_their_own_roots(self):
|
||||
roots = [self.output_dir, self.comfy_dir / "second-output"]
|
||||
for index, root in enumerate(roots):
|
||||
make_png(root / "same.png", color=(index * 255, 0, 255 - index * 255))
|
||||
self.api._get_all_output_dirs = lambda: roots
|
||||
await self.client.post("/prompt_manager/images/generate-thumbnails", json={})
|
||||
data = await (await self.client.get("/prompt_manager/images/output")).json()
|
||||
self.assertEqual(len({image["url"] for image in data["images"]}), 2)
|
||||
self.assertEqual(len({image["thumbnail_url"] for image in data["images"]}), 2)
|
||||
for image in data["images"]:
|
||||
root = roots[image["root_index"]]
|
||||
for key, path in (
|
||||
("url", root / "same.png"),
|
||||
("thumbnail_url", root / "thumbnails" / "same_thumb.png"),
|
||||
):
|
||||
response = await self.client.get(image[key])
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual(await response.read(), path.read_bytes())
|
||||
|
||||
async def test_root_scoped_prompt_lookup_uses_the_matching_database_image(self):
|
||||
roots = [self.output_dir, self.comfy_dir / "second-output"]
|
||||
for index, root in enumerate(roots):
|
||||
self._link_image(make_png(root / "same.png"), text=f"root {index} prompt")
|
||||
self.api._get_all_output_dirs = lambda: roots
|
||||
for index in range(2):
|
||||
response = await self.client.get(
|
||||
f"/prompt_manager/images/prompt/same.png?root={index}"
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
data = await response.json()
|
||||
self.assertEqual(data["prompt"]["text"], f"root {index} prompt")
|
||||
|
||||
async def test_special_characters_in_paths_survive_serving_and_prompt_lookup(self):
|
||||
from urllib.parse import quote
|
||||
|
||||
filenames = ["space #%20 café.png"]
|
||||
if os.name != "nt":
|
||||
filenames.append("space #?%20 café.png")
|
||||
for filename in filenames:
|
||||
self._link_image(make_png(self.output_dir / filename), text="special path")
|
||||
for filename in filenames:
|
||||
with self.subTest(filename=filename):
|
||||
original = self.output_dir / filename
|
||||
data = await (
|
||||
await self.client.get("/prompt_manager/images/output")
|
||||
).json()
|
||||
entry = next(
|
||||
image for image in data["images"] if image["filename"] == filename
|
||||
)
|
||||
response = await self.client.get(entry["url"])
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual(await response.read(), original.read_bytes())
|
||||
response = await self.client.get(
|
||||
f"/prompt_manager/images/prompt/{quote(filename, safe='')}?root=0"
|
||||
)
|
||||
data = await response.json()
|
||||
self.assertEqual(data["prompt"]["text"], "special path")
|
||||
|
||||
async def test_invalid_explicit_roots_never_fall_back_to_another_image(self):
|
||||
self._link_image(make_png(self.output_dir / "same.png"), text="first root")
|
||||
for root in ("bad", "-1", "7", ""):
|
||||
for route in ("serve", "prompt"):
|
||||
with self.subTest(root=root, route=route):
|
||||
response = await self.client.get(
|
||||
f"/prompt_manager/images/{route}/same.png?root={root}"
|
||||
)
|
||||
self.assertEqual(response.status, 403)
|
||||
|
||||
async def test_explicit_root_prompt_lookup_rejects_paths_outside_that_root(self):
|
||||
from urllib.parse import quote
|
||||
|
||||
outside = make_png(self.comfy_dir / "outside.png")
|
||||
self._link_image(outside, text="outside")
|
||||
response = await self.client.get(
|
||||
f"/prompt_manager/images/prompt/{quote(str(outside), safe='')}?root=0"
|
||||
)
|
||||
self.assertEqual(response.status, 403)
|
||||
|
||||
async def test_unlinked_image_does_not_inherit_a_prompt_from_another_root(self):
|
||||
second = self.comfy_dir / "second-output"
|
||||
self._link_image(make_png(self.output_dir / "same.png"), text="first root")
|
||||
make_png(second / "same.png")
|
||||
self.api._get_all_output_dirs = lambda: [self.output_dir, second]
|
||||
response = await self.client.get(
|
||||
"/prompt_manager/images/prompt/same.png?root=1"
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertFalse((await response.json())["success"])
|
||||
|
||||
@@ -894,49 +894,3 @@ class TestBulkRoutes(RouteCoverageCase):
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class TestPromptEditHash(PromptAPITestCase):
|
||||
async def test_edit_moves_the_hash_and_original_text_can_be_saved_again(self):
|
||||
original_id = self._save_prompt("Original landscape")
|
||||
response = await self.client.put(
|
||||
f"/prompt_manager/prompts/{original_id}", json={"text": "Edited portrait"}
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
row = self.api.db.get_prompt_by_id(original_id)
|
||||
self.assertEqual(row["hash"], generate_prompt_hash("Edited portrait"))
|
||||
self.assertIsNone(
|
||||
self.api.db.get_prompt_by_hash(generate_prompt_hash("Original landscape"))
|
||||
)
|
||||
response = await self.client.post(
|
||||
"/prompt_manager/save", json={"text": "Original landscape"}
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertNotEqual((await response.json())["prompt_id"], original_id)
|
||||
response = await self.client.post(
|
||||
"/prompt_manager/save", json={"text": "Edited portrait"}
|
||||
)
|
||||
self.assertEqual((await response.json())["prompt_id"], original_id)
|
||||
|
||||
async def test_duplicate_edit_returns_conflict_without_changing_either_prompt(self):
|
||||
first = self._save_prompt("Original landscape", tags=["landscape"])
|
||||
second = self._save_prompt("Existing portrait", tags=["portrait"])
|
||||
before = [self.api.db.get_prompt_by_id(pid) for pid in (first, second)]
|
||||
response = await self.client.put(
|
||||
f"/prompt_manager/prompts/{first}", json={"text": " EXISTING PORTRAIT "}
|
||||
)
|
||||
self.assertEqual(response.status, 409)
|
||||
self.assertFalse((await response.json())["success"])
|
||||
self.assertEqual(
|
||||
[self.api.db.get_prompt_by_id(pid) for pid in (first, second)], before
|
||||
)
|
||||
|
||||
async def test_edit_with_same_normalized_hash_keeps_its_identity(self):
|
||||
pid = self._save_prompt("Original landscape")
|
||||
response = await self.client.put(
|
||||
f"/prompt_manager/prompts/{pid}", json={"text": "ORIGINAL LANDSCAPE"}
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual(
|
||||
self.api.db.get_prompt_by_id(pid)["text"], "ORIGINAL LANDSCAPE"
|
||||
)
|
||||
|
||||
@@ -263,35 +263,5 @@ class TestConfiguration(IsolatedLoggerTestCase):
|
||||
self.manager.update_config({"console_logging": False})
|
||||
|
||||
|
||||
class TestConsoleNoise(IsolatedLoggerTestCase):
|
||||
"""ComfyUI's console only sees warnings unless the level is DEBUG."""
|
||||
|
||||
def _console_handler(self) -> logging.StreamHandler:
|
||||
return next(
|
||||
h
|
||||
for h in logging.getLogger("prompt_manager").handlers
|
||||
if type(h) is logging.StreamHandler
|
||||
)
|
||||
|
||||
def test_records_do_not_propagate_to_comfyui_root_logger(self) -> None:
|
||||
self.assertFalse(logging.getLogger("prompt_manager").propagate)
|
||||
|
||||
def test_console_hides_info_at_the_default_level(self) -> None:
|
||||
self.manager.update_config({"console_logging": True})
|
||||
self.assertEqual(self._console_handler().level, logging.WARNING)
|
||||
self.logger.info("startup chatter")
|
||||
self.assertEqual(self.manager.get_recent_logs(limit=1)[0]["level"], "INFO")
|
||||
log_file = pathlib.Path(self.log_dir) / "prompt_manager.log"
|
||||
self.assertIn("startup chatter", log_file.read_text())
|
||||
self.manager.update_config({"console_logging": False})
|
||||
|
||||
def test_debug_level_opens_the_console(self) -> None:
|
||||
self.manager.update_config({"console_logging": True, "level": "DEBUG"})
|
||||
self.assertEqual(self._console_handler().level, logging.DEBUG)
|
||||
self.manager.update_config({"level": "INFO"})
|
||||
self.assertEqual(self._console_handler().level, logging.WARNING)
|
||||
self.manager.update_config({"console_logging": False})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
|
||||
test('browser coverage maps nested JS/MJS and excludes unrelated and traversal paths', async t => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'pm-browser-coverage-'));
|
||||
const previous = process.env.PM_JS_COVERAGE;
|
||||
process.env.PM_JS_COVERAGE = directory;
|
||||
t.after(() => {
|
||||
if (previous === undefined) delete process.env.PM_JS_COVERAGE;
|
||||
else process.env.PM_JS_COVERAGE = previous;
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
const urls = [
|
||||
'/prompt_manager/js/gallery.js',
|
||||
'/prompt_manager/js/shared/controls/button.mjs?v=1',
|
||||
'/prompt_manager/js/shared/panel.js',
|
||||
'/scripts/app.js', '/prompt_manager/js/../private.js',
|
||||
'/prompt_manager/js/shared/theme.css',
|
||||
];
|
||||
await saveCoverage({ coverage: { stopJSCoverage: async () => urls.map(url => ({ url: `http://127.0.0.1:8765${url}`, functions: [] })) } }, 'chromium');
|
||||
const report = JSON.parse(readFileSync(join(directory, readdirSync(directory)[0]), 'utf8'));
|
||||
const root = fileURLToPath(new URL('../../web/js/', import.meta.url));
|
||||
assert.deepEqual(report.result.map(entry => fileURLToPath(entry.url)), [join(root, 'gallery.js'), join(root, 'shared/controls/button.mjs'), join(root, 'shared/panel.js')]);
|
||||
});
|
||||
Vendored
-15
@@ -1,15 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = fileURLToPath(new URL('../../', import.meta.url));
|
||||
test('real-ComfyUI suite registers Classic and Nodes 2.0 startup projects', () => {
|
||||
const output = execFileSync(process.execPath, ['tools/vendor/node_modules/@playwright/test/cli.js',
|
||||
'test', '--config', 'tools/vendor/comfyui.config.mjs', '--list'], {
|
||||
cwd: root, encoding: 'utf8', env: { ...process.env,
|
||||
PM_COMFY_ROOT: '/unused-pinned-checkout', PM_COMFY_URL: 'http://127.0.0.1:8199/' },
|
||||
});
|
||||
assert.match(output, /\[classic-start\]/);
|
||||
assert.match(output, /\[nodes2-start\]/);
|
||||
});
|
||||
Vendored
-61
@@ -1,61 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtemp, writeFile, mkdir, readFile, realpath, rm, access } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { once } from 'node:events';
|
||||
import { launchComfyHost } from '../../tools/vendor/comfyui-host.mjs';
|
||||
|
||||
async function fixture(t, program) {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'pm-host-test-'));
|
||||
t.after(() => rm(directory, { recursive: true, force: true }));
|
||||
const checkout = join(directory, 'host');
|
||||
const repository = join(directory, 'plugin');
|
||||
await mkdir(checkout);
|
||||
await mkdir(repository);
|
||||
await writeFile(join(repository, '__init__.py'), '');
|
||||
execFileSync('git', ['init', '--quiet', repository]);
|
||||
execFileSync('git', ['-C', repository, 'add', '__init__.py']);
|
||||
await writeFile(join(repository, 'config.json'), 'private-user-config');
|
||||
await writeFile(join(repository, 'prompts.db'), 'private-user-database');
|
||||
await writeFile(join(checkout, 'main.py'), program);
|
||||
const git = args => execFileSync('git', ['-C', checkout, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
||||
git(['init', '--quiet']);
|
||||
git(['add', 'main.py']);
|
||||
git(['-c', 'user.name=PromptManager Tests', '-c', 'user.email=tests@example.invalid', 'commit', '--quiet', '-m', 'fixture']);
|
||||
return { checkout, repository, python: process.execPath, port: 8199,
|
||||
pins: { revision: git(['rev-parse', 'HEAD']) }, logPath: join(directory, 'host.log'), forwardOutput: false };
|
||||
}
|
||||
|
||||
const idle = `console.log('ready'); setInterval(() => {}, 1000);`;
|
||||
|
||||
test('PromptManager adapter isolates database, configuration and monitored directories', async t => {
|
||||
const options = await fixture(t, idle);
|
||||
const host = await launchComfyHost(options);
|
||||
t.after(() => host.stop());
|
||||
await once(host.child.stdout, 'data');
|
||||
const args = host.child.spawnargs;
|
||||
for (const flag of ['--cpu', '--offline', '--disable-all-custom-nodes']) assert.ok(args.includes(flag));
|
||||
assert.equal(args[args.indexOf('--base-directory') + 1], host.stateDirectory);
|
||||
assert.equal(args[args.indexOf('--listen') + 1], '127.0.0.1');
|
||||
assert.equal(args[args.indexOf('--whitelist-custom-nodes') + 1], 'promptmanager');
|
||||
const installed = join(host.stateDirectory, 'custom_nodes/promptmanager');
|
||||
assert.notEqual(await realpath(installed), await realpath(options.repository));
|
||||
assert.equal(await readFile(join(installed, '__init__.py'), 'utf8'), '');
|
||||
await assert.rejects(access(join(installed, 'prompts.db')), { code: 'ENOENT' });
|
||||
assert.equal(JSON.parse(await readFile(join(installed, 'config.json'), 'utf8')).database.default_path, join(host.stateDirectory, 'prompts.db'));
|
||||
await writeFile(join(installed, 'prompts.db'), 'test data');
|
||||
assert.equal(await readFile(join(options.repository, 'prompts.db'), 'utf8'), 'private-user-database');
|
||||
assert.equal(await readFile(join(options.repository, 'config.json'), 'utf8'), 'private-user-config');
|
||||
const config = JSON.parse(await readFile(join(installed, 'config.json'), 'utf8'));
|
||||
assert.equal(config.gallery.monitoring.enabled, false);
|
||||
assert.deepEqual(config.gallery.monitoring.directories, [join(host.stateDirectory, 'output')]);
|
||||
assert.equal(config.web_ui.result_timeout, 0);
|
||||
await access(join(host.stateDirectory, 'user'));
|
||||
await host.stop();
|
||||
await assert.rejects(access(host.stateDirectory), { code: 'ENOENT' });
|
||||
await access(join(options.checkout, 'main.py'));
|
||||
await access(join(options.repository, '__init__.py'));
|
||||
});
|
||||
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
const sourceRoot = join(process.cwd(), 'web/js');
|
||||
import { checkCoverage, discoverSources } from '../../tools/vendor/frontend-coverage.mjs';
|
||||
const metrics = value => Object.fromEntries(['lines', 'branches', 'functions', 'statements'].map(key => [key, { pct: value }]));
|
||||
const summary = { total: metrics(90), [join(sourceRoot, 'a.js')]: metrics(85) };
|
||||
const baseline = { 'a.js': { lines: 80, branches: 80, functions: 80, statements: 80 } };
|
||||
test('coverage gate accepts measured files at or above their reviewed floor', () => {
|
||||
assert.deepEqual(checkCoverage(summary, baseline, sourceRoot, ['a.js']), []);
|
||||
});
|
||||
test('coverage gate rejects a regression even when the aggregate percentage is high', () => {
|
||||
assert.match(checkCoverage({ ...summary, [join(sourceRoot, 'a.js')]: metrics(70) }, baseline, sourceRoot, ['a.js']).join('\n'), /a.js.*lines/);
|
||||
});
|
||||
test('coverage gate rejects absent source files and unreviewed new files', () => {
|
||||
assert.match(checkCoverage({ total: metrics(100) }, baseline, sourceRoot, ['a.js']).join('\n'), /missing.*a.js/i);
|
||||
assert.match(checkCoverage(summary, baseline, sourceRoot, ['a.js', 'new.js']).join('\n'), /baseline.*new.js/i);
|
||||
});
|
||||
test('coverage gate rejects invalid baselines and missing metrics', () => {
|
||||
assert.throws(() => checkCoverage(summary, { 'a.js': { lines: -1 } }, sourceRoot, ['a.js']), /baseline/i);
|
||||
assert.match(checkCoverage({ total: metrics(100), [join(sourceRoot, 'a.js')]: {} }, baseline, sourceRoot, ['a.js']).join('\n'), /lines/);
|
||||
});
|
||||
|
||||
test('coverage discovers nested JS and MJS while excluding non-source files', t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'pm-coverage-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
mkdirSync(join(root, 'shared/controls'), { recursive: true });
|
||||
for (const file of ['entry.js', 'shared/core.mjs', 'shared/controls/button.js', 'shared/theme.css', 'shared/notes.txt']) writeFileSync(join(root, file), '');
|
||||
assert.deepEqual(discoverSources(root), ['entry.js', 'shared/controls/button.js', 'shared/core.mjs']);
|
||||
assert.match(checkCoverage({}, {}, root, discoverSources(root)).join('\n'), /baseline.*shared\/controls\/button.js/i);
|
||||
});
|
||||
Vendored
-62
@@ -1,62 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { syncToolkit } from '../../tools/vendor/vendor-toolkit.mjs';
|
||||
|
||||
function fixture(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'pm-toolkit-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const tooling = join(root, 'tools/vendor');
|
||||
const dependency = join(tooling, 'node_modules/@comfyassets/toolkit');
|
||||
mkdirSync(join(dependency, 'ui'), { recursive: true });
|
||||
const resolved = 'https://codeload.github.com/ComfyAssets/ComfyAssets-Toolkit/tar.gz/' + 'a'.repeat(40);
|
||||
writeFileSync(join(tooling, 'package.json'), JSON.stringify({ devDependencies: { '@comfyassets/toolkit': resolved } }));
|
||||
writeFileSync(join(tooling, 'package-lock.json'), JSON.stringify({ packages: { 'node_modules/@comfyassets/toolkit': { version: '0.1.0', resolved, integrity: 'sha512-fixture' } } }));
|
||||
writeFileSync(join(dependency, 'package.json'), JSON.stringify({ name: '@comfyassets/toolkit', version: '0.1.0', license: 'MIT' }));
|
||||
for (const file of ['ui/core.js', 'ui/controls.css', 'LICENSE', 'SOURCE.md']) writeFileSync(join(dependency, file), file);
|
||||
return { root, tooling, dependency, destination: join(root, 'web/js/shared/toolkit') };
|
||||
}
|
||||
|
||||
test('toolkit browser assets ship unchanged with deterministic locked provenance', t => {
|
||||
const f = fixture(t);
|
||||
syncToolkit({ root: f.root });
|
||||
for (const [name, source] of Object.entries({ 'core.js': 'ui/core.js', 'controls.css': 'ui/controls.css', LICENSE: 'LICENSE', 'SOURCE.md': 'SOURCE.md' })) assert.deepEqual(readFileSync(join(f.destination, name)), readFileSync(join(f.dependency, source)));
|
||||
const first = readFileSync(join(f.destination, 'provenance.json'), 'utf8');
|
||||
assert.equal(JSON.parse(first).integrity, 'sha512-fixture');
|
||||
syncToolkit({ root: f.root });
|
||||
assert.equal(readFileSync(join(f.destination, 'provenance.json'), 'utf8'), first);
|
||||
syncToolkit({ root: f.root, check: true });
|
||||
writeFileSync(join(f.destination, 'core.js'), 'local edit');
|
||||
assert.throws(() => syncToolkit({ root: f.root, check: true }), /drift/);
|
||||
assert.equal(readFileSync(join(f.destination, 'core.js'), 'utf8'), 'local edit');
|
||||
});
|
||||
|
||||
test('toolkit sync rejects moving pins, identity mismatches and missing sources before writing', t => {
|
||||
const f = fixture(t);
|
||||
writeFileSync(join(f.tooling, 'package.json'), JSON.stringify({ devDependencies: { '@comfyassets/toolkit': 'main' } }));
|
||||
assert.throws(() => syncToolkit({ root: f.root }), /pin/);
|
||||
const second = fixture(t);
|
||||
writeFileSync(join(second.dependency, 'package.json'), JSON.stringify({ name: '@comfyassets/toolkit', version: '0.2.0', license: 'MIT' }));
|
||||
assert.throws(() => syncToolkit({ root: second.root }), /identity/);
|
||||
const third = fixture(t);
|
||||
rmSync(join(third.dependency, 'ui/controls.css'));
|
||||
assert.throws(() => syncToolkit({ root: third.root }), /ENOENT/);
|
||||
assert.throws(() => readFileSync(join(third.destination, 'core.js')), /ENOENT/);
|
||||
});
|
||||
|
||||
test('toolkit sync rejects lookalike download hosts and archive paths before writing', t => {
|
||||
for (const prefix of [
|
||||
'https://codeloadXgithub.com/ComfyAssets/ComfyAssets-Toolkit/tar.gz/',
|
||||
'https://codeload.githubXcom/ComfyAssets/ComfyAssets-Toolkit/tar.gz/',
|
||||
'https://codeload.github.com/ComfyAssets/ComfyAssets-Toolkit/tarXgz/',
|
||||
]) {
|
||||
const f = fixture(t);
|
||||
const resolved = prefix + 'a'.repeat(40);
|
||||
writeFileSync(join(f.tooling, 'package.json'), JSON.stringify({ devDependencies: { '@comfyassets/toolkit': resolved } }));
|
||||
writeFileSync(join(f.tooling, 'package-lock.json'), JSON.stringify({ packages: { 'node_modules/@comfyassets/toolkit': { version: '0.1.0', resolved, integrity: 'sha512-fixture' } } }));
|
||||
assert.throws(() => syncToolkit({ root: f.root }), /immutable commit pin/);
|
||||
assert.throws(() => readFileSync(join(f.destination, 'core.js')), /ENOENT/);
|
||||
}
|
||||
});
|
||||
-107
@@ -1,107 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { syncViewer } from '../../tools/vendor/vendor-viewerjs.mjs';
|
||||
import { updateViewer } from '../../tools/vendor/update-viewerjs.mjs';
|
||||
|
||||
function fixture(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'pm-vendor-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const tooling = join(root, 'tools/vendor');
|
||||
const dependency = join(tooling, 'node_modules/viewerjs');
|
||||
mkdirSync(join(dependency, 'dist'), { recursive: true });
|
||||
writeFileSync(join(tooling, 'package.json'), JSON.stringify({ devDependencies: { viewerjs: '1.15.2' } }));
|
||||
writeFileSync(join(tooling, 'package-lock.json'), JSON.stringify({ packages: {
|
||||
'node_modules/viewerjs': { version: '1.15.2', resolved: 'https://registry.npmjs.org/viewerjs/-/viewerjs-1.15.2.tgz', integrity: 'sha512-fixture' },
|
||||
} }));
|
||||
writeFileSync(join(dependency, 'package.json'), JSON.stringify({ name: 'viewerjs', version: '1.15.2', license: 'MIT' }));
|
||||
for (const name of ['viewer.min.js', 'viewer.min.css']) {
|
||||
writeFileSync(join(dependency, 'dist', name), `/*! Viewer.js v1.15.2 */\n${name}`);
|
||||
}
|
||||
writeFileSync(join(dependency, 'LICENSE'), 'The MIT License (MIT)\nCopyright Chen Fengyuan\n');
|
||||
return { root, tooling, dependency, destination: join(root, 'web/lib/viewerjs') };
|
||||
}
|
||||
|
||||
test('sync copies pristine assets and license and records locked provenance deterministically', t => {
|
||||
const f = fixture(t);
|
||||
syncViewer({ root: f.root });
|
||||
const first = readFileSync(join(f.destination, 'provenance.json'), 'utf8');
|
||||
const record = JSON.parse(first);
|
||||
assert.equal(record.version, '1.15.2');
|
||||
assert.equal(record.integrity, 'sha512-fixture');
|
||||
for (const [target, source] of Object.entries({ 'viewer.min.js': 'dist/viewer.min.js', 'viewer.min.css': 'dist/viewer.min.css', LICENSE: 'LICENSE' })) {
|
||||
const original = readFileSync(join(f.dependency, source));
|
||||
assert.deepEqual(readFileSync(join(f.destination, target)), original);
|
||||
assert.equal(record.files[target].sha256, createHash('sha256').update(original).digest('hex'));
|
||||
}
|
||||
syncViewer({ root: f.root });
|
||||
assert.equal(readFileSync(join(f.destination, 'provenance.json'), 'utf8'), first);
|
||||
syncViewer({ root: f.root, check: true });
|
||||
});
|
||||
|
||||
test('check rejects drift without overwriting local changes', t => {
|
||||
const f = fixture(t);
|
||||
syncViewer({ root: f.root });
|
||||
writeFileSync(join(f.destination, 'viewer.min.js'), 'local edit');
|
||||
assert.throws(() => syncViewer({ root: f.root, check: true }), /viewer.min.js/);
|
||||
assert.equal(readFileSync(join(f.destination, 'viewer.min.js'), 'utf8'), 'local edit');
|
||||
rmSync(join(f.destination, 'LICENSE'));
|
||||
assert.throws(() => syncViewer({ root: f.root, check: true }), /LICENSE/);
|
||||
});
|
||||
|
||||
test('sync refuses mismatched installed versions and unpinned dependencies', t => {
|
||||
const f = fixture(t);
|
||||
writeFileSync(join(f.dependency, 'package.json'), JSON.stringify({ name: 'viewerjs', version: '1.11.6', license: 'MIT' }));
|
||||
assert.throws(() => syncViewer({ root: f.root }), /version/i);
|
||||
writeFileSync(join(f.tooling, 'package.json'), JSON.stringify({ devDependencies: { viewerjs: '^1.15.2' } }));
|
||||
assert.throws(() => syncViewer({ root: f.root }), /exact|pin/i);
|
||||
});
|
||||
|
||||
test('sync validates every upstream asset before writing anything', t => {
|
||||
const f = fixture(t);
|
||||
writeFileSync(join(f.dependency, 'dist/viewer.min.css'), '/*! Viewer.js v1.11.6 */');
|
||||
assert.throws(() => syncViewer({ root: f.root }), /version|banner/i);
|
||||
assert.throws(() => readFileSync(join(f.destination, 'viewer.min.js')), /ENOENT/);
|
||||
});
|
||||
|
||||
test('updater resolves the stable release and installs it exactly with lifecycle scripts disabled', t => {
|
||||
const f = fixture(t);
|
||||
const calls = [];
|
||||
let synced = false;
|
||||
const run = (args, options) => {
|
||||
calls.push({ args, options });
|
||||
return args[0] === 'view' ? '"1.15.3"' : '';
|
||||
};
|
||||
assert.equal(updateViewer({ root: f.root, run, sync: () => { synced = true; } }), '1.15.3');
|
||||
assert.deepEqual(calls[1].args, ['install', '--save-dev', '--save-exact', '--ignore-scripts', 'viewerjs@1.15.3']);
|
||||
assert.equal(calls[1].options.cwd, f.tooling);
|
||||
assert.equal(synced, true);
|
||||
});
|
||||
|
||||
test('updater is a no-op at the current release and rejects invalid version arguments', t => {
|
||||
const f = fixture(t);
|
||||
const run = () => { throw new Error('must not install'); };
|
||||
assert.equal(updateViewer({ root: f.root, version: '1.15.2', run }), null);
|
||||
for (const version of ['latest', '1.15.3-beta.1', '1.15.3;anything', '--global']) {
|
||||
assert.throws(() => updateViewer({ root: f.root, version, run }), /version/i);
|
||||
}
|
||||
});
|
||||
|
||||
test('failed installation never copies vendor assets', t => {
|
||||
const f = fixture(t);
|
||||
let synced = false;
|
||||
assert.throws(() => updateViewer({ root: f.root, version: '1.15.3', run: () => { throw new Error('offline'); }, sync: () => { synced = true; } }), /offline/);
|
||||
assert.equal(synced, false);
|
||||
});
|
||||
|
||||
test('updater accepts a single registry result array and rejects ambiguous metadata', t => {
|
||||
const f = fixture(t);
|
||||
const calls = [];
|
||||
const run = args => { calls.push(args); return '["1.15.2"]'; };
|
||||
assert.equal(updateViewer({ root: f.root, run }), null);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.throws(() => updateViewer({ root: f.root, run: () => '["1.15.2", "1.15.3"]' }), /version/i);
|
||||
});
|
||||
Vendored
-108
@@ -1,108 +0,0 @@
|
||||
# Viewer.js maintenance
|
||||
|
||||
ComfyUI serves the committed files in `web/lib/viewerjs/`. Node and npm are only
|
||||
needed by maintainers and CI. Keep upstream files pristine; application behavior
|
||||
belongs in `web/js/`, and theme overrides belong in `comfyui-theme.css`.
|
||||
|
||||
## Install and verify
|
||||
|
||||
Use Node 24 or newer. From the repository root:
|
||||
|
||||
```sh
|
||||
npm ci --prefix tools/vendor --ignore-scripts
|
||||
npm run --prefix tools/vendor check
|
||||
npm run --prefix tools/vendor test
|
||||
node --test tests/js/*.test.js
|
||||
npx --prefix tools/vendor playwright install chromium
|
||||
npm run --prefix tools/vendor test:browser
|
||||
```
|
||||
|
||||
On Linux, use `playwright install --with-deps chromium` when browser system
|
||||
libraries are missing. The browser tests use synthetic images and mock the
|
||||
ComfyUI API boundary; they run production viewer methods and the committed
|
||||
Viewer.js JS/CSS, without a running ComfyUI instance.
|
||||
|
||||
`check` is read-only and fails if any JS, CSS, license, or provenance file differs
|
||||
from the installed package. The manifest, lockfile, installed package, and asset
|
||||
version banners must agree. The lockfile records npm's archive integrity;
|
||||
`provenance.json` records the upstream source, package integrity, and SHA-256 of
|
||||
each copied file. A normal `npm ci --ignore-scripts` verifies archive integrity.
|
||||
|
||||
Full-page workflows, frontend coverage gates, and required merge checks are
|
||||
documented in [TESTING.md](../../TESTING.md). The `viewerjs` CI check runs both
|
||||
the fixture tests above and the real-page workflows on every PR.
|
||||
|
||||
## Update
|
||||
|
||||
```sh
|
||||
npm run --prefix tools/vendor update
|
||||
# Or select a specific stable version:
|
||||
npm run --prefix tools/vendor update -- --version 1.15.2
|
||||
```
|
||||
|
||||
The command installs an exact version with lifecycle scripts disabled and copies
|
||||
`dist/viewer.min.js`, `dist/viewer.min.css`, and `LICENSE`. It writes deterministic
|
||||
provenance with no timestamps. Repeat the checks above and commit the manifest,
|
||||
lockfile, and all vendor files together. To regenerate files from the current
|
||||
pin without changing versions, run `npm run --prefix tools/vendor sync`.
|
||||
|
||||
## Automated update PRs
|
||||
|
||||
`Update vendored Viewer.js` checks npm's stable release daily at 07:30 UTC and
|
||||
supports **Actions → Update vendored Viewer.js → Run workflow**, with an optional
|
||||
version. It tests the generated update before opening `deps/viewerjs-X.Y.Z`.
|
||||
Existing open or closed PRs for that release are left alone. A failed test does
|
||||
not publish a branch or PR. Changes require review; the workflow never merges
|
||||
or approves PRs.
|
||||
|
||||
The organization and repository must enable **Actions → General → Workflow
|
||||
permissions → Allow GitHub Actions to create and approve pull requests**.
|
||||
Keep default workflow permissions read-only. Only the update job requests
|
||||
`contents: write`, `pull-requests: write`, and `actions: write`.
|
||||
|
||||
The workflow uses `GITHUB_TOKEN`; no personal token is needed. Because bot-token
|
||||
pushes do not normally trigger other workflows, it explicitly dispatches the
|
||||
read-only `Viewer.js vendor and compatibility` workflow on the update branch.
|
||||
Both workflow files must be merged into the default branch before scheduling or
|
||||
manual dispatch becomes available.
|
||||
|
||||
## Compatibility checklist
|
||||
|
||||
Check gallery grid/list views, navigation across 100-image windows, metadata,
|
||||
slideshow/fullscreen, keyboard navigation, Escape and focus cleanup. Check the
|
||||
admin gallery/filmstrip and tag viewer. On touch devices, test long swipes,
|
||||
short drags, and pinch/rotate gestures. Confirm the ComfyUI theme remains usable.
|
||||
|
||||
The gallery keeps its bounded viewer and custom slideshow. Upstream navbar
|
||||
windowing is not a replacement for bounding the supplied image list: native
|
||||
slideshow playback can still preload every supplied original. Review new
|
||||
preloading defaults, gesture behavior, CSS changes, and any adapter use of
|
||||
Viewer.js internal methods before adopting another release.
|
||||
|
||||
## Shared ComfyAssets test tooling
|
||||
|
||||
The exact toolkit commit archive in `package.json` / `package-lock.json` supplies
|
||||
host orchestration and coverage helpers. `comfyui-host.mjs` is PromptManager's
|
||||
configuration adapter; keep reusable lifecycle changes in the toolkit source.
|
||||
The existing npm scripts remain the public maintainer entry points. See
|
||||
[TESTING.md](../../TESTING.md) for the managed real-host matrix and update policy.
|
||||
|
||||
## Shared ComfyAssets browser controls
|
||||
|
||||
Prompt Manager (Classic) consumes the same pinned toolkit's browser controls.
|
||||
The node identifier remains `PromptManager`; existing workflows, sockets, and
|
||||
immediate result selection are preserved. PromptManager Text is unchanged.
|
||||
|
||||
Committed assets in `web/js/shared/toolkit/` work without npm or KikoTools installed.
|
||||
Maintain common controls in [ComfyAssets Toolkit](https://github.com/ComfyAssets/ComfyAssets-Toolkit),
|
||||
then update the exact archive pin and lockfile here. Regenerate using
|
||||
`npm run --prefix tools/vendor sync:toolkit`; `npm run --prefix tools/vendor check`
|
||||
verifies both Viewer.js and the toolkit without rewriting files. The toolkit
|
||||
distribution includes its license, source history, archive integrity and per-file
|
||||
hashes. Do not edit its copied files directly. Classic's controller and layout
|
||||
remain in `web/js/classic-node.js` and `web/js/classic-node.css`.
|
||||
|
||||
Run the frontend coverage/browser suites and managed Classic/Nodes 2.0 matrix
|
||||
after updates. Shared modules participate in the frontend coverage gate.
|
||||
Pixaroma's reusable framework inspired this architecture; no Pixaroma code,
|
||||
styles or assets are included.
|
||||
Vendored
-18
@@ -1,18 +0,0 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { mapBrowserCoverage } from '@comfyassets/toolkit/testing/coverage';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { repositoryRoot } from './vendor-viewerjs.mjs';
|
||||
|
||||
export async function startCoverage(page, browserName) {
|
||||
if (process.env.PM_JS_COVERAGE && browserName === 'chromium') {
|
||||
await page.coverage.startJSCoverage({ resetOnNavigation: false });
|
||||
}
|
||||
}
|
||||
export async function saveCoverage(page, browserName) {
|
||||
if (!process.env.PM_JS_COVERAGE || browserName !== 'chromium') return;
|
||||
const entries = await page.coverage.stopJSCoverage();
|
||||
const result = mapBrowserCoverage(entries, { urlPrefix: '/prompt_manager/js/', sourceRoot: join(repositoryRoot, 'web/js') });
|
||||
mkdirSync(process.env.PM_JS_COVERAGE, { recursive: true });
|
||||
writeFileSync(join(process.env.PM_JS_COVERAGE, `browser-${randomUUID()}.json`), JSON.stringify({ result }));
|
||||
}
|
||||
Vendored
-39
@@ -1,39 +0,0 @@
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { launchComfyHost as launchSharedHost } from '@comfyassets/toolkit/testing/host';
|
||||
import runtime from './comfyui-runtime.json' with { type: 'json' };
|
||||
|
||||
const repositoryRoot = fileURLToPath(new URL('../../', import.meta.url));
|
||||
|
||||
export function launchComfyHost({ checkout, repository = repositoryRoot,
|
||||
python = process.env.PM_TEST_PYTHON || 'python', port = 8189, pins = runtime,
|
||||
logPath = join(repositoryRoot, 'tools/vendor/test-results/comfyui-host.log'), forwardOutput = true }) {
|
||||
return launchSharedHost({ checkout, repository, python, port, pins, logPath, forwardOutput,
|
||||
pluginName: 'promptmanager',
|
||||
async preparePlugin({ installed, stateDirectory }) {
|
||||
const configPath = join(installed, 'config.json');
|
||||
await writeFile(configPath, JSON.stringify({
|
||||
database: { default_path: join(stateDirectory, 'prompts.db') },
|
||||
gallery: { monitoring: { enabled: false, directories: [join(stateDirectory, 'output')] } },
|
||||
web_ui: { result_timeout: 0 },
|
||||
}), { mode: 0o600 });
|
||||
return { PROMPT_MANAGER_CONFIG_PATH: configPath, PROMPT_MANAGER_EXTRA_GALLERY_ROOTS: '' };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
||||
try {
|
||||
const host = await launchComfyHost({ checkout: process.env.PM_COMFY_ROOT,
|
||||
port: Number(process.env.PM_COMFY_PORT || 8189) });
|
||||
const stop = () => { host.stop().catch(error => { console.error(error.message); process.exitCode = 1; }); };
|
||||
process.once('SIGINT', stop);
|
||||
process.once('SIGTERM', stop);
|
||||
const result = await host.finished;
|
||||
process.removeListener('SIGINT', stop);
|
||||
process.removeListener('SIGTERM', stop);
|
||||
if (result.code) console.error(`ComfyUI host exited with status ${result.code}; see tools/vendor/test-results/comfyui-host.log`);
|
||||
process.exitCode = result.code;
|
||||
} catch (error) { console.error(error.message); process.exitCode = 1; }
|
||||
}
|
||||
Vendored
-9
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"repository": "Comfy-Org/ComfyUI",
|
||||
"revision": "e8665ca8e4089ab917567cdb491308e1397663d4",
|
||||
"serverVersion": "0.39.0",
|
||||
"frontendVersion": "1.55.14",
|
||||
"pythonVersion": "3.12",
|
||||
"torchVersion": "2.11.0",
|
||||
"torchvisionVersion": "0.26.0"
|
||||
}
|
||||
Vendored
-28
@@ -1,28 +0,0 @@
|
||||
import { defineConfig } from './node_modules/@playwright/test/index.mjs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
if (!process.env.PM_COMFY_ROOT) throw new Error('Set PM_COMFY_ROOT to a pinned disposable ComfyUI checkout');
|
||||
const host = new URL(process.env.PM_COMFY_URL || 'http://127.0.0.1:8189/');
|
||||
if (host.protocol !== 'http:' || host.hostname !== '127.0.0.1' || !host.port) throw new Error('A managed host needs an HTTP 127.0.0.1 URL with an explicit port');
|
||||
const servers = [{
|
||||
command: 'node tools/vendor/comfyui-host.mjs',
|
||||
cwd: fileURLToPath(new URL('../../', import.meta.url)),
|
||||
url: new URL('system_stats', host).href, timeout: 120000, reuseExistingServer: false,
|
||||
env: { PM_COMFY_PORT: host.port },
|
||||
gracefulShutdown: { signal: 'SIGTERM', timeout: 10000 },
|
||||
}];
|
||||
|
||||
export default defineConfig({
|
||||
testDir: '../../tests/comfyui',
|
||||
outputDir: './test-results/comfyui',
|
||||
timeout: 60000,
|
||||
workers: 1,
|
||||
forbidOnly: Boolean(process.env.CI),
|
||||
retries: 0,
|
||||
use: { baseURL: host.href, browserName: 'chromium', viewport: { width: 1600, height: 1100 }, trace: 'retain-on-failure', screenshot: 'only-on-failure' },
|
||||
webServer: servers,
|
||||
projects: [
|
||||
{ name: 'classic-start', metadata: { initialRenderer: false } },
|
||||
{ name: 'nodes2-start', metadata: { initialRenderer: true } },
|
||||
],
|
||||
});
|
||||
-116
@@ -1,116 +0,0 @@
|
||||
{
|
||||
"admin.js": {
|
||||
"lines": 28,
|
||||
"branches": 58,
|
||||
"functions": 23,
|
||||
"statements": 28
|
||||
},
|
||||
"api-client.js": {
|
||||
"lines": 98,
|
||||
"branches": 94,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"comfy-metadata.js": {
|
||||
"lines": 97,
|
||||
"branches": 90,
|
||||
"functions": 98,
|
||||
"statements": 97
|
||||
},
|
||||
"data-actions.js": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"gallery-settings.js": {
|
||||
"lines": 98,
|
||||
"branches": 90,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"gallery.js": {
|
||||
"lines": 39,
|
||||
"branches": 63,
|
||||
"functions": 43,
|
||||
"statements": 39
|
||||
},
|
||||
"html-escape.js": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"image-helpers.js": {
|
||||
"lines": 96,
|
||||
"branches": 91,
|
||||
"functions": 98,
|
||||
"statements": 96
|
||||
},
|
||||
"list-state.js": {
|
||||
"lines": 98,
|
||||
"branches": 92,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"png-metadata.js": {
|
||||
"lines": 98,
|
||||
"branches": 90,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"prompt-list-sort.js": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"scan-progress.js": {
|
||||
"lines": 97,
|
||||
"branches": 91,
|
||||
"functions": 98,
|
||||
"statements": 97
|
||||
},
|
||||
"sse-stream.js": {
|
||||
"lines": 98,
|
||||
"branches": 90,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"tag-autocomplete.js": {
|
||||
"lines": 98,
|
||||
"branches": 94,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"tags-page.js": {
|
||||
"lines": 57,
|
||||
"branches": 48,
|
||||
"functions": 56,
|
||||
"statements": 57
|
||||
},
|
||||
"theme-bridge.js": {
|
||||
"lines": 90,
|
||||
"branches": 53,
|
||||
"functions": 98,
|
||||
"statements": 90
|
||||
},
|
||||
"view-router.js": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"classic-node.js": {
|
||||
"lines": 98,
|
||||
"branches": 79,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"shared/toolkit/core.js": {
|
||||
"lines": 98,
|
||||
"branches": 96,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
}
|
||||
}
|
||||
Vendored
-60
@@ -1,60 +0,0 @@
|
||||
import { readFileSync, readdirSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join, relative } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { repositoryRoot } from './vendor-viewerjs.mjs';
|
||||
|
||||
import { discoverSources, checkCoverage } from '@comfyassets/toolkit/testing/coverage';
|
||||
export { discoverSources, checkCoverage };
|
||||
const metrics = ['lines', 'branches', 'functions', 'statements'];
|
||||
|
||||
function run(args, env = {}) {
|
||||
const result = spawnSync(process.execPath, args, { cwd: repositoryRoot, stdio: 'inherit',
|
||||
env: { ...process.env, ...env } });
|
||||
if (result.status !== 0) throw new Error(`Command failed (${result.status}): ${args.join(' ')}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const tools = dirname(fileURLToPath(import.meta.url));
|
||||
const reportRoot = join(repositoryRoot, 'coverage/js');
|
||||
const raw = join(reportRoot, 'raw');
|
||||
const sourceRoot = join(repositoryRoot, 'web/js');
|
||||
const files = discoverSources(sourceRoot);
|
||||
rmSync(reportRoot, { recursive: true, force: true });
|
||||
mkdirSync(raw, { recursive: true });
|
||||
const tests = readdirSync(join(repositoryRoot, 'tests/js')).filter(file => file.endsWith('.test.js')).map(file => `tests/js/${file}`);
|
||||
run(['--test', ...tests], { NODE_V8_COVERAGE: raw });
|
||||
const playwright = join(tools, 'node_modules/@playwright/test/cli.js');
|
||||
run([playwright, 'test', '--config', join(tools, 'playwright.config.mjs')]);
|
||||
run([playwright, 'test', '--config', join(tools, 'playwright.workflows.config.mjs'),
|
||||
'--project', 'chromium', '--project', 'touch'], { PM_JS_COVERAGE: raw });
|
||||
run([join(tools, 'node_modules/c8/bin/c8.js'), 'report', '--all', '--src', sourceRoot,
|
||||
'--include', 'web/js/**/*.js', '--include', 'web/js/**/*.mjs', '--temp-directory', raw, '--reports-dir', reportRoot,
|
||||
'--reporter', 'text', '--reporter', 'json-summary', '--reporter', 'html']);
|
||||
const summary = JSON.parse(readFileSync(join(reportRoot, 'coverage-summary.json'), 'utf8'));
|
||||
const baselinePath = join(tools, 'frontend-coverage-baseline.json');
|
||||
if (process.argv.includes('--update-baseline')) {
|
||||
const previous = JSON.parse(readFileSync(baselinePath, 'utf8'));
|
||||
const failures = checkCoverage(summary, previous, sourceRoot, files.filter(file => previous[file]));
|
||||
if (failures.length) throw new Error(`Baseline updates cannot lower coverage:\n${failures.join('\n')}`);
|
||||
const next = Object.fromEntries(files.map(file => {
|
||||
const measured = summary[join(sourceRoot, file)];
|
||||
if (!measured || metrics.some(metric => !Number.isFinite(measured[metric]?.pct))) {
|
||||
throw new Error(`Missing coverage for ${file}`);
|
||||
}
|
||||
if (Object.keys(previous).length && !previous[file] && measured.lines.pct < 80) {
|
||||
throw new Error(`New frontend files need at least 80% line coverage: ${file}`);
|
||||
}
|
||||
return [file, Object.fromEntries(metrics.map(metric => [metric, Math.max(previous[file]?.[metric] || 0, Math.floor(measured[metric].pct) - 2, 0)]))];
|
||||
}));
|
||||
writeFileSync(baselinePath, JSON.stringify(next, null, 2) + '\n');
|
||||
} else {
|
||||
const baseline = JSON.parse(readFileSync(baselinePath, 'utf8'));
|
||||
const failures = checkCoverage(summary, baseline, sourceRoot, files);
|
||||
if (failures.length) throw new Error(`Frontend coverage regressed:\n${failures.join('\n')}`);
|
||||
}
|
||||
console.log(`Frontend coverage passed for ${files.length} files. Report: ${relative(repositoryRoot, reportRoot)}/index.html`);
|
||||
}
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main().catch(error => { console.error(error.message); process.exitCode = 1; });
|
||||
}
|
||||
-803
@@ -1,803 +0,0 @@
|
||||
{
|
||||
"name": "promptmanager-vendor-tools",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "promptmanager-vendor-tools",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"@comfyassets/toolkit": "https://codeload.github.com/ComfyAssets/ComfyAssets-Toolkit/tar.gz/66644f5620e10efeaba090dca2d5be54baa2ee14",
|
||||
"@playwright/test": "1.64.1",
|
||||
"c8": "12.0.0",
|
||||
"viewerjs": "1.15.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@bcoe/v8-coverage": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
|
||||
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@comfyassets/toolkit": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://codeload.github.com/ComfyAssets/ComfyAssets-Toolkit/tar.gz/66644f5620e10efeaba090dca2d5be54baa2ee14",
|
||||
"integrity": "sha512-jvDj3iYJHjRCH7nZimoF4KNLwEVdzKVQIOZrMfoLg9aLnHm98R8YvBUOvnlJ+JNrM8/LnD2m30u3ggSTnR0/sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
}
|
||||
},
|
||||
"node_modules/@istanbuljs/schema": {
|
||||
"version": "0.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz",
|
||||
"integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
|
||||
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.1.0",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.64.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz",
|
||||
"integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.64.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/istanbul-lib-coverage": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
||||
"integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "6.4.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz",
|
||||
"integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
|
||||
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/c8": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/c8/-/c8-12.0.0.tgz",
|
||||
"integrity": "sha512-4zpJvrd1nKWutnnKC2pXkFmb6iM1l+ffN//o1CzlTNwW7GSOs9a1xrLqkC48nU8oEkjmPZLPiwMsIaOvoF4Pqg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@bcoe/v8-coverage": "^1.0.1",
|
||||
"@istanbuljs/schema": "^0.1.3",
|
||||
"find-up": "^5.0.0",
|
||||
"foreground-child": "^3.1.1",
|
||||
"istanbul-lib-coverage": "^3.2.0",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
"istanbul-reports": "^3.1.6",
|
||||
"test-exclude": "^8.0.0",
|
||||
"v8-to-istanbul": "^9.0.0",
|
||||
"yargs": "^18.0.0",
|
||||
"yargs-parser": "^21.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"c8": "bin/c8.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=23"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"monocart-coverage-reports": "^2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"monocart-coverage-reports": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
|
||||
"integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^7.2.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"wrap-ansi": "^9.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/string-width": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
||||
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^10.3.0",
|
||||
"get-east-asian-width": "^1.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
"shebang-command": "^2.0.0",
|
||||
"which": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
|
||||
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
|
||||
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^6.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/foreground-child": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"cross-spawn": "^7.0.6",
|
||||
"signal-exit": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/get-east-asian-width": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz",
|
||||
"integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "13.0.6",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
|
||||
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"minimatch": "^10.2.2",
|
||||
"minipass": "^7.1.3",
|
||||
"path-scurry": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/html-escaper": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/istanbul-lib-coverage": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
|
||||
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-lib-report": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
|
||||
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"istanbul-lib-coverage": "^3.0.0",
|
||||
"make-dir": "^4.0.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-reports": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
|
||||
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"html-escaper": "^2.0.0",
|
||||
"istanbul-lib-report": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "11.5.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
|
||||
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/make-dir": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
||||
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"semver": "^7.5.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "10.2.6",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
||||
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
||||
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"yocto-queue": "^0.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-locate": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
|
||||
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^3.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-scurry": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
|
||||
"integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"lru-cache": "^11.0.0",
|
||||
"minipass": "^7.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.64.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
|
||||
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.64.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.64.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
|
||||
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-regex": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/signal-exit": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz",
|
||||
"integrity": "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-east-asian-width": "^1.5.0",
|
||||
"strip-ansi": "^7.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
||||
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz",
|
||||
"integrity": "sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@istanbuljs/schema": "^0.1.2",
|
||||
"glob": "^13.0.6",
|
||||
"minimatch": "^10.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/v8-to-istanbul": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
|
||||
"integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "^0.3.12",
|
||||
"@types/istanbul-lib-coverage": "^2.0.1",
|
||||
"convert-source-map": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/viewerjs": {
|
||||
"version": "1.15.2",
|
||||
"resolved": "https://registry.npmjs.org/viewerjs/-/viewerjs-1.15.2.tgz",
|
||||
"integrity": "sha512-CYEqSFfPUSnmn1PjrhYxXOScaUC7n8+NpSqI+MrOSM8wcKPe35MlwQYwksuD5rjFkYv/ePmY5NsUa51uRNN2jA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"node-which": "bin/node-which"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
|
||||
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.2.1",
|
||||
"string-width": "^7.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi/node_modules/string-width": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
||||
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^10.3.0",
|
||||
"get-east-asian-width": "^1.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "18.2.0",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.2.0.tgz",
|
||||
"integrity": "sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^9.0.1",
|
||||
"escalade": "^3.1.1",
|
||||
"get-caller-file": "^2.0.5",
|
||||
"string-width": "^8.2.1",
|
||||
"y18n": "^5.0.5",
|
||||
"yargs-parser": "^22.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=23"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "21.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/yargs-parser": {
|
||||
"version": "22.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
|
||||
"integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=23"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
-24
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"name": "promptmanager-vendor-tools",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"description": "Maintainer-only tools for reproducible browser dependencies",
|
||||
"scripts": {
|
||||
"sync": "node vendor-viewerjs.mjs",
|
||||
"check": "node vendor-viewerjs.mjs --check && node vendor-toolkit.mjs --check",
|
||||
"sync:toolkit": "node vendor-toolkit.mjs",
|
||||
"update": "node update-viewerjs.mjs",
|
||||
"test": "node --test ../../tests/vendor/*.test.mjs",
|
||||
"test:browser": "playwright test --config playwright.config.mjs",
|
||||
"test:workflows": "playwright test --config playwright.workflows.config.mjs",
|
||||
"test:comfyui": "playwright test --config comfyui.config.mjs",
|
||||
"test:coverage": "node frontend-coverage.mjs",
|
||||
"coverage:update": "node frontend-coverage.mjs --update-baseline"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@comfyassets/toolkit": "https://codeload.github.com/ComfyAssets/ComfyAssets-Toolkit/tar.gz/66644f5620e10efeaba090dca2d5be54baa2ee14",
|
||||
"@playwright/test": "1.64.1",
|
||||
"c8": "12.0.0",
|
||||
"viewerjs": "1.15.2"
|
||||
}
|
||||
}
|
||||
Vendored
-17
@@ -1,17 +0,0 @@
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
outputDir: './test-results/viewer',
|
||||
testMatch: '*.spec.mjs',
|
||||
fullyParallel: true,
|
||||
workers: 2,
|
||||
forbidOnly: Boolean(process.env.CI),
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
reporter: 'list',
|
||||
use: { browserName: 'chromium', headless: true, trace: 'retain-on-failure' },
|
||||
projects: [
|
||||
{ name: 'desktop', use: { viewport: { width: 1280, height: 800 } } },
|
||||
{ name: 'touch', use: { viewport: { width: 800, height: 700 }, hasTouch: true, isMobile: true } },
|
||||
],
|
||||
});
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import { repositoryRoot } from './vendor-viewerjs.mjs';
|
||||
const port = Number(process.env.PM_TEST_PORT || 8765);
|
||||
export default defineConfig({
|
||||
testDir: '../../tests/browser',
|
||||
outputDir: './test-results/workflows',
|
||||
testMatch: '*.spec.mjs',
|
||||
workers: 1,
|
||||
fullyParallel: false,
|
||||
forbidOnly: Boolean(process.env.CI),
|
||||
retries: 0,
|
||||
reporter: 'list',
|
||||
use: { baseURL: `http://127.0.0.1:${port}`, trace: 'retain-on-failure', screenshot: 'only-on-failure' },
|
||||
webServer: {
|
||||
command: `node tools/vendor/start-browser-server.mjs --port ${port}`,
|
||||
cwd: repositoryRoot,
|
||||
url: `http://127.0.0.1:${port}/prompt_manager/test`,
|
||||
reuseExistingServer: false,
|
||||
gracefulShutdown: { signal: 'SIGTERM', timeout: 5000 },
|
||||
},
|
||||
projects: [
|
||||
{ name: 'chromium', use: { browserName: 'chromium' } },
|
||||
{ name: 'firefox', use: { browserName: 'firefox' } },
|
||||
{ name: 'webkit', use: { browserName: 'webkit' } },
|
||||
{ name: 'touch', use: { ...devices['Pixel 7'], browserName: 'chromium' } },
|
||||
],
|
||||
});
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
const child = spawn(process.env.PM_TEST_PYTHON || 'python',
|
||||
['-m', 'tests.browser.server', ...process.argv.slice(2)], { stdio: 'inherit' });
|
||||
child.on('error', error => { console.error(error.message); process.exitCode = 1; });
|
||||
child.on('exit', code => { process.exitCode = code ?? 1; });
|
||||
for (const signal of ['SIGTERM', 'SIGINT']) {
|
||||
process.once(signal, () => child.kill(signal));
|
||||
}
|
||||
Vendored
-270
@@ -1,270 +0,0 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { repositoryRoot as root } from '../vendor-viewerjs.mjs';
|
||||
|
||||
const source = file => readFileSync(join(root, file), 'utf8');
|
||||
|
||||
async function waitForImage(page, index) {
|
||||
await expect.poll(() => page.evaluate(() => window.gallery.viewer?.viewed)).toBe(true);
|
||||
await expect.poll(() => page.evaluate(() => window.gallery.lastMetadata)).toContain(`/original/${index}.svg`);
|
||||
}
|
||||
|
||||
async function loadGallery(page) {
|
||||
await page.addScriptTag({ content: source('web/js/gallery.js') + '\nwindow.TestGallery = GalleryManager;' });
|
||||
await page.evaluate(() => {
|
||||
const manager = window.gallery = Object.create(TestGallery.prototype);
|
||||
Object.assign(manager, {
|
||||
viewer: null, viewMode: 'grid',
|
||||
images: Array.from({ length: 2000 }, (_, i) => ({ filename: `${i}.svg`,
|
||||
url: `http://viewer.test/original/${i}.svg`, thumbnail_url: `http://viewer.test/thumb/${i}.svg`,
|
||||
relative_path: `${i}.svg`, size: 100, modified_time: 1 })),
|
||||
getSettings: () => ({ showImageInfo: true }),
|
||||
loadImageMetadata(url, content) { this.lastMetadata = url; content.textContent = url; },
|
||||
});
|
||||
manager.renderGallery();
|
||||
});
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
page.viewerErrors = [];
|
||||
page.on('pageerror', error => page.viewerErrors.push(error.message));
|
||||
await page.route('http://viewer.test/**', route => route.request().resourceType() === 'document'
|
||||
? route.fulfill({ contentType: 'text/html', body: '<head><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><button id="outside">Outside</button><div id="galleryGrid"></div><div id="galleryList"></div><div id="loadingState"></div><div id="errorState"></div><div id="galleryContent"></div><div id="galleryEmpty"></div><h1 id="galleryTitle"></h1></body>' })
|
||||
: route.fulfill({ contentType: 'image/svg+xml', body: '<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128"><rect width="128" height="128" fill="teal"/></svg>' }));
|
||||
await page.goto('http://viewer.test/');
|
||||
await page.addStyleTag({ path: join(root, 'web/lib/tailwind/comfyui-theme.css') });
|
||||
await page.addStyleTag({ path: join(root, 'web/lib/tailwind/styles.css') });
|
||||
await page.addStyleTag({ path: join(root, 'web/lib/viewerjs/viewer.min.css') });
|
||||
await page.addStyleTag({ content: '.hidden{display:none} #galleryGrid{display:grid;grid-template-columns:repeat(8,1fr)} #galleryGrid img{width:100px;height:100px}' });
|
||||
for (const file of ['web/lib/viewerjs/viewer.min.js', 'web/js/html-escape.js', 'web/js/image-helpers.js', 'web/js/api-client.js']) {
|
||||
await page.addScriptTag({ path: join(root, file) });
|
||||
}
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
expect(page.viewerErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test('gallery retains cards and navigates across bounded windows with correct metadata', async ({ page }) => {
|
||||
await loadGallery(page);
|
||||
await page.locator('[data-image-index="1500"]').click();
|
||||
await waitForImage(page, 1500);
|
||||
expect(await page.evaluate(() => gallery.viewer.length)).toBeLessThanOrEqual(100);
|
||||
expect(await page.evaluate(() => {
|
||||
const first = document.querySelector('.image-item');
|
||||
gallery.images.push({ ...gallery.images[0], filename: 'appended.svg' });
|
||||
gallery.renderGallery({ from: 2000 });
|
||||
return first === document.querySelector('.image-item');
|
||||
})).toBe(true);
|
||||
await page.evaluate(() => gallery.viewer.view(99));
|
||||
await waitForImage(page, 1549);
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await waitForImage(page, 1550);
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await waitForImage(page, 1549);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect.poll(() => page.evaluate(() => gallery.viewer.isShown)).toBe(false);
|
||||
await expect(page.locator('#metadata-sidebar')).toHaveCount(0);
|
||||
await page.locator('#outside').focus();
|
||||
await expect(page.locator('#outside')).toBeFocused();
|
||||
});
|
||||
|
||||
test('gallery slideshow advances without preloading the collection and cleans up on close', async ({ page }) => {
|
||||
await loadGallery(page);
|
||||
await page.evaluate(() => gallery.openViewer(1500));
|
||||
await waitForImage(page, 1500);
|
||||
await page.clock.install();
|
||||
await page.locator('[data-viewer-action="play"]').click();
|
||||
expect(await page.evaluate(() => gallery.slideshowPlaying)).toBe(true);
|
||||
await expect(page.locator('.viewer-player img')).toHaveCount(0);
|
||||
await page.clock.fastForward(5000);
|
||||
await waitForImage(page, 1501);
|
||||
await page.locator('[data-viewer-action="play"]').click();
|
||||
expect(await page.evaluate(() => gallery.slideshowPlaying)).toBe(false);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect.poll(() => page.evaluate(() => gallery.viewer.isShown)).toBe(false);
|
||||
await page.evaluate(() => gallery.destroyViewer());
|
||||
await expect(page.locator('.viewer-container')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('admin gallery and filmstrip use the real viewer events and destroy temporary containers', async ({ page }) => {
|
||||
// Exercise the production class without starting the ComfyUI dashboard bootstrap.
|
||||
const adminSource = source('web/js/admin.js');
|
||||
await page.addScriptTag({ content: adminSource.slice(0, adminSource.indexOf(' function startAdmin()')) + '\nwindow.TestAdmin = PromptAdmin;' });
|
||||
await page.evaluate(async () => {
|
||||
const admin = window.admin = Object.create(TestAdmin.prototype);
|
||||
const images = Array.from({ length: 3 }, (_, i) => ({ filename: `${i}.svg`, url: `http://viewer.test/original/${i}.svg`, size: 100, created_at: 1 }));
|
||||
Object.assign(admin, {
|
||||
prompts: [{ id: 1, text: 'test' }],
|
||||
getImageUrl: image => image.url,
|
||||
loadFilmStripImages: async () => images,
|
||||
addMetadataSidebar() {},
|
||||
loadMetadataForImage(image) { this.lastMetadata = image.src; },
|
||||
});
|
||||
await admin.openFilmStripViewer(1, 1);
|
||||
});
|
||||
await expect.poll(() => page.evaluate(() => admin.lastMetadata)).toContain('/1.svg');
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect.poll(() => page.evaluate(() => admin.lastMetadata)).toContain('/2.svg');
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('#filmStripViewerContainer')).toHaveCount(0);
|
||||
await expect(page.locator('.viewer-container')).toHaveCount(0);
|
||||
await page.evaluate(() => admin.renderGallery([{ filename: 'a.svg', url: 'http://viewer.test/original/0.svg', size: 100, created_at: 1 }], 1));
|
||||
await expect.poll(() => page.evaluate(() => Boolean(admin.galleryViewer))).toBe(true);
|
||||
await page.evaluate(() => admin.galleryViewer.show());
|
||||
await expect.poll(() => page.evaluate(() => admin.galleryViewer.viewed)).toBe(true);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect.poll(() => page.evaluate(() => admin.galleryViewer.isShown)).toBe(false);
|
||||
await page.evaluate(() => admin.galleryViewer.destroy());
|
||||
});
|
||||
|
||||
test('tag viewer opens at the selected image and cleans up on Escape', async ({ page }) => {
|
||||
await page.addScriptTag({ path: join(root, 'web/js/tags-page.js') });
|
||||
await page.evaluate(async () => {
|
||||
const tags = Object.create(TagsPageManager.prototype);
|
||||
tags.admin = { getImageUrl: image => image.url };
|
||||
await tags.openImageViewer({ images: [0, 1, 2].map(i => ({ filename: `${i}.svg`, url: `http://viewer.test/original/${i}.svg` })), image_count: 3 }, 1);
|
||||
});
|
||||
await expect(page.locator('.viewer-canvas img')).toHaveAttribute('src', /\/1.svg$/);
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect(page.locator('.viewer-canvas img')).toHaveAttribute('src', /\/2.svg$/);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('.viewer-container')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('touch swipes navigate the gallery and restore short drags', async ({ page, isMobile }) => {
|
||||
test.skip(!isMobile, 'Touch interaction project');
|
||||
await loadGallery(page);
|
||||
await page.evaluate(() => gallery.openViewer(1500));
|
||||
await waitForImage(page, 1500);
|
||||
await page.locator('.metadata-collapse-btn').click();
|
||||
await expect(page.locator('.metadata-sidebar')).toHaveClass(/collapsed/);
|
||||
const session = await page.context().newCDPSession(page);
|
||||
const width = await page.evaluate(() => gallery.viewer.viewerData.width);
|
||||
const swipe = async distance => {
|
||||
await session.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: width * 0.9, y: 220 }] });
|
||||
for (let step = 1; step <= 8; step++) {
|
||||
await session.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: width * 0.9 - distance * step / 8, y: 220 }] });
|
||||
}
|
||||
await session.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||||
};
|
||||
await swipe(width * 0.8);
|
||||
await waitForImage(page, 1501);
|
||||
const initialX = await page.evaluate(() => gallery.viewer.imageData.x);
|
||||
await swipe(width * 0.1);
|
||||
await waitForImage(page, 1501);
|
||||
await expect.poll(() => page.evaluate(() => gallery.viewer.imageData.x)).toBe(initialX);
|
||||
});
|
||||
|
||||
test('touch pinch zoom and rotation remain usable', async ({ page, isMobile }) => {
|
||||
test.skip(!isMobile, 'Touch interaction project');
|
||||
await loadGallery(page);
|
||||
await page.evaluate(() => gallery.openViewer(1500));
|
||||
await waitForImage(page, 1500);
|
||||
await page.locator('.metadata-collapse-btn').click();
|
||||
await expect(page.locator('.metadata-sidebar')).toHaveClass(/collapsed/);
|
||||
const session = await page.context().newCDPSession(page);
|
||||
const points = (radius, angle = 0) => [-1, 1].map((sign, id) => ({
|
||||
id, x: 400 + sign * radius * Math.cos(angle), y: 220 + sign * radius * Math.sin(angle),
|
||||
}));
|
||||
await session.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: points(30) });
|
||||
for (let step = 1; step <= 6; step++) {
|
||||
await session.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: points(30 + step * 8) });
|
||||
}
|
||||
await session.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||||
await expect.poll(() => page.evaluate(() => gallery.viewer.imageData.ratio > 1.2)).toBe(true);
|
||||
await session.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: points(70) });
|
||||
for (let step = 1; step <= 8; step++) {
|
||||
await session.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: points(70, Math.PI / 2 * step / 8) });
|
||||
}
|
||||
await session.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||||
await expect.poll(() => page.evaluate(() => Math.abs(gallery.viewer.imageData.rotate))).toBe(90);
|
||||
});
|
||||
|
||||
test('metadata follows the selected image when responses finish out of order', async ({ page }) => {
|
||||
await loadGallery(page);
|
||||
await page.evaluate(() => {
|
||||
delete gallery.loadImageMetadata;
|
||||
window.metadataRequests = new Map();
|
||||
gallery.extractImageMetadata = url => new Promise(resolve => metadataRequests.set(url, resolve));
|
||||
gallery.openViewer(0);
|
||||
});
|
||||
await expect.poll(() => page.evaluate(() => metadataRequests.has('http://viewer.test/original/0.svg'))).toBe(true);
|
||||
await page.evaluate(() => gallery.viewer.next());
|
||||
await expect.poll(() => page.evaluate(() => metadataRequests.has('http://viewer.test/original/1.svg'))).toBe(true);
|
||||
await page.evaluate(() => metadataRequests.get('http://viewer.test/original/1.svg')({ positivePrompt: 'Selected prompt' }));
|
||||
await expect(page.locator('#metadata-content')).toContainText('Selected prompt');
|
||||
await page.evaluate(() => metadataRequests.get('http://viewer.test/original/0.svg')({ positivePrompt: 'Stale prompt' }));
|
||||
await expect(page.locator('#metadata-content')).toContainText('Selected prompt');
|
||||
await expect(page.locator('#metadata-content')).not.toContainText('Stale prompt');
|
||||
expect(await page.evaluate(() => gallery.currentMetadata.positivePrompt)).toBe('Selected prompt');
|
||||
await page.evaluate(() => gallery.viewer.next());
|
||||
await expect.poll(() => page.evaluate(() => metadataRequests.has('http://viewer.test/original/2.svg'))).toBe(true);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('#metadata-sidebar')).toHaveCount(0);
|
||||
await page.evaluate(() => metadataRequests.get('http://viewer.test/original/2.svg')({ positivePrompt: 'Closed prompt' }));
|
||||
expect(await page.evaluate(() => gallery.currentMetadata)).toBeNull();
|
||||
});
|
||||
|
||||
for (const mode of ['gallery', 'filmstrip']) {
|
||||
test(`admin ${mode} metadata ignores stale responses and clears copy state on close`, async ({ page }) => {
|
||||
const urlFor = index => mode === 'gallery' ? `http://viewer.test/prompt_manager/images/${index}/file` : `http://viewer.test/original/${index}.svg`;
|
||||
const adminSource = source('web/js/admin.js');
|
||||
await page.addScriptTag({ content: adminSource.slice(0, adminSource.indexOf(' function startAdmin()')) + '\nwindow.TestAdmin = PromptAdmin;' });
|
||||
await page.evaluate(async mode => {
|
||||
const admin = window.admin = Object.create(TestAdmin.prototype);
|
||||
const images = Array.from({ length: 3 }, (_, i) => ({ id: i, filename: `${i}.svg`, url: `http://viewer.test/original/${i}.svg`, size: 100, created_at: 1 }));
|
||||
window.pendingMetadata = new Map();
|
||||
Object.assign(admin, { prompts: [{ id: 1, text: 'test' }],
|
||||
getImageUrl: image => image.url, loadFilmStripImages: async () => images,
|
||||
extractImageMetadata: url => new Promise(resolve => pendingMetadata.set(url, resolve)),
|
||||
});
|
||||
if (mode === 'gallery') {
|
||||
admin.renderGallery(images, 1);
|
||||
} else {
|
||||
await admin.openFilmStripViewer(1, 0);
|
||||
}
|
||||
}, mode);
|
||||
if (mode === 'gallery') {
|
||||
await expect.poll(() => page.evaluate(() => Boolean(admin.galleryViewer))).toBe(true);
|
||||
await page.evaluate(() => admin.galleryViewer.show());
|
||||
}
|
||||
await expect.poll(() => page.evaluate(url => pendingMetadata.has(url), urlFor(0))).toBe(true);
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect.poll(() => page.evaluate(url => pendingMetadata.has(url), urlFor(1))).toBe(true);
|
||||
await page.evaluate(url => pendingMetadata.get(url)({ positivePrompt: 'Selected prompt', negativePrompt: '', imagePath: '1.svg' }), urlFor(1));
|
||||
await expect(page.locator('#metadata-content')).toContainText('Selected prompt');
|
||||
await page.evaluate(url => pendingMetadata.get(url)({ positivePrompt: 'Stale prompt', negativePrompt: '', imagePath: '0.svg' }), urlFor(0));
|
||||
await expect(page.locator('#metadata-content')).not.toContainText('Stale prompt');
|
||||
expect(await page.evaluate(() => admin.currentMetadata.imagePath)).toBe('1.svg');
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect.poll(() => page.evaluate(url => pendingMetadata.has(url), urlFor(2))).toBe(true);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('#metadata-sidebar')).toHaveCount(0);
|
||||
await page.evaluate(url => pendingMetadata.get(url)({ positivePrompt: 'Closed prompt', negativePrompt: '', imagePath: '2.svg' }), urlFor(2));
|
||||
expect(await page.evaluate(() => admin.currentMetadata)).toBeNull();
|
||||
});
|
||||
}
|
||||
|
||||
test('Tags selection stays current during a delayed prompt response', async ({ page }) => {
|
||||
await page.addScriptTag({ path: join(root, 'web/js/tags-page.js') });
|
||||
await page.evaluate(() => {
|
||||
const grid = document.createElement('div'); grid.id = 'tagPromptsGrid'; document.body.append(grid);
|
||||
const manager = window.tagsManager = Object.create(TagsPageManager.prototype);
|
||||
window.pendingTagPrompts = [];
|
||||
Object.assign(manager, { selectedTags: ['cats'], filterMode: 'and', promptLimit: 20, prompts: [],
|
||||
fetchJson: url => new Promise(resolve => pendingTagPrompts.push({ url, resolve })),
|
||||
renderTagPrompts() { grid.textContent = this.prompts.map(prompt => prompt.text).join(','); },
|
||||
updatePromptCountBadge() {}, updateLoadMoreButton() {}, refreshPromptObserver() {},
|
||||
});
|
||||
manager.loadTagPrompts();
|
||||
manager.selectedTags = ['dogs'];
|
||||
manager.loadTagPrompts();
|
||||
});
|
||||
expect(await page.evaluate(() => pendingTagPrompts.length)).toBe(2);
|
||||
await page.evaluate(() => pendingTagPrompts[1].resolve({ success: true, prompts: [{ text: 'dog prompt' }], pagination: { total: 1, has_more: false } }));
|
||||
await expect(page.locator('#tagPromptsGrid')).toHaveText('dog prompt');
|
||||
await page.evaluate(() => pendingTagPrompts[0].resolve({ success: true, prompts: [{ text: 'cat prompt' }], pagination: { total: 1, has_more: false } }));
|
||||
await expect(page.locator('#tagPromptsGrid')).toHaveText('dog prompt');
|
||||
});
|
||||
Vendored
-8
@@ -1,8 +0,0 @@
|
||||
Update the exact Viewer.js dependency pin and lockfile, pristine JavaScript and CSS, MIT license, and provenance hashes together.
|
||||
|
||||
Validation completed before this PR was opened:
|
||||
- Vendored assets match the installed locked package.
|
||||
- Vendoring tooling and existing frontend tests pass.
|
||||
- Chromium desktop and touch compatibility checks pass for the gallery, admin, and tag viewers.
|
||||
|
||||
Review the [upstream releases](https://github.com/fengyuanchen/viewerjs/releases) for behavioral changes. This PR requires review and is not automatically merged.
|
||||
Vendored
-34
@@ -1,34 +0,0 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { repositoryRoot, stableVersion, syncViewer } from './vendor-viewerjs.mjs';
|
||||
|
||||
function npm(args, options) {
|
||||
if (!process.env.npm_execpath) throw new Error('Run the updater through npm run update');
|
||||
return execFileSync(process.execPath, [process.env.npm_execpath, ...args], options);
|
||||
}
|
||||
|
||||
export function updateViewer({ root = repositoryRoot, version, run = npm, sync = syncViewer } = {}) {
|
||||
const cwd = join(root, 'tools/vendor');
|
||||
const current = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8')).devDependencies.viewerjs;
|
||||
const result = version ?? JSON.parse(run(['view', 'viewerjs', 'version', '--json'], { cwd, encoding: 'utf8' }));
|
||||
const target = Array.isArray(result) && result.length === 1 ? result[0] : result;
|
||||
if (typeof target !== 'string' || !stableVersion.test(target)) throw new Error('Expected a stable Viewer.js version such as 1.15.2');
|
||||
if (target === current) return null;
|
||||
run(['install', '--save-dev', '--save-exact', '--ignore-scripts', `viewerjs@${target}`], { cwd, stdio: 'inherit' });
|
||||
sync({ root });
|
||||
return target;
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
||||
try {
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length && (args.length !== 2 || args[0] !== '--version')) throw new Error('Usage: npm run update -- [--version X.Y.Z]');
|
||||
const version = updateViewer({ version: args[1] });
|
||||
console.log(version ? `Updated Viewer.js to ${version}` : 'Viewer.js is already current');
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
Vendored
-43
@@ -1,43 +0,0 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { repositoryRoot } from './vendor-viewerjs.mjs';
|
||||
|
||||
export function syncToolkit({ root = repositoryRoot, check = false } = {}) {
|
||||
const tooling = join(root, 'tools/vendor');
|
||||
const readJson = path => JSON.parse(readFileSync(path, 'utf8'));
|
||||
const pin = readJson(join(tooling, 'package.json')).devDependencies['@comfyassets/toolkit'];
|
||||
if (!/^https:\/\/codeload\.github\.com\/ComfyAssets\/ComfyAssets-Toolkit\/tar\.gz\/[a-f0-9]{40}$/.test(pin)) throw new Error('Toolkit needs an immutable commit pin');
|
||||
const locked = readJson(join(tooling, 'package-lock.json')).packages['node_modules/@comfyassets/toolkit'];
|
||||
const installed = join(tooling, 'node_modules/@comfyassets/toolkit');
|
||||
const info = readJson(join(installed, 'package.json'));
|
||||
if (locked?.resolved !== pin || !locked.integrity || locked.version !== info.version || info.name !== '@comfyassets/toolkit' || info.license !== 'MIT') throw new Error('Toolkit package identity or lock mismatch; run npm ci --ignore-scripts');
|
||||
const output = new Map();
|
||||
const files = {};
|
||||
for (const [name, source] of Object.entries({ 'core.js': 'ui/core.js', 'controls.css': 'ui/controls.css', LICENSE: 'LICENSE', 'SOURCE.md': 'SOURCE.md' })) {
|
||||
const content = readFileSync(join(installed, source));
|
||||
output.set(name, content);
|
||||
files[name] = { source, sha256: createHash('sha256').update(content).digest('hex') };
|
||||
}
|
||||
output.set('provenance.json', Buffer.from(JSON.stringify({ name: info.name, version: info.version, resolved: pin, integrity: locked.integrity, files }, null, 2) + '\n'));
|
||||
const destination = join(root, 'web/js/shared/toolkit');
|
||||
if (check) {
|
||||
const drift = [...output].filter(([name, content]) => {
|
||||
try { return !readFileSync(join(destination, name)).equals(content); }
|
||||
catch (error) { if (error.code === 'ENOENT') return true; throw error; }
|
||||
}).map(([name]) => name);
|
||||
if (drift.length) throw new Error(`Toolkit browser distribution drift: ${drift.join(', ')}`);
|
||||
} else {
|
||||
mkdirSync(destination, { recursive: true });
|
||||
for (const [name, content] of output) writeFileSync(join(destination, name), content);
|
||||
}
|
||||
return info.version;
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
||||
try {
|
||||
if (process.argv.slice(2).some(arg => arg !== '--check')) throw new Error('Usage: vendor-toolkit.mjs [--check]');
|
||||
console.log(`Toolkit ${syncToolkit({ check: process.argv.includes('--check') })}: verified`);
|
||||
} catch (error) { console.error(error.message); process.exitCode = 1; }
|
||||
}
|
||||
Vendored
-70
@@ -1,70 +0,0 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { resolve, join } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
export const repositoryRoot = fileURLToPath(new URL('../..', import.meta.url));
|
||||
export const stableVersion = /^\d+\.\d+\.\d+$/;
|
||||
const readJson = path => JSON.parse(readFileSync(path, 'utf8'));
|
||||
|
||||
export function syncViewer({ root = repositoryRoot, check = false } = {}) {
|
||||
const tooling = join(root, 'tools/vendor');
|
||||
const manifest = readJson(join(tooling, 'package.json'));
|
||||
const version = manifest.devDependencies.viewerjs;
|
||||
if (!stableVersion.test(version)) throw new Error('Viewer.js must use an exact stable version pin');
|
||||
const locked = readJson(join(tooling, 'package-lock.json')).packages['node_modules/viewerjs'];
|
||||
const installed = join(tooling, 'node_modules/viewerjs');
|
||||
const info = readJson(join(installed, 'package.json'));
|
||||
if (locked?.version !== version || info.version !== version) {
|
||||
throw new Error('Viewer.js version mismatch: run npm ci --ignore-scripts in tools/vendor');
|
||||
}
|
||||
if (!locked.integrity || !locked.resolved || info.name !== 'viewerjs' || info.license !== 'MIT') {
|
||||
throw new Error('Viewer.js package identity, integrity, or license is missing');
|
||||
}
|
||||
const files = {};
|
||||
const output = new Map();
|
||||
for (const [target, source] of Object.entries({
|
||||
'viewer.min.js': 'dist/viewer.min.js',
|
||||
'viewer.min.css': 'dist/viewer.min.css',
|
||||
LICENSE: 'LICENSE',
|
||||
})) {
|
||||
const content = readFileSync(join(installed, source));
|
||||
if (target !== 'LICENSE' && !content.toString('utf8').includes(`Viewer.js v${version}\n`) &&
|
||||
!content.toString('utf8').includes(`Viewer.js v${version} */`)) {
|
||||
throw new Error(`Viewer.js version banner mismatch in ${source}`);
|
||||
}
|
||||
files[target] = { source, sha256: createHash('sha256').update(content).digest('hex') };
|
||||
output.set(target, content);
|
||||
}
|
||||
output.set('provenance.json', Buffer.from(JSON.stringify({
|
||||
name: info.name,
|
||||
version,
|
||||
source: `https://github.com/fengyuanchen/viewerjs/tree/v${version}`,
|
||||
resolved: locked.resolved,
|
||||
integrity: locked.integrity,
|
||||
files,
|
||||
}, null, 2) + '\n'));
|
||||
const destination = join(root, 'web/lib/viewerjs');
|
||||
if (check) {
|
||||
const drift = [...output].filter(([name, content]) => {
|
||||
try { return !readFileSync(join(destination, name)).equals(content); }
|
||||
catch (error) { if (error.code === 'ENOENT') return true; throw error; }
|
||||
}).map(([name]) => name);
|
||||
if (drift.length) throw new Error(`Vendored Viewer.js drift: ${drift.join(', ')}. Run npm run sync in tools/vendor.`);
|
||||
} else {
|
||||
mkdirSync(destination, { recursive: true });
|
||||
for (const [name, content] of output) writeFileSync(join(destination, name), content);
|
||||
}
|
||||
return version;
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
||||
try {
|
||||
const args = process.argv.slice(2);
|
||||
if (args.some(arg => arg !== '--check')) throw new Error('Usage: vendor-viewerjs.mjs [--check]');
|
||||
console.log(`Viewer.js ${syncViewer({ check: args.includes('--check') })}: ${args.includes('--check') ? 'verified' : 'synced'}`);
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -85,8 +85,6 @@ class PromptManagerLogger:
|
||||
# Main logger
|
||||
self.logger = logging.getLogger("prompt_manager")
|
||||
self.logger.setLevel(getattr(logging, self.config["level"]))
|
||||
# ComfyUI's root handler would print every record a second time.
|
||||
self.logger.propagate = False
|
||||
|
||||
# Clear existing handlers
|
||||
for handler in self.logger.handlers[:]:
|
||||
@@ -102,7 +100,6 @@ class PromptManagerLogger:
|
||||
# Console handler
|
||||
if self.config["console_logging"]:
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(self._console_level())
|
||||
console_handler.setFormatter(formatter)
|
||||
self.logger.addHandler(console_handler)
|
||||
|
||||
@@ -127,15 +124,6 @@ class PromptManagerLogger:
|
||||
# Component-specific loggers
|
||||
self._setup_component_loggers()
|
||||
|
||||
def _console_level(self) -> int:
|
||||
"""Console threshold: everything at DEBUG, otherwise warnings only.
|
||||
|
||||
INFO still reaches the log file and the web log viewer.
|
||||
"""
|
||||
if str(self.config["level"]).upper() == "DEBUG":
|
||||
return logging.DEBUG
|
||||
return logging.WARNING
|
||||
|
||||
def _setup_component_loggers(self):
|
||||
"""Set up loggers for specific components.
|
||||
|
||||
@@ -375,10 +363,6 @@ class PromptManagerLogger:
|
||||
logger = logging.getLogger(logger_name)
|
||||
logger.setLevel(level)
|
||||
|
||||
for handler in self.logger.handlers:
|
||||
if type(handler) is logging.StreamHandler:
|
||||
handler.setLevel(self._console_level())
|
||||
|
||||
# Re-setup loggers if handlers changed
|
||||
if any(
|
||||
key in new_config
|
||||
|
||||
+15
-15
@@ -4,16 +4,16 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PromptManager - Admin Dashboard</title>
|
||||
<script src="js/theme-bridge.js"></script>
|
||||
<script src="js/html-escape.js"></script>
|
||||
<script src="js/image-helpers.js"></script>
|
||||
<script src="js/api-client.js"></script>
|
||||
<script src="js/png-metadata.js"></script>
|
||||
<script src="js/list-state.js"></script>
|
||||
<script src="js/tag-autocomplete.js"></script>
|
||||
<script src="js/sse-stream.js"></script>
|
||||
<script src="js/scan-progress.js"></script>
|
||||
<script src="js/data-actions.js"></script>
|
||||
<script src="/prompt_manager/js/theme-bridge.js"></script>
|
||||
<script src="/prompt_manager/js/html-escape.js"></script>
|
||||
<script src="/prompt_manager/js/image-helpers.js"></script>
|
||||
<script src="/prompt_manager/js/api-client.js"></script>
|
||||
<script src="/prompt_manager/js/png-metadata.js"></script>
|
||||
<script src="/prompt_manager/js/list-state.js"></script>
|
||||
<script src="/prompt_manager/js/tag-autocomplete.js"></script>
|
||||
<script src="/prompt_manager/js/sse-stream.js"></script>
|
||||
<script src="/prompt_manager/js/scan-progress.js"></script>
|
||||
<script src="/prompt_manager/js/data-actions.js"></script>
|
||||
<link rel="stylesheet" href="lib/tailwind/comfyui-theme.css">
|
||||
<link rel="stylesheet" href="lib/tailwind/styles.css">
|
||||
<link rel="stylesheet" href="lib/viewerjs/viewer.min.css">
|
||||
@@ -1437,10 +1437,10 @@ No sentences, no commentary, no captions. Only tags. Keep length short but descr
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="js/comfy-metadata.js"></script>
|
||||
<script src="js/prompt-list-sort.js"></script>
|
||||
<script src="js/view-router.js"></script>
|
||||
<script src="js/tags-page.js"></script>
|
||||
<script src="js/admin.js"></script>
|
||||
<script src="/prompt_manager/js/comfy-metadata.js"></script>
|
||||
<script src="/prompt_manager/js/prompt-list-sort.js"></script>
|
||||
<script src="/prompt_manager/js/view-router.js"></script>
|
||||
<script src="/prompt_manager/js/tags-page.js"></script>
|
||||
<script src="/prompt_manager/js/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+17
-26
@@ -2,7 +2,6 @@
|
||||
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { api } from "../../scripts/api.js";
|
||||
const { installClassicControls } = await import(api.fileURL('/prompt_manager/js/classic-node.js'));
|
||||
|
||||
// ============================================================================
|
||||
// Top Bar Button Integration (similar to rgthree-comfy approach)
|
||||
@@ -120,7 +119,7 @@ function addPMTopBarButton() {
|
||||
transition: "background 0.2s",
|
||||
},
|
||||
onclick: () => {
|
||||
window.open(api.fileURL("/prompt_manager/admin"), "_blank");
|
||||
window.open("/prompt_manager/admin", "_blank");
|
||||
},
|
||||
onmouseenter: (e) => { e.target.style.background = "#0d9488"; },
|
||||
onmouseleave: (e) => { e.target.style.background = "#14b8a6"; },
|
||||
@@ -176,7 +175,7 @@ function addPMTopBarButtonLegacy() {
|
||||
btn.onmouseleave = () => btn.style.background = "#14b8a6";
|
||||
|
||||
btn.onclick = () => {
|
||||
window.open(api.fileURL("/prompt_manager/admin"), "_blank");
|
||||
window.open("/prompt_manager/admin", "_blank");
|
||||
};
|
||||
|
||||
topBar.appendChild(btn);
|
||||
@@ -303,8 +302,7 @@ app.registerExtension({
|
||||
// CRITICAL FIX: Ensure text widget always returns current value during serialization
|
||||
// This fixes the issue where graphToPrompt reads stale cached values
|
||||
const self = this;
|
||||
this._textSyncTimer = setTimeout(() => {
|
||||
if (self._classicScope?.signal.aborted) return;
|
||||
setTimeout(() => {
|
||||
const textWidget = self.widgets?.find((w) => w.name === "text");
|
||||
if (textWidget) {
|
||||
// Store reference to the widget for the closure
|
||||
@@ -336,16 +334,13 @@ app.registerExtension({
|
||||
};
|
||||
|
||||
// Sync on every keystroke
|
||||
const listen = self._classicScope
|
||||
? (event) => self._classicScope.listen(textWidget.inputEl, event, syncValue)
|
||||
: (event) => textWidget.inputEl.addEventListener(event, syncValue);
|
||||
listen('input');
|
||||
textWidget.inputEl.addEventListener('input', syncValue);
|
||||
// Sync on change (for paste, autocomplete, etc.)
|
||||
listen('change');
|
||||
textWidget.inputEl.addEventListener('change', syncValue);
|
||||
// Sync when focus leaves the field
|
||||
listen('blur');
|
||||
textWidget.inputEl.addEventListener('blur', syncValue);
|
||||
// Sync on keyup as backup
|
||||
listen('keyup');
|
||||
textWidget.inputEl.addEventListener('keyup', syncValue);
|
||||
|
||||
// Initial sync
|
||||
syncValue();
|
||||
@@ -382,7 +377,7 @@ app.registerExtension({
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 8px;
|
||||
background-color: ${nodeData.name === 'PromptManager' ? 'var(--ca-surface)' : '#2a2a2a'};
|
||||
background-color: #2a2a2a;
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
@@ -691,7 +686,7 @@ app.registerExtension({
|
||||
// Method to load settings from API
|
||||
nodeType.prototype.loadSettings = async function () {
|
||||
try {
|
||||
const response = await fetch(api.fileURL("/prompt_manager/settings"));
|
||||
const response = await fetch("/prompt_manager/settings");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success && data.settings) {
|
||||
@@ -988,8 +983,7 @@ app.registerExtension({
|
||||
throw new Error(`Unknown method: ${method}`);
|
||||
}
|
||||
|
||||
if (this._classicScope) options.signal = this._classicScope.signal;
|
||||
const response = await fetch(api.fileURL(url), options);
|
||||
const response = await fetch(url, options);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
@@ -1003,12 +997,10 @@ app.registerExtension({
|
||||
|
||||
return { results: data.results || [] };
|
||||
} catch (error) {
|
||||
if (!this._classicScope?.signal.aborted) {
|
||||
console.error(
|
||||
`[PromptManager] API call failed for ${method}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
console.error(
|
||||
`[PromptManager] API call failed for ${method}:`,
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -1016,7 +1008,7 @@ app.registerExtension({
|
||||
// Method to save a prompt via API
|
||||
nodeType.prototype.savePromptToDatabase = async function (promptData) {
|
||||
try {
|
||||
const response = await fetch(api.fileURL("/prompt_manager/save"), {
|
||||
const response = await fetch("/prompt_manager/save", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -1044,7 +1036,7 @@ app.registerExtension({
|
||||
// Method to delete a prompt via API
|
||||
nodeType.prototype.deletePrompt = async function (promptId) {
|
||||
try {
|
||||
const response = await fetch(api.fileURL(`/prompt_manager/delete/${promptId}`), {
|
||||
const response = await fetch(`/prompt_manager/delete/${promptId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
@@ -1071,7 +1063,7 @@ app.registerExtension({
|
||||
this.resultsSection.innerHTML =
|
||||
'<div style="color: #999; text-align: center;">🔧 Testing API connection...</div>';
|
||||
|
||||
const response = await fetch(api.fileURL("/prompt_manager/test"));
|
||||
const response = await fetch("/prompt_manager/test");
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
@@ -1182,7 +1174,6 @@ app.registerExtension({
|
||||
document.body.removeChild(notification);
|
||||
}, 3000);
|
||||
};
|
||||
if (nodeData.name === 'PromptManager') installClassicControls(nodeType, app, parseSearchQuery, api);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
+12
-12
@@ -4,16 +4,16 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PromptManager - Gallery</title>
|
||||
<script src="js/theme-bridge.js"></script>
|
||||
<script src="js/html-escape.js"></script>
|
||||
<script src="js/image-helpers.js"></script>
|
||||
<script src="js/gallery-settings.js"></script>
|
||||
<script src="js/scan-progress.js"></script>
|
||||
<script src="js/api-client.js"></script>
|
||||
<script src="js/png-metadata.js"></script>
|
||||
<script src="js/list-state.js"></script>
|
||||
<script src="js/sse-stream.js"></script>
|
||||
<script src="js/data-actions.js"></script>
|
||||
<script src="/prompt_manager/js/theme-bridge.js"></script>
|
||||
<script src="/prompt_manager/js/html-escape.js"></script>
|
||||
<script src="/prompt_manager/js/image-helpers.js"></script>
|
||||
<script src="/prompt_manager/js/gallery-settings.js"></script>
|
||||
<script src="/prompt_manager/js/scan-progress.js"></script>
|
||||
<script src="/prompt_manager/js/api-client.js"></script>
|
||||
<script src="/prompt_manager/js/png-metadata.js"></script>
|
||||
<script src="/prompt_manager/js/list-state.js"></script>
|
||||
<script src="/prompt_manager/js/sse-stream.js"></script>
|
||||
<script src="/prompt_manager/js/data-actions.js"></script>
|
||||
<link rel="stylesheet" href="lib/tailwind/comfyui-theme.css">
|
||||
<link rel="stylesheet" href="lib/tailwind/styles.css">
|
||||
<link rel="stylesheet" href="lib/viewerjs/viewer.min.css">
|
||||
@@ -837,7 +837,7 @@ No sentences, no commentary, no captions. Only tags. Keep length short but descr
|
||||
</div>
|
||||
|
||||
|
||||
<script src="js/comfy-metadata.js"></script>
|
||||
<script src="js/gallery.js"></script>
|
||||
<script src="/prompt_manager/js/comfy-metadata.js"></script>
|
||||
<script src="/prompt_manager/js/gallery.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+2
-2
@@ -55,7 +55,7 @@
|
||||
|
||||
<script>
|
||||
// Redirect to admin interface immediately
|
||||
window.location.href = window.location.pathname.replace(/\/(?:web|index\.html)\/?$/, '/admin');
|
||||
window.location.href = '/prompt_manager/admin';
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
+52
-68
@@ -293,7 +293,7 @@
|
||||
|
||||
async loadSettings() {
|
||||
try {
|
||||
const response = await ApiClient.fetchUrl("/prompt_manager/settings");
|
||||
const response = await fetch("/prompt_manager/settings");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success && data.settings) {
|
||||
@@ -321,7 +321,7 @@
|
||||
|
||||
async loadStatistics() {
|
||||
try {
|
||||
const response = await ApiClient.fetchUrl("/prompt_manager/stats");
|
||||
const response = await fetch("/prompt_manager/stats");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
@@ -351,7 +351,7 @@
|
||||
|
||||
async loadCategories() {
|
||||
try {
|
||||
const response = await ApiClient.fetchUrl("/prompt_manager/categories");
|
||||
const response = await fetch("/prompt_manager/categories");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
@@ -366,7 +366,7 @@
|
||||
|
||||
async loadTags() {
|
||||
try {
|
||||
const response = await ApiClient.fetchUrl("/prompt_manager/tags");
|
||||
const response = await fetch("/prompt_manager/tags");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
@@ -391,7 +391,7 @@
|
||||
|
||||
async loadSubfolders() {
|
||||
try {
|
||||
const response = await ApiClient.fetchUrl("/prompt_manager/subfolders?include_ancestors=true");
|
||||
const response = await fetch("/prompt_manager/subfolders?include_ancestors=true");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
@@ -979,7 +979,7 @@
|
||||
async updateMonitoringStatus() {
|
||||
const statusEl = document.getElementById("monitoringStatus");
|
||||
try {
|
||||
const response = await ApiClient.fetchUrl("/prompt_manager/settings");
|
||||
const response = await fetch("/prompt_manager/settings");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const dirs = data.settings?.monitored_directories || [];
|
||||
@@ -1003,7 +1003,7 @@
|
||||
|
||||
try {
|
||||
// First check current status
|
||||
const statusRes = await ApiClient.fetchUrl("/prompt_manager/lora/status");
|
||||
const statusRes = await fetch("/prompt_manager/lora/status");
|
||||
if (statusRes.ok) {
|
||||
const status = await statusRes.json();
|
||||
if (status.success && status.enabled) {
|
||||
@@ -1027,7 +1027,7 @@
|
||||
}
|
||||
|
||||
// Try auto-detection
|
||||
const detectRes = await ApiClient.fetchUrl("/prompt_manager/lora/detect");
|
||||
const detectRes = await fetch("/prompt_manager/lora/detect");
|
||||
if (!detectRes.ok) return;
|
||||
const data = await detectRes.json();
|
||||
|
||||
@@ -1084,7 +1084,7 @@
|
||||
else if (civitaiKey) payload.civitai_api_key = civitaiKey;
|
||||
|
||||
try {
|
||||
const res = await ApiClient.fetchUrl("/prompt_manager/lora/enable", {
|
||||
const res = await fetch("/prompt_manager/lora/enable", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
@@ -1115,7 +1115,7 @@
|
||||
this.showModal("loraImportModal");
|
||||
|
||||
try {
|
||||
const res = await ApiClient.fetchUrl("/prompt_manager/lora/scan", { method: "POST" });
|
||||
const res = await fetch("/prompt_manager/lora/scan", { method: "POST" });
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
@@ -1175,7 +1175,7 @@
|
||||
this.settings.galleryRootPaths = galleryPaths;
|
||||
|
||||
try {
|
||||
const response = await ApiClient.fetchUrl("/prompt_manager/settings", {
|
||||
const response = await fetch("/prompt_manager/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -1291,7 +1291,7 @@
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await ApiClient.fetchUrl(`/prompt_manager/prompts/${promptId}/rating`, {
|
||||
const response = await fetch(`/prompt_manager/prompts/${promptId}/rating`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ rating }),
|
||||
@@ -1348,7 +1348,7 @@
|
||||
if (tags.length === 0) return;
|
||||
|
||||
try {
|
||||
const response = await ApiClient.fetchUrl("/prompt_manager/prompts/tags", {
|
||||
const response = await fetch("/prompt_manager/prompts/tags", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -1371,7 +1371,7 @@
|
||||
|
||||
async removeTag(promptId, tag) {
|
||||
try {
|
||||
const response = await ApiClient.fetchUrl(`/prompt_manager/prompts/${promptId}/tags`, {
|
||||
const response = await fetch(`/prompt_manager/prompts/${promptId}/tags`, {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ tag }),
|
||||
@@ -1473,7 +1473,7 @@
|
||||
if (tags.length === 0) return;
|
||||
|
||||
try {
|
||||
const response = await ApiClient.fetchUrl("/prompt_manager/bulk/tags", {
|
||||
const response = await fetch("/prompt_manager/bulk/tags", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -1499,7 +1499,7 @@
|
||||
if (!category) return;
|
||||
|
||||
try {
|
||||
const response = await ApiClient.fetchUrl("/prompt_manager/bulk/category", {
|
||||
const response = await fetch("/prompt_manager/bulk/category", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -1524,7 +1524,7 @@
|
||||
if (!confirm(`Are you sure you want to delete ${this.selectedPrompts.size} prompts?`)) return;
|
||||
|
||||
try {
|
||||
const response = await ApiClient.fetchUrl("/prompt_manager/bulk/delete", {
|
||||
const response = await fetch("/prompt_manager/bulk/delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -1545,7 +1545,7 @@
|
||||
|
||||
async exportPrompts() {
|
||||
try {
|
||||
const response = await ApiClient.fetchUrl("/prompt_manager/export");
|
||||
const response = await fetch("/prompt_manager/export");
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
@@ -1578,7 +1578,7 @@
|
||||
document.getElementById("galleryEmpty").classList.add("hidden");
|
||||
|
||||
try {
|
||||
const response = await ApiClient.fetchUrl(`/prompt_manager/prompts/${promptId}/images`);
|
||||
const response = await fetch(`/prompt_manager/prompts/${promptId}/images`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
@@ -1620,7 +1620,7 @@
|
||||
content.innerHTML = images.map((image, index) => {
|
||||
const generated = new Date(image.generation_time);
|
||||
const when = escapeHtml(`${generated.toLocaleDateString()} ${generated.toLocaleTimeString()}`);
|
||||
const fileUrl = ApiClient.resolveUrl(`/prompt_manager/images/${escapeHtml(image.id)}/file`);
|
||||
const fileUrl = `/prompt_manager/images/${escapeHtml(image.id)}/file`;
|
||||
const caption = ImageHelpers.formatImageCaption(image, { formatFileSize });
|
||||
const details = ImageHelpers.formatImageCaption(image, { formatFileSize, separator: ' • ' });
|
||||
return `
|
||||
@@ -1656,7 +1656,6 @@
|
||||
// Initialize ViewerJS for the gallery after DOM is updated
|
||||
setTimeout(() => {
|
||||
if (this.galleryViewer) {
|
||||
this.invalidateMetadataRequest();
|
||||
this.galleryViewer.destroy();
|
||||
}
|
||||
|
||||
@@ -1700,7 +1699,6 @@
|
||||
viewed: (event) => {
|
||||
this.loadMetadataForImage(event.detail.image);
|
||||
},
|
||||
hide: () => this.invalidateMetadataRequest(),
|
||||
hidden: () => {
|
||||
const sidebar = document.getElementById('metadata-sidebar');
|
||||
if (sidebar) sidebar.remove();
|
||||
@@ -1713,13 +1711,7 @@
|
||||
|
||||
|
||||
|
||||
invalidateMetadataRequest() {
|
||||
this.metadataRequest = null;
|
||||
this.currentMetadata = null;
|
||||
}
|
||||
|
||||
closeGallery() {
|
||||
this.invalidateMetadataRequest();
|
||||
this.hideModal("galleryModal");
|
||||
}
|
||||
|
||||
@@ -1795,7 +1787,7 @@
|
||||
`;
|
||||
|
||||
try {
|
||||
const response = await ApiClient.fetchUrl('/prompt_manager/maintenance', {
|
||||
const response = await fetch('/prompt_manager/maintenance', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ operations: selectedOps })
|
||||
@@ -1928,7 +1920,7 @@
|
||||
`;
|
||||
|
||||
try {
|
||||
const response = await ApiClient.fetchUrl('/prompt_manager/diagnostics');
|
||||
const response = await fetch('/prompt_manager/diagnostics');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
@@ -2045,7 +2037,7 @@
|
||||
const testPrompt = this.prompts[0];
|
||||
|
||||
try {
|
||||
const response = await ApiClient.fetchUrl('/prompt_manager/diagnostics/test-link', {
|
||||
const response = await fetch('/prompt_manager/diagnostics/test-link', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -2075,7 +2067,7 @@
|
||||
// Database backup and restore functionality
|
||||
async backupDatabase() {
|
||||
try {
|
||||
const response = await ApiClient.fetchUrl('/prompt_manager/backup');
|
||||
const response = await fetch('/prompt_manager/backup');
|
||||
|
||||
if (response.ok) {
|
||||
// Get the filename from the Content-Disposition header
|
||||
@@ -2152,7 +2144,7 @@
|
||||
const formData = new FormData();
|
||||
formData.append('database_file', file);
|
||||
|
||||
const response = await ApiClient.fetchUrl('/prompt_manager/restore', {
|
||||
const response = await fetch('/prompt_manager/restore', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
@@ -2342,7 +2334,7 @@
|
||||
async resumeRunningScan() {
|
||||
let status;
|
||||
try {
|
||||
const response = await ApiClient.fetchUrl("/prompt_manager/scan/status");
|
||||
const response = await fetch("/prompt_manager/scan/status");
|
||||
if (!response.ok) return;
|
||||
status = await response.json();
|
||||
} catch (_) {
|
||||
@@ -2390,7 +2382,7 @@
|
||||
|
||||
async quickBackup() {
|
||||
try {
|
||||
const response = await ApiClient.fetchUrl("/prompt_manager/backup");
|
||||
const response = await fetch("/prompt_manager/backup");
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
@@ -2453,7 +2445,7 @@
|
||||
|
||||
async loadLogStats() {
|
||||
try {
|
||||
const response = await ApiClient.fetchUrl('/prompt_manager/logs/stats');
|
||||
const response = await fetch('/prompt_manager/logs/stats');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
@@ -2471,7 +2463,7 @@
|
||||
|
||||
async loadLogConfig() {
|
||||
try {
|
||||
const response = await ApiClient.fetchUrl('/prompt_manager/logs/config');
|
||||
const response = await fetch('/prompt_manager/logs/config');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
@@ -2486,7 +2478,7 @@
|
||||
|
||||
async loadLogFiles() {
|
||||
try {
|
||||
const response = await ApiClient.fetchUrl('/prompt_manager/logs/files');
|
||||
const response = await fetch('/prompt_manager/logs/files');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
@@ -2523,11 +2515,11 @@
|
||||
const limit = parseInt(document.getElementById("logLimit").value);
|
||||
|
||||
try {
|
||||
const url = new URL(ApiClient.resolveUrl('/prompt_manager/logs'), window.location.origin);
|
||||
const url = new URL('/prompt_manager/logs', window.location.origin);
|
||||
if (level) url.searchParams.set('level', level);
|
||||
url.searchParams.set('limit', limit.toString());
|
||||
|
||||
const response = await ApiClient.fetchUrl(url);
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
@@ -2586,11 +2578,11 @@
|
||||
const limit = parseInt(document.getElementById("logLimit").value);
|
||||
|
||||
try {
|
||||
const url = new URL(ApiClient.resolveUrl('/prompt_manager/logs'), window.location.origin);
|
||||
const url = new URL('/prompt_manager/logs', window.location.origin);
|
||||
if (level) url.searchParams.set('level', level);
|
||||
url.searchParams.set('limit', limit.toString());
|
||||
|
||||
const response = await ApiClient.fetchUrl(url);
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
@@ -2620,7 +2612,7 @@
|
||||
|
||||
async downloadLogFile(filename) {
|
||||
try {
|
||||
const response = await ApiClient.fetchUrl(`/prompt_manager/logs/download/${encodeURIComponent(filename)}`);
|
||||
const response = await fetch(`/prompt_manager/logs/download/${encodeURIComponent(filename)}`);
|
||||
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
@@ -2649,7 +2641,7 @@
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await ApiClient.fetchUrl('/prompt_manager/logs/truncate', {
|
||||
const response = await fetch('/prompt_manager/logs/truncate', {
|
||||
method: 'POST'
|
||||
});
|
||||
const data = await response.json();
|
||||
@@ -2673,7 +2665,7 @@
|
||||
const consoleLogging = document.getElementById("consoleLogging").checked;
|
||||
|
||||
try {
|
||||
const response = await ApiClient.fetchUrl('/prompt_manager/logs/config', {
|
||||
const response = await fetch('/prompt_manager/logs/config', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
@@ -2747,12 +2739,12 @@
|
||||
|
||||
// Metadata functionality
|
||||
openMetadataViewer() {
|
||||
window.open(ApiClient.resolveUrl('/prompt_manager/gallery.html'), '_blank');
|
||||
window.open('/prompt_manager/gallery.html', '_blank');
|
||||
}
|
||||
|
||||
// Gallery functionality
|
||||
openGallery() {
|
||||
window.open(ApiClient.resolveUrl('/prompt_manager/gallery'), '_blank');
|
||||
window.open('/prompt_manager/gallery', '_blank');
|
||||
}
|
||||
|
||||
|
||||
@@ -2817,7 +2809,7 @@
|
||||
async extractImageMetadata(imageUrl) {
|
||||
try {
|
||||
// Fetch the image as an array buffer
|
||||
const response = await ApiClient.fetchUrl(imageUrl);
|
||||
const response = await fetch(imageUrl);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
|
||||
// Parse PNG metadata
|
||||
@@ -2904,11 +2896,6 @@
|
||||
const metadataContent = document.getElementById('metadata-content');
|
||||
if (!metadataContent) return;
|
||||
|
||||
const request = {};
|
||||
this.metadataRequest = request;
|
||||
this.currentMetadata = null;
|
||||
const isCurrent = () => this.metadataRequest === request && metadataContent.isConnected !== false;
|
||||
|
||||
// Show loading state
|
||||
metadataContent.innerHTML = `
|
||||
<div class="metadata-loading">
|
||||
@@ -2924,11 +2911,9 @@
|
||||
|
||||
try {
|
||||
const metadata = await this.extractImageMetadata(imageUrl);
|
||||
if (!isCurrent()) return;
|
||||
this.currentMetadata = metadata;
|
||||
this.updateMetadataSidebar(metadata);
|
||||
} catch (error) {
|
||||
if (!isCurrent()) return;
|
||||
console.error('Failed to load metadata:', error);
|
||||
metadataContent.innerHTML = `
|
||||
<div class="text-center text-pm-error py-8">
|
||||
@@ -3322,7 +3307,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await ApiClient.fetchUrl('/prompt_manager/save', {
|
||||
const response = await fetch('/prompt_manager/save', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -3360,7 +3345,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
|
||||
getImageUrl(image) {
|
||||
// If the image already has a url property, use it
|
||||
if (image.url) return ApiClient.resolveUrl(image.url);
|
||||
if (image.url) return image.url;
|
||||
|
||||
// Otherwise construct from image_path
|
||||
if (image.image_path) {
|
||||
@@ -3371,17 +3356,17 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
const filename = image.filename || path.split(/[/\\]/).pop();
|
||||
// Use the image ID if available, otherwise use the path-based serve endpoint
|
||||
if (image.id) {
|
||||
return ApiClient.resolveUrl(`/prompt_manager/images/${image.id}/file`);
|
||||
return `/prompt_manager/images/${image.id}/file`;
|
||||
}
|
||||
// Fallback: serve by path (encode the path)
|
||||
return ApiClient.resolveUrl(`/prompt_manager/images/serve/${encodeURIComponent(path)}`);
|
||||
return `/prompt_manager/images/serve/${encodeURIComponent(path)}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getThumbnailUrl(image) {
|
||||
// If thumbnail_url already exists, use it
|
||||
if (image.thumbnail_url) return ApiClient.resolveUrl(image.thumbnail_url);
|
||||
if (image.thumbnail_url) return image.thumbnail_url;
|
||||
|
||||
// Try to construct thumbnail URL from filename and relative_path
|
||||
// Thumbnails are stored as: thumbnails/{parent_dir}/{stem}_thumb{ext}
|
||||
@@ -3404,7 +3389,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
|
||||
// URL encode the path components (but preserve slashes)
|
||||
const encodedParentDir = parentDir.split('/').map(p => encodeURIComponent(p)).join('/');
|
||||
return ApiClient.resolveUrl(`/prompt_manager/images/serve/thumbnails/${encodedParentDir}${encodeURIComponent(stem)}_thumb${ext}`);
|
||||
return `/prompt_manager/images/serve/thumbnails/${encodedParentDir}${encodeURIComponent(stem)}_thumb${ext}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3414,7 +3399,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
|
||||
async loadFilmStripImages(promptId) {
|
||||
try {
|
||||
const response = await ApiClient.fetchUrl(`/prompt_manager/prompts/${promptId}/images`);
|
||||
const response = await fetch(`/prompt_manager/prompts/${promptId}/images`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success && data.images) {
|
||||
@@ -3530,7 +3515,6 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
viewed: (event) => {
|
||||
this.loadMetadataForImage(event.detail.image);
|
||||
},
|
||||
hide: () => this.invalidateMetadataRequest(),
|
||||
hidden: () => {
|
||||
const sidebar = document.getElementById('metadata-sidebar');
|
||||
if (sidebar) sidebar.remove();
|
||||
@@ -3588,7 +3572,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
statusEls.forEach(el => { el.textContent = 'Checking...'; el.className = 'flex items-center gap-1.5'; });
|
||||
|
||||
try {
|
||||
const response = await ApiClient.fetchUrl('/prompt_manager/autotag/models');
|
||||
const response = await fetch('/prompt_manager/autotag/models');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
@@ -3687,7 +3671,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
const skipTagged = document.querySelector('input[name="autoTagMode"]:checked').value === 'skip';
|
||||
|
||||
// Check if model is downloaded
|
||||
const response = await ApiClient.fetchUrl('/prompt_manager/autotag/models');
|
||||
const response = await fetch('/prompt_manager/autotag/models');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && !data.models[modelType].downloaded) {
|
||||
@@ -3778,7 +3762,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
const modelType = document.querySelector('input[name="autoTagModel"]:checked').value;
|
||||
|
||||
// Check if model is downloaded
|
||||
const response = await ApiClient.fetchUrl('/prompt_manager/autotag/models');
|
||||
const response = await fetch('/prompt_manager/autotag/models');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && !data.models[modelType].downloaded) {
|
||||
@@ -3920,7 +3904,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
// Review rows come from /images/all (database ids), so this resolves to image_id
|
||||
const requestBody = ImageHelpers.autotagSingleBody(image, this.autoTagState);
|
||||
|
||||
const response = await ApiClient.fetchUrl('/prompt_manager/autotag/single', {
|
||||
const response = await fetch('/prompt_manager/autotag/single', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requestBody)
|
||||
@@ -4074,7 +4058,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await ApiClient.fetchUrl('/prompt_manager/autotag/apply', {
|
||||
const response = await fetch('/prompt_manager/autotag/apply', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
|
||||
+2
-14
@@ -25,18 +25,6 @@
|
||||
/** Result of a latestOnly() call that was superseded by a newer one. */
|
||||
const STALE = Symbol("stale response");
|
||||
|
||||
/** Keep Prompt Manager requests under the prefix that serves its page. */
|
||||
function resolveUrl(url, pathname = root.location && root.location.pathname) {
|
||||
if (typeof url !== "string" || !url.startsWith("/prompt_manager/")) return url;
|
||||
const match = (pathname || "").match(/^(.*)\/prompt_manager(?:\/|$)/);
|
||||
return match && match[1] ? match[1] + url : url;
|
||||
}
|
||||
|
||||
/** Like fetch(), but keep Prompt Manager URLs under the page's proxy mount. */
|
||||
function fetchUrl(url, init) {
|
||||
return globalThis.fetch(resolveUrl(url), init);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {typeof fetch} [fetchImpl] - defaults to the global fetch (bound to globalThis)
|
||||
*/
|
||||
@@ -51,7 +39,7 @@
|
||||
}
|
||||
if (signal) init.signal = signal;
|
||||
|
||||
const response = await doFetch(resolveUrl(url), init);
|
||||
const response = await doFetch(url, init);
|
||||
let data = null;
|
||||
let parsed = true;
|
||||
try {
|
||||
@@ -99,7 +87,7 @@
|
||||
};
|
||||
}
|
||||
|
||||
const api = { createApiClient, latestOnly, resolveUrl, fetchUrl, ApiError, STALE };
|
||||
const api = { createApiClient, latestOnly, ApiError, STALE };
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
||||
else root.ApiClient = api;
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
.pm-classic .pm-classic-workspace { min-block-size: 0; }
|
||||
.pm-classic .pm-classic-toolbar { display: flex; flex-wrap: wrap; gap: 8px; flex-shrink: 0; }
|
||||
.pm-classic-toolbar > button { flex: 1; }
|
||||
.pm-classic .pm-classic-results { flex: 1; min-block-size: 100px; overflow: auto; padding: 8px; border: 1px solid var(--ca-line); border-radius: 5px; background: var(--ca-field); }
|
||||
.pm-classic-message { color: var(--ca-muted); text-align: center; padding: 12px; margin: 0; overflow-wrap: anywhere; }
|
||||
.pm-classic-error { color: var(--ca-error-text); background: var(--ca-error-bg); }
|
||||
.pm-classic-count { color: var(--ca-text); text-align: center; margin: 0 0 10px; }
|
||||
.pm-classic button.pm-classic-result { display: flex; flex-direction: column; align-items: stretch; text-align: start; inline-size: 100%; white-space: normal; margin-block-end: 5px; }
|
||||
.pm-classic-result[aria-pressed="true"] { border-color: var(--ca-accent); box-shadow: inset 3px 0 var(--ca-accent); }
|
||||
.pm-classic-preview { overflow-wrap: anywhere; }
|
||||
.pm-classic-metadata { color: var(--ca-muted); font-size: 11px; overflow-wrap: anywhere; }
|
||||
.pm-classic-restore { white-space: normal !important; }
|
||||
.pm-classic-notification { position: fixed; inset-block-start: 20px; inset-inline-end: 20px; padding: 10px 15px; border: 1px solid var(--ca-border); border-radius: 5px; z-index: 10000; max-inline-size: min(300px, calc(100vw - 40px)); overflow-wrap: anywhere; }
|
||||
.pm-classic-notification[data-kind="error"] { color: var(--ca-error-text); background: var(--ca-error-bg); border-color: var(--ca-error); }
|
||||
@@ -1,156 +0,0 @@
|
||||
import { createScope, createButton, mountTheme } from './shared/toolkit/core.js';
|
||||
|
||||
function loadStyles() {
|
||||
const href = new URL('./classic-node.css', import.meta.url).href;
|
||||
if ([...document.querySelectorAll('link[rel="stylesheet"]')].some(link => link.href === href)) return;
|
||||
const link = document.createElement('link');
|
||||
Object.assign(link, { rel: 'stylesheet', href });
|
||||
document.head.append(link);
|
||||
}
|
||||
|
||||
function message(node, text, error = false) {
|
||||
node._classicResultsScope?.dispose();
|
||||
const element = document.createElement('p');
|
||||
element.className = error ? 'pm-classic-message pm-classic-error' : 'pm-classic-message';
|
||||
element.textContent = text;
|
||||
element.setAttribute('role', error ? 'alert' : 'status');
|
||||
node.resultsSection.replaceChildren(element);
|
||||
}
|
||||
|
||||
export function installClassicControls(nodeType, app, parseSearchQuery, api) {
|
||||
const prototype = nodeType.prototype;
|
||||
const onCreated = prototype.onNodeCreated;
|
||||
const onRemoved = prototype.onRemoved;
|
||||
prototype.onNodeCreated = function (...args) {
|
||||
this._classicScope = createScope();
|
||||
this._classicNotifications = new Set();
|
||||
this._classicScope.cleanup(() => {
|
||||
clearTimeout(this._textSyncTimer);
|
||||
clearTimeout(this.resultHideTimer);
|
||||
this._classicResultsScope?.dispose();
|
||||
for (const item of this._classicNotifications) { clearTimeout(item.timer); item.element.remove(); }
|
||||
this._classicNotifications.clear();
|
||||
});
|
||||
onCreated.apply(this, args);
|
||||
};
|
||||
prototype.onRemoved = function (...args) {
|
||||
this._classicScope?.dispose();
|
||||
return onRemoved?.apply(this, args);
|
||||
};
|
||||
prototype.createSearchUI = function (container) {
|
||||
loadStyles();
|
||||
container.classList.add('ca-tools', 'pm-classic');
|
||||
mountTheme(container, app.ui?.settings, this._classicScope);
|
||||
this._classicContainer = container;
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'pm-classic-workspace';
|
||||
const buttons = document.createElement('div');
|
||||
buttons.className = 'pm-classic-toolbar';
|
||||
for (const [label, action, primary] of [
|
||||
['🔍 Search', () => this.performSearch(), true],
|
||||
['📋 Recent', () => this.loadRecentPrompts()],
|
||||
['🌐 Web UI', () => this.openWebInterface()],
|
||||
]) {
|
||||
const button = createButton(label, primary ? 'ca-primary' : '');
|
||||
this._classicScope.listen(button, 'click', action);
|
||||
buttons.append(button);
|
||||
}
|
||||
wrapper.append(buttons);
|
||||
if (this.properties.showTestButton) {
|
||||
const test = createButton('🔧 Test API');
|
||||
this._classicScope.listen(test, 'click', () => this.testApiConnection());
|
||||
wrapper.append(test);
|
||||
}
|
||||
this.resultsSection = document.createElement('div');
|
||||
this.resultsSection.className = 'pm-classic-results';
|
||||
wrapper.append(this.resultsSection);
|
||||
container.replaceChildren(wrapper);
|
||||
message(this, '💡 Click Search or Recent to find prompts');
|
||||
};
|
||||
prototype.performSearch = async function () {
|
||||
const { text, tags } = parseSearchQuery(this.widgets?.find(widget => widget.name === 'search_text')?.value || '');
|
||||
if (!text && !tags.length) { message(this, '⚠️ Enter search text or tag:name'); return; }
|
||||
await this._classicQuery('search_prompts', { search_text: text, tags }, '🔍 Searching database...');
|
||||
};
|
||||
prototype.loadRecentPrompts = async function () {
|
||||
await this._classicQuery('get_recent_prompts', { limit: 20 }, '📋 Loading recent prompts...');
|
||||
};
|
||||
prototype._classicQuery = async function (method, params, pending) {
|
||||
message(this, pending);
|
||||
try {
|
||||
const response = await this.callNodeMethod(method, params);
|
||||
if (this._classicScope.signal.aborted) return;
|
||||
this.displayResults(response.results || []);
|
||||
this.showNotification(response.results?.length ? `Found ${response.results.length} prompts` : 'No recent prompts found');
|
||||
} catch (error) {
|
||||
if (this._classicScope.signal.aborted) return;
|
||||
message(this, `❌ ${error.message}`, true);
|
||||
this.showNotification(error.message, 'error');
|
||||
}
|
||||
};
|
||||
prototype.displayResults = function (results) {
|
||||
if (this._classicScope.signal.aborted) return;
|
||||
this._searchResults = results || [];
|
||||
this._selectedPromptIndex = -1;
|
||||
if (!results?.length) { message(this, '📭 No prompts found'); return; }
|
||||
this._classicResultsScope?.dispose();
|
||||
this._classicResultsScope = createScope();
|
||||
const header = document.createElement('p');
|
||||
header.className = 'pm-classic-count';
|
||||
header.textContent = `📚 Found ${results.length} prompt${results.length === 1 ? '' : 's'}`;
|
||||
this.resultsSection.replaceChildren(header);
|
||||
results.forEach((result, index) => {
|
||||
const button = createButton('', 'pm-classic-result');
|
||||
button.setAttribute('aria-pressed', 'false');
|
||||
const preview = document.createElement('span');
|
||||
preview.className = 'pm-classic-preview';
|
||||
preview.textContent = result.text.length > 80 ? result.text.substring(0, 80) + '...' : result.text;
|
||||
const metadata = document.createElement('span');
|
||||
metadata.className = 'pm-classic-metadata';
|
||||
metadata.textContent = `${result.category || 'No category'} · ${result.rating ? '⭐' + result.rating : 'No rating'} · ${result.created_at ? new Date(result.created_at).toLocaleDateString() : 'Unknown'} · ${Array.isArray(result.tags) ? result.tags.join(', ') : 'No tags'}`;
|
||||
button.append(preview, metadata);
|
||||
this._classicResultsScope.listen(button, 'click', () => this.usePrompt(result, index));
|
||||
this.resultsSection.append(button);
|
||||
});
|
||||
this.startAutoHideTimer();
|
||||
};
|
||||
prototype.highlightSelectedResult = function (index) {
|
||||
this._selectedPromptIndex = index;
|
||||
this.resultsSection.querySelectorAll('.pm-classic-result').forEach((element, i) => element.setAttribute('aria-pressed', String(i === index)));
|
||||
};
|
||||
prototype.testApiConnection = async function () {
|
||||
message(this, '🔧 Testing API connection...');
|
||||
try {
|
||||
const response = await fetch(api.fileURL('/prompt_manager/test'), { signal: this._classicScope.signal });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
const data = await response.json();
|
||||
if (!data.success) throw new Error(data.message || 'Test failed');
|
||||
if (this._classicScope.signal.aborted) return;
|
||||
message(this, `✅ API Connection Successful! ${data.message || ''} ${data.timestamp || ''}`);
|
||||
this.showNotification('API connection test passed!', 'success');
|
||||
} catch (error) {
|
||||
if (this._classicScope.signal.aborted) return;
|
||||
message(this, `❌ API Connection Failed: ${error.message}`, true);
|
||||
this.showNotification(`API test failed: ${error.message}`, 'error');
|
||||
}
|
||||
};
|
||||
prototype.hideResults = function () {
|
||||
if (this._classicScope.signal.aborted || this.restoreButton) return;
|
||||
this.resultsSection.style.display = 'none';
|
||||
this.restoreButton = createButton('🔄 Results auto-hidden · Click to restore', 'pm-classic-restore');
|
||||
this._classicScope.listen(this.restoreButton, 'click', () => this.showResults());
|
||||
this.resultsSection.after(this.restoreButton);
|
||||
};
|
||||
prototype.showNotification = function (text, type = 'info') {
|
||||
if (this._classicScope.signal.aborted) return;
|
||||
const element = document.createElement('div');
|
||||
element.className = 'ca-tools pm-classic-notification';
|
||||
element.dataset.caTheme = this._classicContainer.dataset.caTheme;
|
||||
element.dataset.kind = type;
|
||||
element.setAttribute('role', type === 'error' ? 'alert' : 'status');
|
||||
element.textContent = text;
|
||||
document.body.append(element);
|
||||
const item = { element, timer: setTimeout(() => { element.remove(); this._classicNotifications.delete(item); }, 3000) };
|
||||
this._classicNotifications.add(item);
|
||||
};
|
||||
}
|
||||
+166
-201
@@ -205,7 +205,7 @@
|
||||
|
||||
async loadFolderTree() {
|
||||
try {
|
||||
const response = await ApiClient.fetchUrl('/prompt_manager/gallery/subfolders?include_ancestors=true');
|
||||
const response = await fetch('/prompt_manager/gallery/subfolders?include_ancestors=true');
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
if (data.success && data.subfolders.length > 0) {
|
||||
@@ -352,15 +352,9 @@
|
||||
);
|
||||
this.currentPage = next.page;
|
||||
this.total = next.total;
|
||||
const images = data.images.map(image => ({
|
||||
...image,
|
||||
url: ApiClient.resolveUrl(image.url),
|
||||
thumbnail_url: ApiClient.resolveUrl(image.thumbnail_url),
|
||||
}));
|
||||
const from = append ? this.images.length : 0;
|
||||
this.images = append ? this.images.concat(images) : images;
|
||||
this.images = append ? this.images.concat(data.images) : data.images;
|
||||
this.updateStats();
|
||||
this.renderGallery({ from });
|
||||
this.renderGallery();
|
||||
this.updatePagination();
|
||||
document.getElementById('loadingStatus').textContent = 'Loaded';
|
||||
this.maybeLoadMore(); // a short page may not fill the viewport
|
||||
@@ -446,8 +440,7 @@
|
||||
this.renderGallery();
|
||||
}
|
||||
|
||||
renderGallery({ from = 0 } = {}) {
|
||||
if (from === 0) this.destroyViewer();
|
||||
renderGallery() {
|
||||
if (this.images.length === 0) {
|
||||
this.showError('No images found in the output folder');
|
||||
return;
|
||||
@@ -457,15 +450,19 @@
|
||||
document.getElementById('errorState').classList.add('hidden');
|
||||
|
||||
if (this.viewMode === 'grid') {
|
||||
this.renderGridView({ from });
|
||||
this.renderGridView();
|
||||
} else {
|
||||
this.renderListView({ from });
|
||||
this.renderListView();
|
||||
}
|
||||
|
||||
// Initialize ViewerJS after rendering
|
||||
setTimeout(() => this.initializeViewer(), 100);
|
||||
|
||||
// Add simple fallback click handlers
|
||||
this.addFallbackClickHandlers();
|
||||
}
|
||||
|
||||
renderGridView({ from = 0 } = {}) {
|
||||
renderGridView() {
|
||||
const grid = document.getElementById('galleryGrid');
|
||||
const list = document.getElementById('galleryList');
|
||||
const showInfo = this.getSettings().showImageInfo;
|
||||
@@ -473,8 +470,7 @@
|
||||
list.classList.add('hidden');
|
||||
grid.classList.remove('hidden');
|
||||
|
||||
const html = this.images.slice(from).map((image, offset) => {
|
||||
const index = from + offset;
|
||||
grid.innerHTML = this.images.map((image, index) => {
|
||||
// Use thumbnail for display if available, fallback to original
|
||||
const displayUrl = image.thumbnail_url || image.url;
|
||||
const hasThumb = !!image.thumbnail_url;
|
||||
@@ -482,7 +478,7 @@
|
||||
const mediaType = image.media_type || 'image';
|
||||
|
||||
return `
|
||||
<div data-image-index="${index}" class="image-item bg-pm-surface rounded-pm-md overflow-hidden border border-pm hover:border-pm cursor-pointer group">
|
||||
<div class="image-item bg-pm-surface rounded-pm-md overflow-hidden border border-pm hover:border-pm cursor-pointer group">
|
||||
<div class="aspect-square bg-pm-primary overflow-hidden relative">
|
||||
<img src="${escapeHtml(displayUrl)}"
|
||||
alt="${escapeHtml(image.filename)}"
|
||||
@@ -524,11 +520,9 @@
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
if (from === 0) grid.innerHTML = html;
|
||||
else grid.insertAdjacentHTML('beforeend', html);
|
||||
}
|
||||
|
||||
renderListView({ from = 0 } = {}) {
|
||||
renderListView() {
|
||||
const grid = document.getElementById('galleryGrid');
|
||||
const list = document.getElementById('galleryList');
|
||||
const showInfo = this.getSettings().showImageInfo;
|
||||
@@ -536,8 +530,7 @@
|
||||
grid.classList.add('hidden');
|
||||
list.classList.remove('hidden');
|
||||
|
||||
const html = this.images.slice(from).map((image, offset) => {
|
||||
const index = from + offset;
|
||||
list.innerHTML = this.images.map((image, index) => {
|
||||
// Use thumbnail for display if available, fallback to original
|
||||
const displayUrl = image.thumbnail_url || image.url;
|
||||
const hasThumb = !!image.thumbnail_url;
|
||||
@@ -545,7 +538,7 @@
|
||||
const mediaType = image.media_type || 'image';
|
||||
|
||||
return `
|
||||
<div data-image-index="${index}" class="image-item bg-pm-surface rounded-pm-md border border-pm hover:border-pm cursor-pointer group flex items-center p-4">
|
||||
<div class="image-item bg-pm-surface rounded-pm-md border border-pm hover:border-pm cursor-pointer group flex items-center p-4">
|
||||
<div class="w-16 h-16 bg-pm-primary rounded-pm-md overflow-hidden flex-shrink-0 mr-4 relative">
|
||||
<img src="${escapeHtml(displayUrl)}"
|
||||
alt="${escapeHtml(image.filename)}"
|
||||
@@ -578,37 +571,26 @@
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
if (from === 0) list.innerHTML = html;
|
||||
else list.insertAdjacentHTML('beforeend', html);
|
||||
}
|
||||
|
||||
destroyViewer({ keepSlideshow = false } = {}) {
|
||||
this.metadataRequest = null;
|
||||
this.currentMetadata = null;
|
||||
if (!keepSlideshow) this.stopViewerSlideshow();
|
||||
initializeViewer() {
|
||||
// Destroy existing viewer if it exists
|
||||
if (this.viewer) {
|
||||
this.viewer.clearEnforceFocus();
|
||||
this.viewer.destroy();
|
||||
}
|
||||
this.viewer = null;
|
||||
document.getElementById('metadata-sidebar')?.remove();
|
||||
}
|
||||
|
||||
openViewer(index, { keepSlideshow = false } = {}) {
|
||||
if (!Number.isInteger(index) || index < 0 || index >= this.images.length) return;
|
||||
this.destroyViewer({ keepSlideshow });
|
||||
const size = 100;
|
||||
const start = Math.max(0, Math.min(index - size / 2, this.images.length - size));
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = this.images.slice(start, start + size).map(image =>
|
||||
`<img src="${escapeHtml(image.thumbnail_url || image.url)}"
|
||||
data-original="${escapeHtml(image.url)}"
|
||||
alt="${escapeHtml(image.filename)}">`
|
||||
).join('');
|
||||
const container = this.viewMode === 'grid' ? document.getElementById('galleryGrid') : document.getElementById('galleryList');
|
||||
|
||||
// Find all images in the container
|
||||
const images = container.querySelectorAll('img[data-original]');
|
||||
|
||||
if (images.length === 0) {
|
||||
console.warn('No images found for viewer initialization');
|
||||
return;
|
||||
}
|
||||
|
||||
this.viewer = new Viewer(container, {
|
||||
inline: false,
|
||||
initialViewIndex: index - start,
|
||||
button: true,
|
||||
backdrop: true,
|
||||
navbar: true,
|
||||
@@ -637,22 +619,14 @@
|
||||
// Add metadata sidebar when ViewerJS opens
|
||||
// Use the original image URL from data-original attribute
|
||||
const originalImg = event.detail.originalImage;
|
||||
this.addMetadataSidebar(originalImg);
|
||||
setTimeout(() => this.addMetadataSidebar(originalImg), 100);
|
||||
},
|
||||
play: (event) => {
|
||||
event.preventDefault();
|
||||
if (this.slideshowPlaying) this.stopViewerSlideshow();
|
||||
else {
|
||||
this.slideshowPlaying = true;
|
||||
if (this.viewer.options.fullscreen) this.viewer.requestFullscreen();
|
||||
this.scheduleViewerSlide();
|
||||
}
|
||||
show: function() {
|
||||
},
|
||||
hide: () => {
|
||||
this.metadataRequest = null;
|
||||
this.currentMetadata = null;
|
||||
this.viewer?.clearEnforceFocus();
|
||||
this.stopViewerSlideshow();
|
||||
shown: function() {
|
||||
},
|
||||
hide: function() {
|
||||
// Remove sidebar when viewer closes
|
||||
const sidebar = document.getElementById('metadata-sidebar');
|
||||
if (sidebar) {
|
||||
sidebar.remove();
|
||||
@@ -660,50 +634,12 @@
|
||||
}
|
||||
});
|
||||
|
||||
const viewer = this.viewer;
|
||||
const navigate = (direction, loop = false) => {
|
||||
if (viewer !== this.viewer || viewer.hiding || viewer.played) return viewer;
|
||||
let target = start + viewer.index + direction;
|
||||
if (loop) target = (target + this.images.length) % this.images.length;
|
||||
else target = Math.max(0, Math.min(target, this.images.length - 1));
|
||||
if (target >= start && target < start + viewer.length) {
|
||||
viewer.view(target - start);
|
||||
} else {
|
||||
this.openViewer(target, { keepSlideshow: true });
|
||||
}
|
||||
return this.viewer;
|
||||
};
|
||||
viewer.prev = (loop) => navigate(-1, loop);
|
||||
viewer.next = (loop) => navigate(1, loop);
|
||||
viewer.show();
|
||||
}
|
||||
|
||||
stopViewerSlideshow() {
|
||||
clearTimeout(this.slideshowTimer);
|
||||
this.slideshowTimer = null;
|
||||
if (this.slideshowPlaying) this.viewer?.exitFullscreen();
|
||||
this.slideshowPlaying = false;
|
||||
}
|
||||
|
||||
scheduleViewerSlide() {
|
||||
// Native Viewer.js slideshows preload every original and loop within the filmstrip.
|
||||
this.slideshowTimer = setTimeout(() => {
|
||||
this.slideshowTimer = null;
|
||||
if (!this.slideshowPlaying || !this.viewer) return;
|
||||
this.viewer.next(true);
|
||||
if (this.slideshowPlaying) this.scheduleViewerSlide();
|
||||
}, 5000);
|
||||
// Don't add click handlers here - let ViewerJS handle them
|
||||
}
|
||||
|
||||
addMetadataSidebar(originalImage) {
|
||||
const viewerContainer = document.querySelector('.viewer-container');
|
||||
if (!viewerContainer) return;
|
||||
const existing = document.getElementById('metadata-sidebar');
|
||||
if (existing) {
|
||||
this.loadImageMetadata(originalImage.dataset.original || originalImage.src,
|
||||
existing.querySelector('#metadata-content'));
|
||||
return;
|
||||
}
|
||||
if (!viewerContainer || document.getElementById('metadata-sidebar')) return;
|
||||
|
||||
const sidebar = document.createElement('div');
|
||||
sidebar.id = 'metadata-sidebar';
|
||||
@@ -749,37 +685,81 @@
|
||||
// Load metadata for the current image (ALWAYS use original for metadata)
|
||||
const originalImageUrl = originalImage.dataset.original || originalImage.src;
|
||||
this.loadImageMetadata(originalImageUrl, sidebar.querySelector('#metadata-content'));
|
||||
|
||||
// Listen for ViewerJS view changes to update metadata
|
||||
this.setupViewerMetadataUpdates(sidebar);
|
||||
}
|
||||
|
||||
addFallbackClickHandlers() {
|
||||
for (const id of ['galleryGrid', 'galleryList']) {
|
||||
const container = document.getElementById(id);
|
||||
if (container.dataset.mediaClicksBound) continue;
|
||||
container.dataset.mediaClicksBound = '1';
|
||||
container.addEventListener('click', (event) => {
|
||||
const item = event.target.closest('.image-item');
|
||||
if (!item) return;
|
||||
const img = item.querySelector('img[data-original]');
|
||||
if (!img) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const index = Number(item.dataset.imageIndex);
|
||||
const { original, caption, isVideo } = img.dataset;
|
||||
if (isVideo === 'true') {
|
||||
this.showVideoModal(original, caption, index);
|
||||
} else {
|
||||
try {
|
||||
this.openViewer(index);
|
||||
} catch (error) {
|
||||
console.warn('ViewerJS failed, using fallback:', error);
|
||||
this.destroyViewer();
|
||||
this.showSimpleImageModal(original, caption);
|
||||
setupViewerMetadataUpdates(sidebar) {
|
||||
// Listen for ViewerJS navigation events
|
||||
const viewerContainer = document.querySelector('.viewer-container');
|
||||
if (!viewerContainer) return;
|
||||
|
||||
// Override ViewerJS navigation to update metadata
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.type === 'attributes' && mutation.attributeName === 'src') {
|
||||
const img = mutation.target;
|
||||
if (img.tagName === 'IMG' && img.closest('.viewer-canvas')) {
|
||||
// Find the corresponding original image
|
||||
const imageUrl = img.src;
|
||||
this.loadImageMetadata(imageUrl, sidebar.querySelector('#metadata-content'));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const canvas = viewerContainer.querySelector('.viewer-canvas');
|
||||
if (canvas) {
|
||||
observer.observe(canvas, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['src']
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
addFallbackClickHandlers() {
|
||||
// Add simple click handlers that work even if ViewerJS fails
|
||||
const container = this.viewMode === 'grid' ? document.getElementById('galleryGrid') : document.getElementById('galleryList');
|
||||
const imageItems = container.querySelectorAll('.image-item');
|
||||
|
||||
imageItems.forEach((item, index) => {
|
||||
item.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const img = item.querySelector('img[data-original]');
|
||||
if (img) {
|
||||
// ALWAYS use the original media URL for viewing
|
||||
const originalMediaUrl = img.dataset.original;
|
||||
const caption = img.dataset.caption;
|
||||
const isVideo = img.dataset.isVideo === 'true';
|
||||
const mediaType = img.dataset.mediaType || 'image';
|
||||
|
||||
|
||||
if (isVideo) {
|
||||
// For videos, use our custom video modal
|
||||
this.showVideoModal(originalMediaUrl, caption, index);
|
||||
} else {
|
||||
// For images, try ViewerJS first, fallback to simple modal
|
||||
if (this.viewer) {
|
||||
try {
|
||||
this.viewer.view(index);
|
||||
} catch (e) {
|
||||
console.warn('ViewerJS failed, using fallback:', e);
|
||||
this.showSimpleImageModal(originalMediaUrl, caption);
|
||||
}
|
||||
} else {
|
||||
this.showSimpleImageModal(originalMediaUrl, caption);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async showSimpleImageModal(imageUrl, caption) {
|
||||
// Create a modal that matches the screenshot design
|
||||
const modal = document.createElement('div');
|
||||
@@ -1193,18 +1173,15 @@
|
||||
}
|
||||
|
||||
async loadImageMetadata(imageUrl, container) {
|
||||
const request = {};
|
||||
this.metadataRequest = request;
|
||||
this.currentMetadata = null;
|
||||
container.textContent = 'Loading metadata...';
|
||||
const isCurrent = () => this.metadataRequest === request && container.isConnected !== false;
|
||||
try {
|
||||
// First try to get linked prompt from database
|
||||
let databasePrompt = null;
|
||||
try {
|
||||
const metadataUrl = ImageHelpers.metadataLookupUrl(imageUrl);
|
||||
if (metadataUrl) {
|
||||
const response = await ApiClient.fetchUrl(metadataUrl);
|
||||
// Extract relative path from the URL for the API call
|
||||
const urlParts = imageUrl.split('/prompt_manager/images/serve/');
|
||||
if (urlParts.length > 1) {
|
||||
const relativePath = urlParts[1];
|
||||
const response = await fetch(`/prompt_manager/images/prompt/${encodeURIComponent(relativePath)}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success && data.prompt) {
|
||||
@@ -1263,9 +1240,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
if (isCurrent()) this.displayMetadata(metadata, container);
|
||||
this.displayMetadata(metadata, container);
|
||||
} catch (error) {
|
||||
if (!isCurrent()) return;
|
||||
console.error('Error loading metadata:', error);
|
||||
container.innerHTML = `
|
||||
<div class="bg-pm-error-tint border border-pm-error rounded-pm-md p-4">
|
||||
@@ -1613,7 +1589,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
async extractImageMetadata(imageUrl) {
|
||||
try {
|
||||
// Fetch the image as an array buffer
|
||||
const response = await ApiClient.fetchUrl(imageUrl);
|
||||
const response = await fetch(imageUrl);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
|
||||
// Parse PNG metadata
|
||||
@@ -1766,7 +1742,6 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
debugMode: document.getElementById('debugModeToggle').checked,
|
||||
apiTimeout: parseInt(document.getElementById('apiTimeoutInput').value),
|
||||
thumbnailsGenerated: this.getSettings().thumbnailsGenerated, // Preserve this
|
||||
thumbnailPromptDismissed: this.getSettings().thumbnailPromptDismissed,
|
||||
checkThumbnailsAtStartup: document.getElementById('checkThumbnailsAtStartup').checked,
|
||||
// Video settings
|
||||
videoAutoplay: document.getElementById('videoAutoplayToggle').checked,
|
||||
@@ -2115,7 +2090,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
|
||||
// Call backend to clear server-side thumbnails (only our generated thumbnails)
|
||||
try {
|
||||
const response = await ApiClient.fetchUrl('/prompt_manager/images/clear-thumbnails', {
|
||||
const response = await fetch('/prompt_manager/images/clear-thumbnails', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
@@ -2206,7 +2181,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
async resumeDuplicateScan() {
|
||||
let status;
|
||||
try {
|
||||
const response = await ApiClient.fetchUrl('/prompt_manager/scan_duplicates/status');
|
||||
const response = await fetch('/prompt_manager/scan_duplicates/status');
|
||||
if (!response.ok) return;
|
||||
status = await response.json();
|
||||
} catch (_) {
|
||||
@@ -2344,7 +2319,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
${image.media_type === 'video' ? `
|
||||
<div class="w-20 h-20 bg-pm-input rounded flex items-center justify-center relative">
|
||||
${image.thumbnail_url ? `
|
||||
<img src="${escapeHtml(ApiClient.resolveUrl(image.thumbnail_url))}" alt="Video thumbnail" class="w-full h-full object-cover rounded">
|
||||
<img src="${escapeHtml(image.thumbnail_url)}" alt="Video thumbnail" class="w-full h-full object-cover rounded">
|
||||
` : `
|
||||
<svg class="w-8 h-8 text-pm-secondary" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M2 6a2 2 0 012-2h6l2 2h6a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6zM14.553 7.106A1 1 0 0014 8v4a1 1 0 00.553.894l2 1A1 1 0 0018 13V7a1 1 0 00-1.447-.894l-2 1z"/>
|
||||
@@ -2353,7 +2328,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
<div class="absolute bottom-0 right-0 bg-black/75 text-pm text-xs px-1 rounded">VIDEO</div>
|
||||
</div>
|
||||
` : `
|
||||
<img src="${escapeHtml(ApiClient.resolveUrl(image.thumbnail_url || image.url))}" alt="${escapeHtml(image.filename)}"
|
||||
<img src="${escapeHtml(image.thumbnail_url || image.url)}" alt="${escapeHtml(image.filename)}"
|
||||
class="w-20 h-20 object-cover rounded cursor-pointer hover:opacity-80"
|
||||
onclick="this.parentElement.parentElement.querySelector('.image-preview').click()">
|
||||
`}
|
||||
@@ -2372,7 +2347,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-2">
|
||||
<button class="image-preview text-xs bg-pm-accent hover:bg-pm-accent-hover text-pm px-2 py-1 rounded"
|
||||
data-action="open-url" data-url="${escapeHtml(ApiClient.resolveUrl(image.url))}">
|
||||
data-action="open-url" data-url="${escapeHtml(image.url)}">
|
||||
View Full
|
||||
</button>
|
||||
</div>
|
||||
@@ -2432,7 +2407,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
try {
|
||||
const imagePaths = Array.from(checkboxes).map(cb => cb.dataset.imagePath);
|
||||
|
||||
const response = await ApiClient.fetchUrl('/prompt_manager/delete_duplicate_images', {
|
||||
const response = await fetch('/prompt_manager/delete_duplicate_images', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
@@ -2523,16 +2498,37 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await this.api.get('/prompt_manager/images/thumbnails/status');
|
||||
const total = data.total_images;
|
||||
const pending = data.pending;
|
||||
if (!data.success || !Number.isSafeInteger(total) ||
|
||||
!Number.isSafeInteger(pending) || total <= 0 ||
|
||||
pending <= 0 || pending > total) return;
|
||||
const signature = JSON.stringify([data.collection_id, total, pending]);
|
||||
if (settings.thumbnailPromptDismissed === signature) return;
|
||||
if (pending / total > 0.1) {
|
||||
this.showThumbnailGenerationPrompt(total, pending, signature);
|
||||
// Check a sample of images to see if any are missing thumbnails
|
||||
const response = await fetch('/prompt_manager/images/output?limit=50&offset=0');
|
||||
const data = await response.json();
|
||||
|
||||
console.debug('Checking images for missing thumbnails:', data);
|
||||
|
||||
if (data.success && data.images && data.images.length > 0) {
|
||||
// Count images missing thumbnails
|
||||
let missingThumbnails = 0;
|
||||
for (const image of data.images) {
|
||||
if (!image.thumbnail_url) {
|
||||
missingThumbnails++;
|
||||
}
|
||||
}
|
||||
|
||||
console.debug(`Found ${missingThumbnails} images without thumbnails in sample of ${data.images.length}`);
|
||||
|
||||
// If more than 10% of sampled images are missing thumbnails, show prompt
|
||||
const missingPercentage = (missingThumbnails / data.images.length) * 100;
|
||||
if (missingThumbnails > 0 && missingPercentage > 10) {
|
||||
// Estimate total missing based on sample
|
||||
const estimatedMissing = Math.round((missingThumbnails / data.images.length) * data.total);
|
||||
console.debug(`Estimated ${estimatedMissing} total images missing thumbnails (${missingPercentage.toFixed(1)}% of sample)`);
|
||||
this.showThumbnailGenerationPrompt(data.total, estimatedMissing);
|
||||
} else if (missingThumbnails > 0) {
|
||||
console.debug(`Only ${missingPercentage.toFixed(1)}% of images missing thumbnails, not showing prompt`);
|
||||
} else {
|
||||
console.debug('All sampled images have thumbnails');
|
||||
}
|
||||
} else {
|
||||
console.debug('No images found or failed to fetch images');
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('Could not check for missing thumbnails at startup:', error);
|
||||
@@ -2540,7 +2536,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
}, 1000); // Wait 1 second for page to settle
|
||||
}
|
||||
|
||||
showThumbnailGenerationPrompt(imageCount, missingCount = imageCount, signature = null) {
|
||||
showThumbnailGenerationPrompt(imageCount, missingCount = imageCount) {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'fixed inset-0 bg-black/50 flex items-center justify-center z-50';
|
||||
modal.innerHTML = `
|
||||
@@ -2551,10 +2547,10 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
Generate Thumbnails?
|
||||
</h3>
|
||||
<p class="text-pm-secondary mb-4">
|
||||
${missingCount} images need new or updated thumbnails (${imageCount} total images). Generate them for faster loading?
|
||||
Found ${missingCount} images without thumbnails (${imageCount} total images). Would you like to generate thumbnails for faster loading?
|
||||
</p>
|
||||
<p class="text-sm text-pm-muted mb-3">
|
||||
Only missing or outdated thumbnails will be generated. Existing current thumbnails will be kept. You can change startup checks in Gallery Settings.
|
||||
💡 Thumbnails speed up gallery loading significantly. You can change this setting in Gallery Settings.
|
||||
</p>
|
||||
<div class="flex space-x-3">
|
||||
<button id="thumbnailPromptNo" class="flex-1 px-4 py-2 bg-pm-input hover:bg-pm-hover text-pm rounded-pm-md transition-colors">
|
||||
@@ -2570,20 +2566,11 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
const dismiss = () => {
|
||||
if (signature) {
|
||||
try {
|
||||
const settings = this.getSettings();
|
||||
settings.thumbnailPromptDismissed = signature;
|
||||
localStorage.setItem('gallerySettings', JSON.stringify(settings));
|
||||
} catch (error) {
|
||||
console.warn('Could not save thumbnail prompt dismissal:', error);
|
||||
}
|
||||
}
|
||||
// Handle button clicks
|
||||
document.getElementById('thumbnailPromptNo').addEventListener('click', () => {
|
||||
document.body.removeChild(modal);
|
||||
};
|
||||
document.getElementById('thumbnailPromptNo').addEventListener('click', dismiss);
|
||||
|
||||
});
|
||||
|
||||
document.getElementById('thumbnailPromptYes').addEventListener('click', () => {
|
||||
// Don't remove modal yet - transform it to show progress
|
||||
this.startThumbnailGenerationInModal(modal, imageCount, missingCount);
|
||||
@@ -2592,7 +2579,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
// Close on outside click
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) {
|
||||
dismiss();
|
||||
document.body.removeChild(modal);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -2988,7 +2975,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
async loadTagsAndCategories() {
|
||||
try {
|
||||
// Load tags
|
||||
const tagsResponse = await ApiClient.fetchUrl('/prompt_manager/tags');
|
||||
const tagsResponse = await fetch('/prompt_manager/tags');
|
||||
const tagsData = await tagsResponse.json();
|
||||
if (tagsData.success) {
|
||||
// Filter out the __protected__ tag from suggestions
|
||||
@@ -2996,7 +2983,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
}
|
||||
|
||||
// Load categories
|
||||
const categoriesResponse = await ApiClient.fetchUrl('/prompt_manager/categories');
|
||||
const categoriesResponse = await fetch('/prompt_manager/categories');
|
||||
const categoriesData = await categoriesResponse.json();
|
||||
if (categoriesData.success) {
|
||||
this.addPromptState.allCategories = categoriesData.categories;
|
||||
@@ -3190,7 +3177,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
saveBtn.textContent = 'Saving...';
|
||||
|
||||
try {
|
||||
const response = await ApiClient.fetchUrl('/prompt_manager/save', {
|
||||
const response = await fetch('/prompt_manager/save', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -3287,7 +3274,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
statusEls.forEach(el => { el.textContent = 'Checking...'; });
|
||||
|
||||
try {
|
||||
const response = await ApiClient.fetchUrl('/prompt_manager/autotag/models');
|
||||
const response = await fetch('/prompt_manager/autotag/models');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
@@ -3396,7 +3383,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
unloadBtn.textContent = 'Unloading...';
|
||||
|
||||
try {
|
||||
const response = await ApiClient.fetchUrl('/prompt_manager/autotag/unload', {
|
||||
const response = await fetch('/prompt_manager/autotag/unload', {
|
||||
method: 'POST'
|
||||
});
|
||||
const data = await response.json();
|
||||
@@ -3422,7 +3409,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
const keepInMemory = document.getElementById('keepModelInMemory').checked;
|
||||
|
||||
// Check if model is downloaded
|
||||
const response = await ApiClient.fetchUrl('/prompt_manager/autotag/models');
|
||||
const response = await fetch('/prompt_manager/autotag/models');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && !data.models[modelType].downloaded) {
|
||||
@@ -3512,7 +3499,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
const modelType = document.querySelector('input[name="autoTagModel"]:checked').value;
|
||||
|
||||
// Check if model is downloaded
|
||||
const response = await ApiClient.fetchUrl('/prompt_manager/autotag/models');
|
||||
const response = await fetch('/prompt_manager/autotag/models');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && !data.models[modelType].downloaded) {
|
||||
@@ -3542,10 +3529,6 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
}
|
||||
|
||||
async loadNextReviewImage() {
|
||||
const request = {};
|
||||
this.reviewRequest = request;
|
||||
this.autoTagState.currentTags = [];
|
||||
this.autoTagState.reviewPromptId = null;
|
||||
if (this.autoTagState.reviewIndex >= this.autoTagState.reviewImages.length) {
|
||||
this.hideModal("autoTagReviewModal");
|
||||
this.showNotification('Review complete!', 'success');
|
||||
@@ -3555,7 +3538,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
|
||||
const image = this.autoTagState.reviewImages[this.autoTagState.reviewIndex];
|
||||
document.getElementById('reviewCurrentIndex').textContent = this.autoTagState.reviewIndex + 1;
|
||||
document.getElementById('reviewImage').src = ApiClient.resolveUrl(image.thumbnail_url || image.url);
|
||||
document.getElementById('reviewImage').src = image.thumbnail_url || image.url;
|
||||
document.getElementById('reviewImageName').textContent = image.filename || 'Unknown';
|
||||
document.getElementById('reviewTagsContainer').innerHTML = '<div class="text-pm-secondary">Generating tags...</div>';
|
||||
|
||||
@@ -3563,7 +3546,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
// Gallery entries come from /images/output: id is a path digest, so address by path
|
||||
const requestBody = ImageHelpers.autotagSingleBody(image, this.autoTagState);
|
||||
|
||||
const response = await ApiClient.fetchUrl('/prompt_manager/autotag/single', {
|
||||
const response = await fetch('/prompt_manager/autotag/single', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requestBody)
|
||||
@@ -3571,9 +3554,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (this.reviewRequest !== request) return;
|
||||
if (data.success) {
|
||||
this.autoTagState.reviewPromptId = data.prompt_id ?? null;
|
||||
this.autoTagState.currentTags = data.tags;
|
||||
this.renderReviewTags();
|
||||
} else {
|
||||
@@ -3581,7 +3562,6 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
`<div class="text-pm-error">Error: ${escapeHtml(data.error)}</div>`;
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.reviewRequest !== request) return;
|
||||
console.error('Error generating tags:', error);
|
||||
document.getElementById('reviewTagsContainer').innerHTML =
|
||||
'<div class="text-pm-error">Failed to generate tags</div>';
|
||||
@@ -3619,41 +3599,28 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
return;
|
||||
}
|
||||
|
||||
const promptId = this.autoTagState.reviewPromptId;
|
||||
if (!promptId) {
|
||||
this.showNotification('This image has no linked prompt. Scan the output folder first.', 'warning');
|
||||
return;
|
||||
}
|
||||
const request = this.reviewRequest;
|
||||
if (this.reviewApplying === request) return;
|
||||
this.reviewApplying = request;
|
||||
const image = this.autoTagState.reviewImages[this.autoTagState.reviewIndex];
|
||||
|
||||
try {
|
||||
const response = await ApiClient.fetchUrl('/prompt_manager/autotag/apply', {
|
||||
const response = await fetch('/prompt_manager/autotag/apply', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
prompt_id: promptId,
|
||||
image_path: image.path,
|
||||
tags: this.autoTagState.currentTags
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (this.reviewRequest !== request) return;
|
||||
if (data.success) {
|
||||
this.showNotification(`Applied ${this.autoTagState.currentTags.length} tags`, 'success');
|
||||
} else {
|
||||
this.showNotification(data.error || 'Failed to apply tags', 'warning');
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.reviewRequest !== request) return;
|
||||
console.error('Error applying tags:', error);
|
||||
this.showNotification('Failed to apply tags', 'error');
|
||||
return;
|
||||
} finally {
|
||||
if (this.reviewApplying === request) this.reviewApplying = null;
|
||||
}
|
||||
|
||||
this.autoTagState.reviewIndex++;
|
||||
@@ -3661,8 +3628,6 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
}
|
||||
|
||||
cancelReview() {
|
||||
this.reviewRequest = null;
|
||||
this.autoTagState.reviewPromptId = null;
|
||||
this.autoTagState.reviewImages = [];
|
||||
this.autoTagState.reviewIndex = 0;
|
||||
this.autoTagState.currentTags = [];
|
||||
|
||||
+1
-20
@@ -79,9 +79,6 @@
|
||||
typeof entry.path === "string" && entry.path && !Number.isInteger(entry.id)
|
||||
? { path: entry.path }
|
||||
: { image_id: entry.id };
|
||||
if ("path" in target && Number.isInteger(entry.root_index)) {
|
||||
target.root_index = entry.root_index;
|
||||
}
|
||||
const modelType = String(settings.modelType || "");
|
||||
const params = modelType.startsWith("wd14")
|
||||
? {
|
||||
@@ -106,23 +103,7 @@
|
||||
return original;
|
||||
}
|
||||
|
||||
function metadataLookupUrl(imageUrl) {
|
||||
try {
|
||||
const url = new URL(imageUrl, 'http://prompt-manager.invalid');
|
||||
const marker = '/prompt_manager/images/serve/';
|
||||
const index = url.pathname.indexOf(marker);
|
||||
if (index < 0) return null;
|
||||
const relativePath = decodeURIComponent(url.pathname.slice(index + marker.length));
|
||||
const query = new URLSearchParams();
|
||||
if (url.searchParams.has('root')) query.set('root', url.searchParams.get('root'));
|
||||
const suffix = query.size ? `?${query}` : '';
|
||||
return `/prompt_manager/images/prompt/${encodeURIComponent(relativePath)}${suffix}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const api = { formatImageCaption, workflowDocumentHtml, autotagSingleBody, fallbackImageSource, metadataLookupUrl };
|
||||
const api = { formatImageCaption, workflowDocumentHtml, autotagSingleBody, fallbackImageSource };
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
||||
else root.ImageHelpers = api;
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 ComfyAssets
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,23 +0,0 @@
|
||||
# Initial source provenance
|
||||
|
||||
The initial browser controls are extracted from ComfyAssets' own
|
||||
KikoTools modules at commit `1820fb19ea5d200e8cd2a09bcbbcc35926f13228`.
|
||||
KikoTools remains unchanged by this extraction. Future toolkit changes belong
|
||||
here and are validated in PromptManager before a later KikoTools adoption PR.
|
||||
|
||||
| Toolkit file | KikoTools source | Original source SHA-256 |
|
||||
| --- | --- | --- |
|
||||
| `ui/core.js` | `web/ui/core.js` | `cd10b700441a607cb1d56051cf0a430c0b36f901c0c351d8f4dae067090d8838` |
|
||||
| `ui/controls.css` | `web/ui/controls.css` | `64d6b0f3059b0a21187796059fe14bfb5358a796a18f775543b7baef43874e80` |
|
||||
|
||||
Host orchestration and coverage helpers were extracted and generalized from
|
||||
KikoTools' maintainer tooling, with isolated tracked-file installation and a
|
||||
project preparation adapter proven against PromptManager. They have no runtime
|
||||
imports from either node pack. All original code retains the ComfyAssets MIT
|
||||
license. No Pixaroma implementation, styles or assets were copied.
|
||||
|
||||
`ui/core.js` is unchanged. `ui/controls.css` retains only the shared control,
|
||||
range, touch-target and forced-colors rules (original lines 1–61, 97–99, 118,
|
||||
170–172 and closing brace 174). Feature-view selectors remain in KikoTools.
|
||||
The retained declarations are unchanged; the extracted stylesheet SHA-256 is
|
||||
`3ae1ca39077ffc3621b86ae7ef77202841fa27061b0e4623f2bb0413b27ecd7b`.
|
||||
@@ -1,69 +0,0 @@
|
||||
.ca-tools {
|
||||
color-scheme: dark;
|
||||
--ca-surface: #2b2b2e;
|
||||
--ca-field: #202023;
|
||||
--ca-control: #37373b;
|
||||
--ca-hover: #45454b;
|
||||
--ca-text: #eeeef1;
|
||||
--ca-muted: #b5b5bd;
|
||||
--ca-line: #48484f;
|
||||
--ca-border: #7d7d87;
|
||||
--ca-accent: #0877bb;
|
||||
--ca-focus: #5bc2ff;
|
||||
--ca-error: #d52430;
|
||||
--ca-error-text: #ffadb3;
|
||||
--ca-error-bg: #391d23;
|
||||
font: 13px/1.5 Inter, ui-sans-serif, system-ui, sans-serif;
|
||||
background: var(--ca-surface);
|
||||
color: var(--ca-text);
|
||||
}
|
||||
.ca-tools[data-ca-theme="light"] {
|
||||
color-scheme: light;
|
||||
--ca-surface: #ffffff;
|
||||
--ca-field: #f5f5f7;
|
||||
--ca-control: #eeeef1;
|
||||
--ca-hover: #e2e2e8;
|
||||
--ca-text: #23232b;
|
||||
--ca-muted: #5c5c68;
|
||||
--ca-line: #d4d4dc;
|
||||
--ca-border: #85858f;
|
||||
--ca-focus: #0066a6;
|
||||
--ca-error-text: #a20d20;
|
||||
--ca-error-bg: #fff0f1;
|
||||
}
|
||||
.ca-tools :where(div, span, label, input, textarea, button, select, fieldset) { box-sizing: border-box; }
|
||||
.ca-tools :where(button, input, select, textarea) { font: inherit; color: inherit; accent-color: var(--ca-accent); }
|
||||
.ca-tools :where(button, select, input:not([type="radio"], [role="switch"]), textarea) { border: 1px solid var(--ca-border); border-radius: 5px; min-block-size: 30px; }
|
||||
.ca-tools button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 4px 10px; background: var(--ca-control); cursor: pointer; white-space: nowrap; }
|
||||
.ca-tools button:hover:not(:disabled) { background: var(--ca-hover); }
|
||||
.ca-tools button:disabled { opacity: .42; cursor: default; }
|
||||
.ca-tools :where(input:not([type="radio"], [role="switch"]), select, textarea) { background: var(--ca-field); min-inline-size: 0; padding: 4px 8px; }
|
||||
.ca-tools :where(button, input, select, textarea):focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 2px; }
|
||||
.ca-tools :where(input, textarea)::placeholder { color: var(--ca-muted); opacity: 1; }
|
||||
.ca-tools .ca-primary, .ca-tools button[aria-current="page"] { background: var(--ca-accent); border-color: var(--ca-accent); color: #fff; }
|
||||
.ca-tools .ca-primary:hover:not(:disabled) { background: #096ba6; }
|
||||
.ca-tools .ca-danger { background: var(--ca-error); border-color: var(--ca-error); color: #fff; }
|
||||
.ca-tools .ca-danger:hover:not(:disabled) { background: #b51c28; }
|
||||
.ca-tools .ca-icon-button { min-inline-size: 30px; padding-inline: 6px; }
|
||||
.ca-tools .ca-field-label { display: block; font-size: 11px; color: var(--ca-muted); }
|
||||
.ca-tools .ca-field { display: inline-flex; align-items: center; gap: 6px; min-inline-size: 0; }
|
||||
.ca-tools .ca-switch { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; min-block-size: 30px; font-size: 12px; }
|
||||
.ca-tools .ca-switch input { appearance: none; position: relative; inline-size: 36px; min-block-size: 24px; border: 1px solid var(--ca-border); border-radius: 20px; background: var(--ca-control); margin: 0; cursor: pointer; flex-shrink: 0; }
|
||||
.ca-tools .ca-switch input::before { content: ""; position: absolute; inline-size: 16px; block-size: 16px; background: var(--ca-muted); inset-block-start: 3px; inset-inline-start: 3px; border-radius: 50%; }
|
||||
.ca-tools .ca-switch input:checked { background: var(--ca-accent); border-color: var(--ca-accent); }
|
||||
.ca-tools .ca-switch input:checked::before { background: #fff; inset-inline-start: 15px; }
|
||||
.ca-tools .ca-segments { border: 0; padding: 0; margin: 0; display: inline-flex; gap: 2px; min-inline-size: 0; }
|
||||
.ca-tools .ca-segments label { position: relative; display: inline-flex; min-inline-size: 24px; min-block-size: 30px; cursor: pointer; }
|
||||
.ca-tools .ca-segments input { position: absolute; inset: 0; margin: 0; opacity: 0; inline-size: 100%; block-size: 100%; cursor: pointer; }
|
||||
.ca-tools .ca-segments label > span { padding: 4px 12px; background: var(--ca-field); border: 1px solid var(--ca-border); border-radius: 4px; color: var(--ca-text); font-size: 13px; }
|
||||
.ca-tools .ca-segments input:checked + span { background: var(--ca-accent); border-color: var(--ca-accent); color: #fff; }
|
||||
.ca-tools .ca-segments input:focus-visible + span { outline: 2px solid var(--ca-focus); outline-offset: 2px; }
|
||||
.ca-tools .ca-visually-hidden { position: absolute; inline-size: 1px; block-size: 1px; clip-path: inset(50%); overflow: hidden; white-space: nowrap; }
|
||||
.ca-tools .ca-range { display: flex; align-items: center; gap: 10px; flex-shrink: 0; color: var(--ca-muted); font-size: 11px; }
|
||||
.ca-tools .ca-range input[type="range"] { flex: 1; inline-size: 60px; min-inline-size: 0; margin: 0; padding: 0; border: 0; min-block-size: 30px; cursor: pointer; }
|
||||
.ca-tools .ca-range output { min-inline-size: 44px; font-variant-numeric: tabular-nums; }
|
||||
@media (pointer: coarse) { .ca-tools :where(button, select), .ca-tools .ca-segments label > span, .ca-tools .ca-switch { min-block-size: 40px; } }
|
||||
@media (forced-colors: active) {
|
||||
.ca-tools { --ca-accent: Highlight; --ca-focus: Highlight; --ca-border: ButtonText; --ca-line: CanvasText; }
|
||||
.ca-tools .ca-switch input:checked::before { background: HighlightText; }
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
export function createScope() {
|
||||
const controller = new AbortController();
|
||||
const cleanups = [];
|
||||
return {
|
||||
signal: controller.signal,
|
||||
listen(target, event, callback) {
|
||||
target.addEventListener(event, callback, { signal: controller.signal });
|
||||
},
|
||||
cleanup(callback) { cleanups.push(callback); },
|
||||
debounce(callback, delay) {
|
||||
let timer;
|
||||
cleanups.push(() => clearTimeout(timer));
|
||||
return (...args) => {
|
||||
clearTimeout(timer);
|
||||
if (!controller.signal.aborted) timer = setTimeout(() => callback(...args), delay);
|
||||
};
|
||||
},
|
||||
dispose() {
|
||||
if (controller.signal.aborted) return;
|
||||
controller.abort();
|
||||
cleanups.splice(0).forEach(callback => callback());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function themeFromColor(color) {
|
||||
let values;
|
||||
const hex = color.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i)?.[1];
|
||||
if (hex) {
|
||||
const expanded = hex.length === 3 ? [...hex].map(value => value + value).join('') : hex;
|
||||
values = expanded.match(/../g).map(value => parseInt(value, 16));
|
||||
} else if (/^rgb\(/.test(color)) {
|
||||
values = color.match(/[\d.]+/g)?.slice(0, 3).map(Number);
|
||||
}
|
||||
if (!values || values.length !== 3) return null;
|
||||
const linear = values.map(value => value / 255).map(value => value <= .04045 ? value / 12.92 : ((value + .055) / 1.055) ** 2.4);
|
||||
return linear[0] * .2126 + linear[1] * .7152 + linear[2] * .0722 > .35 ? 'light' : 'dark';
|
||||
}
|
||||
|
||||
export function mountTheme(element, settings, scope) {
|
||||
const stylesheet = new URL('./controls.css', import.meta.url).href;
|
||||
if (![...document.querySelectorAll('link[rel="stylesheet"]')].some(link => link.href === stylesheet)) {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = stylesheet;
|
||||
document.head.append(link);
|
||||
}
|
||||
const preference = matchMedia('(prefers-color-scheme: dark)');
|
||||
const update = () => {
|
||||
const background = getComputedStyle(document.body).getPropertyValue('--bg-color').trim();
|
||||
element.dataset.caTheme = themeFromColor(background) || (preference.matches ? 'dark' : 'light');
|
||||
};
|
||||
const observer = new MutationObserver(update);
|
||||
for (const target of [document.documentElement, document.body]) observer.observe(target, { attributes: true, attributeFilter: ['style', 'class', 'data-theme'] });
|
||||
scope.cleanup(() => observer.disconnect());
|
||||
scope.listen(preference, 'change', update);
|
||||
if (settings?.addEventListener) scope.listen(settings, 'Comfy.ColorPalette.change', update);
|
||||
update();
|
||||
}
|
||||
|
||||
export function createButton(text, className = '') {
|
||||
const element = document.createElement('button');
|
||||
element.type = 'button';
|
||||
element.className = className;
|
||||
element.textContent = text;
|
||||
return element;
|
||||
}
|
||||
|
||||
export function createSlider(label, { id, min, max, step, value, unit }, scope, onInput) {
|
||||
const field = document.createElement('div');
|
||||
field.className = 'ca-range';
|
||||
const caption = document.createElement('label');
|
||||
caption.htmlFor = id;
|
||||
caption.textContent = label;
|
||||
const input = document.createElement('input');
|
||||
Object.assign(input, { type: 'range', id, min, max, step, value });
|
||||
const output = document.createElement('output');
|
||||
output.setAttribute('for', id);
|
||||
output.value = `${value} ${unit}`;
|
||||
scope.listen(input, 'input', () => {
|
||||
output.value = `${input.value} ${unit}`;
|
||||
onInput(Number(input.value));
|
||||
});
|
||||
field.append(caption, input, output);
|
||||
return field;
|
||||
}
|
||||
|
||||
export function createField(label, input, id) {
|
||||
const field = document.createElement('label');
|
||||
field.className = 'ca-field';
|
||||
field.htmlFor = id;
|
||||
const caption = document.createElement('span');
|
||||
caption.className = 'ca-field-label';
|
||||
caption.textContent = label;
|
||||
input.id = id;
|
||||
field.append(caption, input);
|
||||
return field;
|
||||
}
|
||||
|
||||
export function createSwitch(label, className, id) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'checkbox';
|
||||
input.role = 'switch';
|
||||
input.checked = true;
|
||||
input.className = className;
|
||||
const field = createField(label, input, id);
|
||||
field.className = 'ca-switch';
|
||||
field.replaceChildren(input, document.createTextNode(label));
|
||||
return field;
|
||||
}
|
||||
|
||||
export function createSelect(options, className) {
|
||||
const select = document.createElement('select');
|
||||
select.className = className;
|
||||
for (const [value, text] of options) select.add(new Option(text, value));
|
||||
return select;
|
||||
}
|
||||
|
||||
export function createSegments(label, options, name, scope, onChange) {
|
||||
const fieldset = document.createElement('fieldset');
|
||||
fieldset.className = 'ca-segments';
|
||||
const legend = document.createElement('legend');
|
||||
legend.className = 'ca-visually-hidden';
|
||||
legend.textContent = label;
|
||||
fieldset.append(legend);
|
||||
options.forEach(([value, text], index) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'radio';
|
||||
input.name = name;
|
||||
input.value = value;
|
||||
input.checked = index === 0;
|
||||
const field = createField(text, input, `${name}-${value}`);
|
||||
field.replaceChildren(input, field.firstChild);
|
||||
scope.listen(input, 'change', () => onChange(value));
|
||||
fieldset.append(field);
|
||||
});
|
||||
return fieldset;
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"name": "@comfyassets/toolkit",
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://codeload.github.com/ComfyAssets/ComfyAssets-Toolkit/tar.gz/66644f5620e10efeaba090dca2d5be54baa2ee14",
|
||||
"integrity": "sha512-jvDj3iYJHjRCH7nZimoF4KNLwEVdzKVQIOZrMfoLg9aLnHm98R8YvBUOvnlJ+JNrM8/LnD2m30u3ggSTnR0/sw==",
|
||||
"files": {
|
||||
"core.js": {
|
||||
"source": "ui/core.js",
|
||||
"sha256": "cd10b700441a607cb1d56051cf0a430c0b36f901c0c351d8f4dae067090d8838"
|
||||
},
|
||||
"controls.css": {
|
||||
"source": "ui/controls.css",
|
||||
"sha256": "3ae1ca39077ffc3621b86ae7ef77202841fa27061b0e4623f2bb0413b27ecd7b"
|
||||
},
|
||||
"LICENSE": {
|
||||
"source": "LICENSE",
|
||||
"sha256": "0c865294d0c580dbe365d9ff5ccd655bd2d3716ca3282cc485b5cf789ec8ea6f"
|
||||
},
|
||||
"SOURCE.md": {
|
||||
"source": "SOURCE.md",
|
||||
"sha256": "46c226483f1d0c01524b1363435ccb3b1e67c2b0f6ed9161f7f087e49e8ed5a9"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,8 +101,7 @@
|
||||
try {
|
||||
const init = { method, headers: { Accept: "text/event-stream" }, signal: controller.signal };
|
||||
if (body !== undefined) init.body = body;
|
||||
const requestUrl = root.ApiClient ? root.ApiClient.resolveUrl(url) : url;
|
||||
const response = await doFetch(requestUrl, init);
|
||||
const response = await doFetch(url, init);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
if (source.readyState === CLOSED) return;
|
||||
source.readyState = OPEN;
|
||||
|
||||
+11
-27
@@ -583,12 +583,8 @@ class TagsPageManager {
|
||||
// ── Prompts Display ───────────────────────────────────────
|
||||
|
||||
async loadTagPrompts() {
|
||||
if (this.selectedTags.length === 0) {
|
||||
this.clearPrompts();
|
||||
return;
|
||||
}
|
||||
const request = {};
|
||||
this.promptRequest = request;
|
||||
if (this.isLoadingPrompts) return;
|
||||
if (this.selectedTags.length === 0) return;
|
||||
|
||||
this.isLoadingPrompts = true;
|
||||
this.promptOffset = 0;
|
||||
@@ -626,7 +622,6 @@ class TagsPageManager {
|
||||
}
|
||||
|
||||
const data = await this.fetchJson(url);
|
||||
if (this.promptRequest !== request) return;
|
||||
|
||||
if (data.success) {
|
||||
this.prompts = data.prompts;
|
||||
@@ -639,7 +634,6 @@ class TagsPageManager {
|
||||
requestAnimationFrame(() => this.refreshPromptObserver());
|
||||
}
|
||||
} catch (err) {
|
||||
if (this.promptRequest !== request) return;
|
||||
console.error('Failed to load tag prompts:', err);
|
||||
if (grid) {
|
||||
grid.replaceChildren();
|
||||
@@ -649,14 +643,12 @@ class TagsPageManager {
|
||||
grid.appendChild(errDiv);
|
||||
}
|
||||
} finally {
|
||||
if (this.promptRequest === request) this.isLoadingPrompts = false;
|
||||
this.isLoadingPrompts = false;
|
||||
}
|
||||
}
|
||||
|
||||
async loadMoreTagPrompts() {
|
||||
if (this.isLoadingPrompts || !this.hasMorePrompts) return;
|
||||
const request = {};
|
||||
this.promptRequest = request;
|
||||
this.isLoadingPrompts = true;
|
||||
|
||||
const spinner = document.querySelector('#tagPromptsLoader .prompts-loader-spinner');
|
||||
@@ -683,7 +675,6 @@ class TagsPageManager {
|
||||
}
|
||||
|
||||
const data = await this.fetchJson(url);
|
||||
if (this.promptRequest !== request) return;
|
||||
|
||||
if (data.success) {
|
||||
const startIndex = this.prompts.length;
|
||||
@@ -694,16 +685,13 @@ class TagsPageManager {
|
||||
this.updatePromptCountBadge();
|
||||
}
|
||||
} catch (err) {
|
||||
if (this.promptRequest !== request) return;
|
||||
console.error('Failed to load more prompts:', err);
|
||||
this.hasMorePrompts = false;
|
||||
} finally {
|
||||
if (this.promptRequest === request) {
|
||||
this.isLoadingPrompts = false;
|
||||
if (spinner) spinner.classList.add('hidden');
|
||||
this.updateLoadMoreButton();
|
||||
requestAnimationFrame(() => this.refreshPromptObserver());
|
||||
}
|
||||
this.isLoadingPrompts = false;
|
||||
if (spinner) spinner.classList.add('hidden');
|
||||
this.updateLoadMoreButton();
|
||||
requestAnimationFrame(() => this.refreshPromptObserver());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -999,9 +987,6 @@ class TagsPageManager {
|
||||
}
|
||||
|
||||
clearPrompts() {
|
||||
this.promptRequest = null;
|
||||
this.isLoadingPrompts = false;
|
||||
this.promptOffset = 0;
|
||||
this.prompts = [];
|
||||
this.promptTotal = 0;
|
||||
this.hasMorePrompts = false;
|
||||
@@ -1106,7 +1091,6 @@ class TagsPageManager {
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.promptRequest = null;
|
||||
this.destroyInfiniteScroll();
|
||||
this.hideContextMenu();
|
||||
clearTimeout(this.searchTimer);
|
||||
@@ -1123,7 +1107,7 @@ class TagsPageManager {
|
||||
// ── Helpers ───────────────────────────────────────────────
|
||||
|
||||
async fetchJson(url, options = {}) {
|
||||
const resp = await ApiClient.fetchUrl(url, options);
|
||||
const resp = await fetch(url, options);
|
||||
if (!resp.ok) {
|
||||
let errorMsg;
|
||||
try { errorMsg = (await resp.json()).error; } catch { errorMsg = resp.statusText; }
|
||||
@@ -1133,9 +1117,9 @@ class TagsPageManager {
|
||||
}
|
||||
|
||||
getThumbnailUrl(image) {
|
||||
if (image.thumbnail_url) return ApiClient.resolveUrl(image.thumbnail_url);
|
||||
if (image.url) return ApiClient.resolveUrl(image.url);
|
||||
if (image.id) return ApiClient.resolveUrl(`/prompt_manager/images/${image.id}/file`);
|
||||
if (image.thumbnail_url) return image.thumbnail_url;
|
||||
if (image.url) return image.url;
|
||||
if (image.id) return `/prompt_manager/images/${image.id}/file`;
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,7 @@
|
||||
|
||||
async function detectPalette() {
|
||||
try {
|
||||
const prefix = window.location.pathname.match(/^(.*)\/prompt_manager(?:\/|$)/)?.[1] || '';
|
||||
const res = await fetch(`${prefix}/api/settings/Comfy.ColorPalette`);
|
||||
const res = await fetch('/api/settings/Comfy.ColorPalette');
|
||||
if (!res.ok) return 'dark';
|
||||
const data = await res.json();
|
||||
// data is the palette name string, or an object with value
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright 2015-present Chen Fengyuan
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"name": "viewerjs",
|
||||
"version": "1.15.2",
|
||||
"source": "https://github.com/fengyuanchen/viewerjs/tree/v1.15.2",
|
||||
"resolved": "https://registry.npmjs.org/viewerjs/-/viewerjs-1.15.2.tgz",
|
||||
"integrity": "sha512-CYEqSFfPUSnmn1PjrhYxXOScaUC7n8+NpSqI+MrOSM8wcKPe35MlwQYwksuD5rjFkYv/ePmY5NsUa51uRNN2jA==",
|
||||
"files": {
|
||||
"viewer.min.js": {
|
||||
"source": "dist/viewer.min.js",
|
||||
"sha256": "490580be4988dff2e05cd9c81c4161b82ce0c3715d268fadf8f7193853f40031"
|
||||
},
|
||||
"viewer.min.css": {
|
||||
"source": "dist/viewer.min.css",
|
||||
"sha256": "24a43eb0b188c824bb5b32e4d95c08b6162ec01eaa709ca8f0823d9512d0913d"
|
||||
},
|
||||
"LICENSE": {
|
||||
"source": "LICENSE",
|
||||
"sha256": "eb917650781dcf06a60b97cba9b05bf583931ec2d311d8a5548a8dca90a57e0f"
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+3
-3
File diff suppressed because one or more lines are too long
Vendored
+3
-3
File diff suppressed because one or more lines are too long
+4
-4
@@ -4,10 +4,10 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Image Gallery - Generation Data</title>
|
||||
<script src="js/theme-bridge.js"></script>
|
||||
<script src="js/html-escape.js"></script>
|
||||
<script src="js/png-metadata.js"></script>
|
||||
<script src="js/comfy-metadata.js"></script>
|
||||
<script src="/prompt_manager/js/theme-bridge.js"></script>
|
||||
<script src="/prompt_manager/js/html-escape.js"></script>
|
||||
<script src="/prompt_manager/js/png-metadata.js"></script>
|
||||
<script src="/prompt_manager/js/comfy-metadata.js"></script>
|
||||
<link rel="stylesheet" href="lib/tailwind/comfyui-theme.css">
|
||||
<link rel="stylesheet" href="lib/tailwind/styles.css">
|
||||
<style>
|
||||
|
||||
Reference in New Issue
Block a user