Compare commits

...
Author SHA1 Message Date
Vito Sansevero 387bca2c51 test: exercise real page workflows and enforce frontend coverage 2026-10-16 06:30:56 -07:00
Vito e9fe586442 fix: repair prompt edit hashes, auto-tag review, and stale UI responses (#180)
* fix: keep edited prompt hashes consistent

Fixes #176

* fix: align gallery auto-tag review with scoped image APIs

Fixes #177

* fix: keep Tags prompt results on the current filter

Fixes #178

* fix: ignore stale metadata in admin image viewers

Fixes #179

* test: compare canonical gallery paths on macOS
2026-10-16 06:14:06 -07:00
Vito 6d9f3fc8fb fix: preserve gallery image identity and ignore stale metadata (#175)
* fix: preserve gallery root identity across media and metadata

Fixes #173

* fix: ignore stale gallery metadata responses

Fixes #174

* test: use platform-valid gallery filenames on Windows
2026-10-16 05:35:03 -07:00
dependabot[bot] 911b801883 deps(pip)(deps-dev): update aiohttp requirement (#172)
---
updated-dependencies:
- dependency-name: aiohttp
  dependency-version: 3.14.5
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-16 05:23:48 -07:00
dependabot[bot] a576cff69f deps(pip)(deps-dev): update black requirement (#171)
Updates the requirements on [black](https://github.com/psf/black) to permit the latest version.
- [Release notes](https://github.com/psf/black/releases)
- [Changelog](https://github.com/psf/black/blob/main/CHANGES.md)
- [Commits](https://github.com/psf/black/compare/26.10.0...26.10.1)

---
updated-dependencies:
- dependency-name: black
  dependency-version: 26.10.1
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-16 05:23:28 -07:00
Vito cf0cef3f25 feat: vendor Viewer.js reproducibly and automate release updates (#170)
Refs #169. Pin and verify pristine distribution assets, upgrade to 1.15.2, and add tested maintainer tooling plus desktop and touch browser checks.
2026-10-16 04:56:25 -07:00
Vito 7b6430e20a fix: stop misleading thumbnail prompts on gallery startup (#168)
* fix: count pending thumbnails accurately across gallery roots

* fix: clear thumbnails across all configured gallery roots

* fix: log skipped thumbnail directory cleanup
2026-10-16 04:56:07 -07:00
Vito 611c19d2f4 fix: keep infinite-scroll gallery rendering and viewer work bounded (#166) 2026-10-16 04:17:20 -07:00
ghosttigerllc-bitandkai 2763e9f9fa Read A1111/Forge generation parameters: fix the SELECT and parse multi-line prompts (#165)
* get_image_prompt_info: include gi.parameters in SELECT and return value

The function selects workflow_data, prompt_metadata and generation_time from
generated_images but omits the parameters column, so the A1111/Forge
generation-parameters string (steps, sampler, CFG, seed, LoRA list) never
reaches the API even when present in the database. Add it to the SELECT and
include it in the returned dict.

* parse A1111/Forge parameters: multi-line prompts, structured settings

_parse_comfyui_prompt took lines[0] of the parameters chunk as the whole
positive prompt, truncating every multi-line prompt to its first line, and
matched 'negative prompt:' only as a full line start. Split on the trailing
'Steps: ...' settings line instead, keep the full multi-line positive prompt,
and extract Steps / Sampler / Schedule type / CFG scale / Seed as structured
fields alongside the raw string.

Together with the get_image_prompt_info SELECT fix (previous commit), the
full A1111/Forge generation recipe now flows from the database to the API.

* style: apply black to the A1111 parameters parser

CI's black --check flagged py/api/__init__.py (slice spacing and two long
lines in the settings extraction). Formatting only; the AST is unchanged.

---------

Co-authored-by: kai <kai@hermes.local>
2026-10-16 03:50:26 -07:00
50 changed files with 3750 additions and 304 deletions
+25
View File
@@ -0,0 +1,25 @@
{
"required_status_checks": {
"strict": true,
"contexts": [
"py3.10 on ubuntu-latest",
"py3.11 on ubuntu-latest",
"py3.12 on ubuntu-latest",
"py3.10 on windows-latest",
"py3.11 on windows-latest",
"py3.12 on windows-latest",
"py3.10 on macos-latest",
"py3.11 on macos-latest",
"py3.12 on macos-latest",
"coverage gate (85%)",
"black and flake8",
"frontend modules",
"viewerjs"
]
},
"enforce_admins": true,
"required_pull_request_reviews": null,
"restrictions": null,
"allow_force_pushes": false,
"allow_deletions": false
}
+12
View File
@@ -46,3 +46,15 @@ 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)"
+47
View File
@@ -0,0 +1,47 @@
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
+83
View File
@@ -0,0 +1,83 @@
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"
+8
View File
@@ -200,3 +200,11 @@ 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/
+3
View File
@@ -139,6 +139,9 @@ 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.
### 🔬 ComfyUI Workflow Analysis
- **PNG Metadata Parsing**: Advanced PNG chunk analysis to extract embedded ComfyUI data
+83
View File
@@ -0,0 +1,83 @@
# 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 |
| 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.
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`.
## 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` 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.
Live checks remain useful for ComfyUI host compatibility, real inference/GPU or
model dependency changes, and visual design changes. The automated fixtures do
not certify those environments or every existing product feature.
## Required merge checks
`.github/branch-protection.json` records the required checks: all nine Python/OS
combinations, Python coverage, formatting, frontend modules, and `viewerjs`.
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.
+17 -6
View File
@@ -13,12 +13,14 @@ 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
@@ -1854,16 +1856,20 @@ class PromptDatabase:
}
def update_prompt_text(self, prompt_id: int, new_text: str) -> bool:
"""Update only the text of a prompt.
"""Update prompt text and its deduplication hash atomically.
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 = ?, updated_at = ? WHERE id = ?",
"UPDATE prompts SET text = ?, hash = ?, updated_at = ? WHERE id = ?",
(
new_text,
generate_prompt_hash(new_text),
datetime.datetime.now(datetime.timezone.utc).isoformat(),
prompt_id,
),
@@ -1967,23 +1973,27 @@ class PromptDatabase:
conn.commit()
return count
def get_image_prompt_info(self, image_path: str) -> Optional[Dict[str, Any]]:
def get_image_prompt_info(
self, image_path: str, allow_filename_fallback: bool = True
) -> 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 — or None.
workflow_data, prompt_metadata, generation_time, parameters — 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.workflow_data, gi.prompt_metadata, gi.generation_time,
gi.parameters
FROM generated_images gi
JOIN prompts p ON gi.prompt_id = p.id
WHERE gi.image_path = ? OR gi.file_path = ?
OR gi.image_path LIKE ? ESCAPE '\\'""",
OR (? AND gi.image_path LIKE ? ESCAPE '\\')""",
(
image_path,
normalize_image_path(image_path),
allow_filename_fallback,
f"%{escape_like(os.path.basename(image_path))}",
),
)
@@ -2009,6 +2019,7 @@ 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]:
+33 -8
View File
@@ -703,18 +703,43 @@ class PromptManagerAPI(
"negative_prompt": None,
}
# Check for A1111 style parameters first (like parse-metadata.py)
# 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.
if "parameters" in metadata:
import re as _re
params = metadata["parameters"]
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
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
# 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:
+16
View File
@@ -484,6 +484,22 @@ 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)
+176 -117
View File
@@ -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 newer than ``src`` (blocking stat)."""
"""True when ``dst`` exists and is at least as new as ``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,6 +316,9 @@ 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],
@@ -324,7 +327,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/{rel_path.as_posix()}",
"url": f"/prompt_manager/images/serve/{encoded_path}?root={root_index}",
"thumbnail_url": thumbnail_url,
"size": stat.st_size,
"modified_time": stat.st_mtime,
@@ -382,6 +385,10 @@ 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)
@@ -699,10 +706,10 @@ class ImageRoutesMixin:
try:
idx = int(root_param)
except ValueError:
return output_dirs
return []
if 0 <= idx < len(output_dirs):
return [output_dirs[idx]]
return output_dirs
return []
async def serve_output_image(self, request):
"""Serve a media file from the ComfyUI output folder(s)."""
@@ -815,19 +822,15 @@ class ImageRoutesMixin:
return error
thumbnail_size = self._thumbnail_size(data.get("quality", "medium"))
output_dir = self._find_comfyui_output_dir()
if not output_dir:
output_dirs = self._get_all_output_dirs()
if not output_dirs:
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_thumbnails_sync,
output_path,
output_path / "thumbnails",
thumbnail_size,
self._generate_gallery_thumbnails_sync, output_dirs, thumbnail_size
)
self.invalidate_gallery_cache()
return web.json_response(result)
@@ -842,6 +845,22 @@ 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"]
@@ -855,10 +874,50 @@ 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).
@@ -923,7 +982,7 @@ class ImageRoutesMixin:
try:
if not src.is_file():
return {**result, "action": "error", "error": "File no longer exists"}
if dst.exists() and dst.stat().st_mtime > src.stat().st_mtime:
if _thumbnail_is_fresh(src, dst):
return {**result, "action": "skipped"}
dst.parent.mkdir(parents=True, exist_ok=True)
if is_video:
@@ -957,21 +1016,20 @@ class ImageRoutesMixin:
)
await response.prepare(request)
try:
output_dir = self._find_comfyui_output_dir()
if not output_dir:
output_dirs = self._get_all_output_dirs()
if not output_dirs:
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_thumbnails, output_path, output_path / "thumbnails"
self._plan_gallery_thumbnails, output_dirs
)
await self._stream_thumbnails(
response,
@@ -1196,119 +1254,109 @@ class ImageRoutesMixin:
)
return False
async def clear_thumbnails(self, request):
"""Safely clear only our generated thumbnails, never touch original images."""
try:
async def clear_thumbnails(self, request: web.Request) -> web.Response:
"""Clear generated thumbnails across gallery roots, preserving originals.
# Find ComfyUI output directory
output_dir = self._find_comfyui_output_dir()
if not output_dir:
Invalid thumbnail directories return 400; unavailable gallery roots
return 404, and unexpected filesystem failures return 500.
"""
try:
output_dirs = self._get_all_output_dirs()
if not output_dirs:
return web.json_response(
{"success": False, "error": "ComfyUI output directory not found"},
status=404,
)
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 bytes_size < 1024.0:
return f"{bytes_size:.1f} {unit}"
bytes_size /= 1024.0
return f"{bytes_size:.1f} TB"
self.logger.info(
f"Thumbnail cleanup: cleared {cleared_count} files "
f"({format_size(cleared_size)})"
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)
for unit in ["B", "KB", "MB", "GB"]:
if size < 1024.0:
return f"{size:.1f} {unit}"
size /= 1024.0
return f"{size:.1f} TB"
size_formatted = format_size(cleared_size)
self.logger.info(
f"Thumbnail cleanup: cleared {cleared_count} files ({size_formatted})"
)
return web.json_response(
{
"success": True,
"cleared_files": cleared_count,
"cleared_size": cleared_size,
"cleared_size_formatted": format_size(cleared_size),
"cleared_size_formatted": size_formatted,
"message": f"Cleared {cleared_count} thumbnail files "
"({format_size(cleared_size)})",
f"({size_formatted})",
}
)
except Exception as e:
self.logger.error(f"Clear thumbnails error: {e}")
except ValueError as exc:
self.logger.error(f"Thumbnail path validation failed: {exc}")
return web.json_response(
{"success": False, "error": _safe_error(e)}, status=500
{"success": False, "error": _safe_error(exc)}, status=400
)
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):
@@ -1389,7 +1437,7 @@ class ImageRoutesMixin:
try:
# Get the image path from URL
raw_image_path = request.match_info.get("image_path", "")
image_path = urllib.parse.unquote(raw_image_path)
image_path = raw_image_path
if not image_path:
return web.json_response(
@@ -1397,15 +1445,26 @@ class ImageRoutesMixin:
)
# Convert relative path to absolute if needed
if not os.path.isabs(image_path):
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):
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, image_path
self.db.get_image_prompt_info, *args
)
if prompt_data:
prompt_data["image_path"] = _public_path(image_path)
+6
View File
@@ -3,6 +3,7 @@
import datetime
import json
import os
import sqlite3
from aiohttp import web
from urllib.parse import quote
@@ -839,6 +840,11 @@ 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
View File
@@ -1,14 +1,14 @@
# Runtime dependencies
watchdog>=6.0.0
Pillow>=12.3.0
aiohttp>=3.14.4
aiohttp>=3.14.5
# Tests and coverage
coverage>=7.16.2
numpy>=2.2.6 # WD14 engine tests
# Code quality
black>=26.10.0
black>=26.10.1
flake8>=7.4.1
# Security scanning
+1
View File
@@ -0,0 +1 @@
"""Browser workflow fixtures; never imported by production code."""
+154
View File
@@ -0,0 +1,154 @@
"""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()
+141
View File
@@ -0,0 +1,141 @@
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');
});
+50
View File
@@ -0,0 +1,50 @@
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');
});
}
+76
View File
@@ -0,0 +1,76 @@
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');
});
}
+209
View File
@@ -0,0 +1,209 @@
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);
});
+88
View File
@@ -0,0 +1,88 @@
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);
});
+30
View File
@@ -0,0 +1,30 @@
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',
]);
});
+90
View File
@@ -0,0 +1,90 @@
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');
});
+13
View File
@@ -108,3 +108,16 @@ 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);
});
+71
View File
@@ -0,0 +1,71 @@
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');
});
+69
View File
@@ -1049,3 +1049,72 @@ 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)
+260 -11
View File
@@ -224,7 +224,7 @@ class TestServeOutputImage(ImageAPITestCase):
self.assertEqual(hit.status, 200)
self.assertEqual(miss.status, 404)
self.assertEqual(bad_index.status, 200)
self.assertEqual(bad_index.status, 403)
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_thumbnails", calls)
self.assertIn("_plan_gallery_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._find_comfyui_output_dir = lambda: None
self.api._get_all_output_dirs = lambda: []
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._find_comfyui_output_dir = lambda: None
self.api._get_all_output_dirs = lambda: []
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+$",
r"^/prompt_manager/images/serve/thumbnails/a_thumb\.png\?v=\d+&root=0$",
)
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_searches_all_roots(self):
async def test_out_of_range_root_index_is_rejected(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, 200)
self.assertEqual(resp.status, 403)
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._find_comfyui_output_dir = _raise
self.api._get_all_output_dirs = _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._find_comfyui_output_dir = lambda: None
self.api._get_all_output_dirs = lambda: []
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._find_comfyui_output_dir = _raise
self.api._get_all_output_dirs = _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._find_comfyui_output_dir = lambda: str(out)
self.api._get_all_output_dirs = lambda: [out]
status, data = await self._json("POST", self.URL)
@@ -1589,6 +1589,79 @@ 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):
@@ -1819,3 +1892,179 @@ 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"])
+46
View File
@@ -894,3 +894,49 @@ 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"
)
+22
View File
@@ -0,0 +1,22 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
const sourceRoot = join(process.cwd(), 'web/js');
import { checkCoverage } 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/);
});
+107
View File
@@ -0,0 +1,107 @@
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);
});
+80
View File
@@ -0,0 +1,80 @@
# 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.
+22
View File
@@ -0,0 +1,22 @@
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
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 = [];
for (const entry of entries) {
const match = entry.url.match(/\/prompt_manager\/js\/([a-z0-9-]+\.js)(?:\?.*)?$/i);
if (match) result.push({ ...entry, url: pathToFileURL(join(repositoryRoot, 'web/js', match[1])).href });
}
mkdirSync(process.env.PM_JS_COVERAGE, { recursive: true });
writeFileSync(join(process.env.PM_JS_COVERAGE, `browser-${randomUUID()}.json`), JSON.stringify({ result }));
}
+104
View File
@@ -0,0 +1,104 @@
{
"admin.js": {
"lines": 24,
"branches": 53,
"functions": 20,
"statements": 24
},
"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
}
}
+76
View File
@@ -0,0 +1,76 @@
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';
const metrics = ['lines', 'branches', 'functions', 'statements'];
export function checkCoverage(summary, baseline, sourceRoot, files) {
const failures = [];
for (const file of files) {
const floor = baseline[file];
if (!floor) { failures.push(`Missing baseline for ${file}`); continue; }
const measured = summary[join(sourceRoot, file)];
if (!measured) { failures.push(`Missing coverage for ${file}`); continue; }
for (const metric of metrics) {
if (!Number.isFinite(floor[metric]) || floor[metric] < 0 || floor[metric] > 100) {
throw new Error(`Invalid baseline for ${file}: ${metric}`);
}
if (!Number.isFinite(measured[metric]?.pct) || measured[metric].pct < floor[metric]) {
failures.push(`${file} ${metric}: ${measured[metric]?.pct ?? 'missing'} < ${floor[metric]}`);
}
}
}
return failures;
}
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 = readdirSync(sourceRoot).filter(file => file.endsWith('.js')).sort();
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', '--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; });
}
+792
View File
@@ -0,0 +1,792 @@
{
"name": "promptmanager-vendor-tools",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "promptmanager-vendor-tools",
"version": "1.0.0",
"devDependencies": {
"@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/@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"
}
}
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"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",
"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:coverage": "node frontend-coverage.mjs",
"coverage:update": "node frontend-coverage.mjs --update-baseline"
},
"devDependencies": {
"@playwright/test": "1.64.1",
"c8": "12.0.0",
"viewerjs": "1.15.2"
}
}
+17
View File
@@ -0,0 +1,17 @@
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
View File
@@ -0,0 +1,27 @@
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
View File
@@ -0,0 +1,8 @@
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));
}
+270
View File
@@ -0,0 +1,270 @@
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');
});
+8
View File
@@ -0,0 +1,8 @@
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.
+34
View File
@@ -0,0 +1,34 @@
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;
}
}
+70
View File
@@ -0,0 +1,70 @@
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;
}
}
+16
View File
@@ -1656,6 +1656,7 @@
// Initialize ViewerJS for the gallery after DOM is updated
setTimeout(() => {
if (this.galleryViewer) {
this.invalidateMetadataRequest();
this.galleryViewer.destroy();
}
@@ -1699,6 +1700,7 @@
viewed: (event) => {
this.loadMetadataForImage(event.detail.image);
},
hide: () => this.invalidateMetadataRequest(),
hidden: () => {
const sidebar = document.getElementById('metadata-sidebar');
if (sidebar) sidebar.remove();
@@ -1711,7 +1713,13 @@
invalidateMetadataRequest() {
this.metadataRequest = null;
this.currentMetadata = null;
}
closeGallery() {
this.invalidateMetadataRequest();
this.hideModal("galleryModal");
}
@@ -2896,6 +2904,11 @@
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">
@@ -2911,9 +2924,11 @@
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">
@@ -3515,6 +3530,7 @@ 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();
+176 -146
View File
@@ -357,9 +357,10 @@
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.updateStats();
this.renderGallery();
this.renderGallery({ from });
this.updatePagination();
document.getElementById('loadingStatus').textContent = 'Loaded';
this.maybeLoadMore(); // a short page may not fill the viewport
@@ -445,7 +446,8 @@
this.renderGallery();
}
renderGallery() {
renderGallery({ from = 0 } = {}) {
if (from === 0) this.destroyViewer();
if (this.images.length === 0) {
this.showError('No images found in the output folder');
return;
@@ -455,19 +457,15 @@
document.getElementById('errorState').classList.add('hidden');
if (this.viewMode === 'grid') {
this.renderGridView();
this.renderGridView({ from });
} else {
this.renderListView();
this.renderListView({ from });
}
// Initialize ViewerJS after rendering
setTimeout(() => this.initializeViewer(), 100);
// Add simple fallback click handlers
this.addFallbackClickHandlers();
}
renderGridView() {
renderGridView({ from = 0 } = {}) {
const grid = document.getElementById('galleryGrid');
const list = document.getElementById('galleryList');
const showInfo = this.getSettings().showImageInfo;
@@ -475,7 +473,8 @@
list.classList.add('hidden');
grid.classList.remove('hidden');
grid.innerHTML = this.images.map((image, index) => {
const html = this.images.slice(from).map((image, offset) => {
const index = from + offset;
// Use thumbnail for display if available, fallback to original
const displayUrl = image.thumbnail_url || image.url;
const hasThumb = !!image.thumbnail_url;
@@ -483,7 +482,7 @@
const mediaType = image.media_type || 'image';
return `
<div class="image-item bg-pm-surface rounded-pm-md overflow-hidden border border-pm hover:border-pm cursor-pointer group">
<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="aspect-square bg-pm-primary overflow-hidden relative">
<img src="${escapeHtml(displayUrl)}"
alt="${escapeHtml(image.filename)}"
@@ -525,9 +524,11 @@
</div>
`;
}).join('');
if (from === 0) grid.innerHTML = html;
else grid.insertAdjacentHTML('beforeend', html);
}
renderListView() {
renderListView({ from = 0 } = {}) {
const grid = document.getElementById('galleryGrid');
const list = document.getElementById('galleryList');
const showInfo = this.getSettings().showImageInfo;
@@ -535,7 +536,8 @@
grid.classList.add('hidden');
list.classList.remove('hidden');
list.innerHTML = this.images.map((image, index) => {
const html = this.images.slice(from).map((image, offset) => {
const index = from + offset;
// Use thumbnail for display if available, fallback to original
const displayUrl = image.thumbnail_url || image.url;
const hasThumb = !!image.thumbnail_url;
@@ -543,7 +545,7 @@
const mediaType = image.media_type || 'image';
return `
<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 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="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)}"
@@ -576,26 +578,37 @@
</div>
`;
}).join('');
if (from === 0) list.innerHTML = html;
else list.insertAdjacentHTML('beforeend', html);
}
initializeViewer() {
// Destroy existing viewer if it exists
destroyViewer({ keepSlideshow = false } = {}) {
this.metadataRequest = null;
this.currentMetadata = null;
if (!keepSlideshow) this.stopViewerSlideshow();
if (this.viewer) {
this.viewer.clearEnforceFocus();
this.viewer.destroy();
}
this.viewer = null;
document.getElementById('metadata-sidebar')?.remove();
}
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;
}
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('');
this.viewer = new Viewer(container, {
inline: false,
initialViewIndex: index - start,
button: true,
backdrop: true,
navbar: true,
@@ -624,14 +637,22 @@
// Add metadata sidebar when ViewerJS opens
// Use the original image URL from data-original attribute
const originalImg = event.detail.originalImage;
setTimeout(() => this.addMetadataSidebar(originalImg), 100);
this.addMetadataSidebar(originalImg);
},
show: function() {
play: (event) => {
event.preventDefault();
if (this.slideshowPlaying) this.stopViewerSlideshow();
else {
this.slideshowPlaying = true;
if (this.viewer.options.fullscreen) this.viewer.requestFullscreen();
this.scheduleViewerSlide();
}
},
shown: function() {
},
hide: function() {
// Remove sidebar when viewer closes
hide: () => {
this.metadataRequest = null;
this.currentMetadata = null;
this.viewer?.clearEnforceFocus();
this.stopViewerSlideshow();
const sidebar = document.getElementById('metadata-sidebar');
if (sidebar) {
sidebar.remove();
@@ -639,12 +660,50 @@
}
});
// Don't add click handlers here - let ViewerJS handle them
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);
}
addMetadataSidebar(originalImage) {
const viewerContainer = document.querySelector('.viewer-container');
if (!viewerContainer || document.getElementById('metadata-sidebar')) return;
if (!viewerContainer) return;
const existing = document.getElementById('metadata-sidebar');
if (existing) {
this.loadImageMetadata(originalImage.dataset.original || originalImage.src,
existing.querySelector('#metadata-content'));
return;
}
const sidebar = document.createElement('div');
sidebar.id = 'metadata-sidebar';
@@ -690,79 +749,35 @@
// 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);
}
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();
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) {
// 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);
}
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);
}
}
});
});
}
}
async showSimpleImageModal(imageUrl, caption) {
@@ -1178,15 +1193,18 @@
}
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 {
// 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 ApiClient.fetchUrl(`/prompt_manager/images/prompt/${encodeURIComponent(relativePath)}`);
const metadataUrl = ImageHelpers.metadataLookupUrl(imageUrl);
if (metadataUrl) {
const response = await ApiClient.fetchUrl(metadataUrl);
if (response.ok) {
const data = await response.json();
if (data.success && data.prompt) {
@@ -1245,8 +1263,9 @@
}
}
this.displayMetadata(metadata, container);
if (isCurrent()) 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">
@@ -1747,6 +1766,7 @@ 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,
@@ -2503,37 +2523,16 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
}
try {
// Check a sample of images to see if any are missing thumbnails
const response = await ApiClient.fetchUrl('/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');
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);
}
} catch (error) {
console.debug('Could not check for missing thumbnails at startup:', error);
@@ -2541,7 +2540,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
}, 1000); // Wait 1 second for page to settle
}
showThumbnailGenerationPrompt(imageCount, missingCount = imageCount) {
showThumbnailGenerationPrompt(imageCount, missingCount = imageCount, signature = null) {
const modal = document.createElement('div');
modal.className = 'fixed inset-0 bg-black/50 flex items-center justify-center z-50';
modal.innerHTML = `
@@ -2552,10 +2551,10 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
Generate Thumbnails?
</h3>
<p class="text-pm-secondary mb-4">
Found ${missingCount} images without thumbnails (${imageCount} total images). Would you like to generate thumbnails for faster loading?
${missingCount} images need new or updated thumbnails (${imageCount} total images). Generate them for faster loading?
</p>
<p class="text-sm text-pm-muted mb-3">
💡 Thumbnails speed up gallery loading significantly. You can change this setting in Gallery Settings.
Only missing or outdated thumbnails will be generated. Existing current thumbnails will be kept. You can change startup checks 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">
@@ -2571,11 +2570,20 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
document.body.appendChild(modal);
// Handle button clicks
document.getElementById('thumbnailPromptNo').addEventListener('click', () => {
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);
}
}
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);
@@ -2584,7 +2592,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
// Close on outside click
modal.addEventListener('click', (e) => {
if (e.target === modal) {
document.body.removeChild(modal);
dismiss();
}
});
}
@@ -3534,6 +3542,10 @@ 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');
@@ -3559,7 +3571,9 @@ 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 {
@@ -3567,6 +3581,7 @@ 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>';
@@ -3604,28 +3619,41 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
return;
}
const image = this.autoTagState.reviewImages[this.autoTagState.reviewIndex];
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;
try {
const response = await ApiClient.fetchUrl('/prompt_manager/autotag/apply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
image_path: image.path,
prompt_id: promptId,
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++;
@@ -3633,6 +3661,8 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
}
cancelReview() {
this.reviewRequest = null;
this.autoTagState.reviewPromptId = null;
this.autoTagState.reviewImages = [];
this.autoTagState.reviewIndex = 0;
this.autoTagState.currentTags = [];
+20 -1
View File
@@ -79,6 +79,9 @@
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")
? {
@@ -103,7 +106,23 @@
return original;
}
const api = { formatImageCaption, workflowDocumentHtml, autotagSingleBody, fallbackImageSource };
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 };
if (typeof module !== "undefined" && module.exports) module.exports = api;
else root.ImageHelpers = api;
})(typeof window !== "undefined" ? window : globalThis);
+23 -7
View File
@@ -583,8 +583,12 @@ class TagsPageManager {
// ── Prompts Display ───────────────────────────────────────
async loadTagPrompts() {
if (this.isLoadingPrompts) return;
if (this.selectedTags.length === 0) return;
if (this.selectedTags.length === 0) {
this.clearPrompts();
return;
}
const request = {};
this.promptRequest = request;
this.isLoadingPrompts = true;
this.promptOffset = 0;
@@ -622,6 +626,7 @@ class TagsPageManager {
}
const data = await this.fetchJson(url);
if (this.promptRequest !== request) return;
if (data.success) {
this.prompts = data.prompts;
@@ -634,6 +639,7 @@ class TagsPageManager {
requestAnimationFrame(() => this.refreshPromptObserver());
}
} catch (err) {
if (this.promptRequest !== request) return;
console.error('Failed to load tag prompts:', err);
if (grid) {
grid.replaceChildren();
@@ -643,12 +649,14 @@ class TagsPageManager {
grid.appendChild(errDiv);
}
} finally {
this.isLoadingPrompts = false;
if (this.promptRequest === request) 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');
@@ -675,6 +683,7 @@ class TagsPageManager {
}
const data = await this.fetchJson(url);
if (this.promptRequest !== request) return;
if (data.success) {
const startIndex = this.prompts.length;
@@ -685,13 +694,16 @@ class TagsPageManager {
this.updatePromptCountBadge();
}
} catch (err) {
if (this.promptRequest !== request) return;
console.error('Failed to load more prompts:', err);
this.hasMorePrompts = false;
} finally {
this.isLoadingPrompts = false;
if (spinner) spinner.classList.add('hidden');
this.updateLoadMoreButton();
requestAnimationFrame(() => this.refreshPromptObserver());
if (this.promptRequest === request) {
this.isLoadingPrompts = false;
if (spinner) spinner.classList.add('hidden');
this.updateLoadMoreButton();
requestAnimationFrame(() => this.refreshPromptObserver());
}
}
}
@@ -987,6 +999,9 @@ class TagsPageManager {
}
clearPrompts() {
this.promptRequest = null;
this.isLoadingPrompts = false;
this.promptOffset = 0;
this.prompts = [];
this.promptTotal = 0;
this.hasMorePrompts = false;
@@ -1091,6 +1106,7 @@ class TagsPageManager {
}
destroy() {
this.promptRequest = null;
this.destroyInfiniteScroll();
this.hideContextMenu();
clearTimeout(this.searchTimer);
+21
View File
@@ -0,0 +1,21 @@
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.
+21
View File
@@ -0,0 +1,21 @@
{
"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"
}
}
}
+3 -3
View File
File diff suppressed because one or more lines are too long
+3 -3
View File
File diff suppressed because one or more lines are too long