Compare commits

..
290 changed files with 509 additions and 51563 deletions
+1 -2
View File
@@ -18,8 +18,7 @@
"black and flake8",
"frontend modules",
"viewerjs",
"real ComfyUI compatibility",
"bandit (high severity)"
"real ComfyUI compatibility"
]
},
"enforce_admins": true,
+36 -13
View File
@@ -9,15 +9,42 @@ on:
pull_request:
branches: [main]
# Superseded PR pushes cancel their older run. Every main push gets its own
# group (run_id), so no main commit's run is ever cancelled or dropped as pending.
concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Set up Python 3.10
uses: actions/setup-python@v7
with:
python-version: "3.10"
- name: Cache pip dependencies
uses: actions/cache@v6
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-quality-${{ hashFiles('requirements-dev.txt') }}
restore-keys: |
${{ runner.os }}-pip-quality-
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
- name: Check formatting with Black
run: black --check --diff .
- name: Lint with flake8
run: |
# Fail on syntax errors and undefined names
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
# Warnings as non-blocking
flake8 . --count --exit-zero --max-line-length=88 --statistics --exclude=reference/,.git,__pycache__,web/
security:
name: bandit (high severity)
runs-on: ubuntu-latest
steps:
@@ -31,9 +58,5 @@ jobs:
- name: Install bandit
run: pip install bandit[toml]
# Medium findings are reported without failing until they are triaged.
# High severity is a hard gate.
- name: Security report (medium and above, informational)
run: bandit -r . -ll -q --exclude ./tests,./reference,./venv,./tools/vendor/node_modules --exit-zero
- name: Security scan (high severity gate)
run: bandit -r . -lll -q --exclude ./tests,./reference,./venv,./tools/vendor/node_modules
- name: Security scan
run: bandit -r . -ll --exclude=./tests,./reference,./venv -q || true
-33
View File
@@ -9,15 +9,6 @@ on:
push:
branches: [main]
# Superseded PR pushes cancel their older run. Every main push gets its own
# group (run_id), so no main commit's run is ever cancelled or dropped as pending.
concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
PYTHONFAULTHANDLER: "1"
jobs:
test:
name: py${{ matrix.python-version }} on ${{ matrix.os }}
@@ -65,9 +56,6 @@ jobs:
node --input-type=module <<'JS' >> "$GITHUB_OUTPUT"
import runtime from './tools/vendor/comfyui-runtime.json' with { type: 'json' };
for (const [key, value] of Object.entries(runtime)) console.log(`${key}=${value}`);
import crossPack from './tools/vendor/cross-pack-runtime.json' with { type: 'json' };
console.log(`kikoRepository=${crossPack.repository}`);
console.log(`kikoRevision=${crossPack.revision}`);
JS
- name: Check out pinned ComfyUI
uses: actions/checkout@v7
@@ -76,13 +64,6 @@ jobs:
ref: ${{ steps.runtime.outputs.revision }}
path: .ci-comfyui
persist-credentials: false
- name: Check out pinned KikoTools for compatibility
uses: actions/checkout@v7
with:
repository: ${{ steps.runtime.outputs.kikoRepository }}
ref: ${{ steps.runtime.outputs.kikoRevision }}
path: .ci-kikotools
persist-credentials: false
- uses: actions/setup-python@v7
with:
python-version: ${{ steps.runtime.outputs.pythonVersion }}
@@ -103,17 +84,6 @@ jobs:
PM_COMFY_ROOT: ${{ github.workspace }}/.ci-comfyui
PM_TEST_PYTHON: python
run: npm run --prefix tools/vendor test:comfyui
- name: Real cached and headless execution history
env:
PM_COMFY_ROOT: ${{ github.workspace }}/.ci-comfyui
PM_TEST_PYTHON: python
run: npm run --prefix tools/vendor test:executions
- name: Independent and combined pack compatibility
env:
PM_COMFY_ROOT: ${{ github.workspace }}/.ci-comfyui
PM_KIKO_ROOT: ${{ github.workspace }}/.ci-kikotools
PM_TEST_PYTHON: python
run: npm run --prefix tools/vendor test:compat
- name: Upload real-host reports and startup logs
if: always()
uses: actions/upload-artifact@v7
@@ -175,8 +145,5 @@ jobs:
node --input-type=module --check < "$f" || exit 1
done
- name: Install pinned shared test contracts
run: npm ci --prefix tools/vendor --ignore-scripts
- name: Run JS tests
run: node --test tests/js/*.test.js
+6 -62
View File
@@ -10,17 +10,10 @@ on:
permissions:
contents: read
# Superseded PR pushes cancel their older run. Every main push gets its own
# group (run_id), so no main commit's run is ever cancelled or dropped as pending.
concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
frontend-coverage:
name: frontend coverage (chromium and touch)
viewerjs:
runs-on: ubuntu-latest
timeout-minutes: 25
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
@@ -37,10 +30,12 @@ jobs:
- 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
- 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: Upload frontend coverage and Chromium traces
- 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:
@@ -50,54 +45,3 @@ jobs:
coverage/js/
!coverage/js/raw/**
retention-days: 14
browser-workflows:
name: page workflows (${{ matrix.browser }})
runs-on: ubuntu-latest
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
browser: [firefox, webkit]
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: npx --prefix tools/vendor playwright install --with-deps ${{ matrix.browser }}
- name: Page workflows in ${{ matrix.browser }}
run: npm run --prefix tools/vendor test:workflows -- --project ${{ matrix.browser }}
- name: Upload browser traces
if: always()
uses: actions/upload-artifact@v7
with:
name: browser-traces-${{ matrix.browser }}
path: tools/vendor/test-results/
retention-days: 14
# Branch protection requires the exact context "viewerjs". This job keeps that
# name and fails unless every frontend job above succeeded, including when one
# was cancelled or skipped.
viewerjs:
needs: [frontend-coverage, browser-workflows]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Require every frontend job to succeed
env:
COVERAGE_RESULT: ${{ needs.frontend-coverage.result }}
WORKFLOWS_RESULT: ${{ needs.browser-workflows.result }}
run: |
echo "frontend-coverage: $COVERAGE_RESULT"
echo "browser-workflows: $WORKFLOWS_RESULT"
test "$COVERAGE_RESULT" = success && test "$WORKFLOWS_RESULT" = success
+2 -34
View File
@@ -162,12 +162,6 @@ when the fields are collapsed, so LoRA trigger outputs can connect directly.
Encoding, storage, and CONDITIONING/text outputs match Classic. Both layouts
support ComfyUI Classic and Nodes 2.0.
**Prompt Manager (Split)** keeps the same editor and library visible together.
Wide nodes place the library beside the editor; narrow nodes stack the panels.
It shares Focused's category/tag autocomplete, search filters, preview-before-use,
workflow persistence, and connectable prepend/append inputs. Encoding and storage
match Classic and Focused.
### 🔬 ComfyUI Workflow Analysis
- **PNG Metadata Parsing**: Advanced PNG chunk analysis to extract embedded ComfyUI data
@@ -531,7 +525,7 @@ Keep your database optimized with built-in maintenance tools:
### Database Location
By default, the database is saved as `prompts.db` in the extension directory. It contains your prompt library and image links. See the [database path compatibility note](#database-path-compatibility-211) before upgrading a deployment with a custom path.
By default, the database is saved as `example_prompts.db` in the node directory. This file contains all your prompts and linked images and can be backed up or shared.
### Searching Prompts
@@ -773,32 +767,6 @@ Relative paths are resolved against the extension directory. Absolute paths are
}
```
### Database Path Compatibility (#211)
PromptManager now honors the `database.default_path` setting in `config.json`
when loaded as a ComfyUI custom node. Relative paths resolve against the extension
directory, not ComfyUI's working directory; absolute paths are used as-is. Earlier
versions could ignore this setting and use `<extension>/prompts.db` instead.
Honoring a different configured path can therefore show an empty or different
library. PromptManager does not automatically move or merge database files.
Before upgrading or changing this setting:
1. Identify the database your current installation actually uses and confirm its
library contents. Check both the configured path and the prior fallback
`<extension>/prompts.db`; keep track of which existing file you intend to use.
2. Preserve a consistent backup of the active database with the dashboard's
Backup/Restore backup action or SQLite's online backup API. Do not copy only
the main file of a live SQLite database: committed data may still be in its
`-wal` file. For a filesystem copy, stop ComfyUI cleanly first and preserve any
remaining `-wal` file together with the main database.
3. Set `database.default_path` to the existing intended database file. An absolute
path makes the selection explicit; do not move or overwrite either database
while choosing the path.
4. Restart ComfyUI, then verify that the expected library appears and the active
database path resolves to the file you selected. Keep the backup until both
checks succeed.
### Gallery & Monitoring Settings
Configure the automatic image detection and gallery system:
@@ -870,7 +838,7 @@ print(f"Average rating: {info['average_rating']}")
# Create backup
db.model.backup_database("backup_prompts.db")
# The online backup includes committed data still in the WAL.
# The database file can be copied directly for backup
```
## Development
+6 -379
View File
@@ -10,7 +10,7 @@ reviewed. Add a regression test for each bug; prove it fails before the fix.
| Python tests | Domain logic, SQLite, API handlers, filesystem checks | ComfyUI host and ML runtimes where needed |
| JavaScript tests | Frontend modules and controller methods | DOM/network fixtures for focused edge cases |
| Viewer checks | Production Viewer.js and frontend methods in Chromium desktop/touch | Controlled images and page fixtures |
| Real ComfyUI | Pinned CPU host, actual extension, node definitions, widgets, routes, SQLite and real execution lifecycle | Stock UI cases isolate host writes; the execution suite queues deterministic test-only nodes without models |
| Real ComfyUI | Pinned CPU host, actual extension, node definitions, widgets, routes and SQLite | Inference is not queued; browser startup settings and host preference writes are isolated |
| Page workflows | Production HTML, startup, click handlers, HTTP routes, SQLite, PNG files, thumbnails | ComfyUI host services and the tagging model |
Page workflows cover save/edit/reload, duplicate-edit rejection, gallery image
@@ -99,51 +99,21 @@ 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. It is an
aggregate job that passes only when the `frontend coverage (chromium and touch)`
job and both `page workflows (firefox)` and `page workflows (webkit)` jobs
succeed; the browsers run on separate runners in parallel. GitHub artifacts
named `frontend-test-reports` contain HTML coverage, a JSON summary, and the
Chromium/touch failure screenshots/traces; `browser-traces-firefox` and
`browser-traces-webkit` hold the other browsers' traces. Open traces with
Playwright's `show-trace` tool.
The `viewerjs` check runs on every PR, including backend-only changes. GitHub
artifacts named `frontend-test-reports` contain HTML coverage, a JSON summary,
and failure screenshots/traces. Open traces with Playwright's `show-trace` tool.
The real-host suite covers both Classic and Nodes 2.0 startup, switching both
ways, actual database prompt selection, execution-text serialization, workflow
reload, widget/style cleanup, node schemas and standalone-page opening. It does
not certify every existing feature, arbitrary host versions,
not queue inference or certify every existing feature, arbitrary host versions,
GPU/model changes, cross-pack installation or visual design changes. Those still
need targeted validation.
## Windows portability preflight
Before changing paths, SQLite, streams, restore or cleanup:
- Follow the boundary's existing native canonical/identity contract;
`Path.resolve()` alone does not fold Windows case. Keep lexical aliases
distinct from resolved targets. Cover mixed case, drive and UNC spellings,
plus foreign-namespace denial with an allowed native control.
See Python's [normcase documentation](https://docs.python.org/3.13/library/os.path.html#os.path.normcase).
- Close every owned database/model connection, worker and stream handle before
unlink or `TemporaryDirectory.cleanup()`. Await worker/stream retirement.
A SQLite [`with conn` manages transactions, not connection closure](https://docs.python.org/3.13/library/sqlite3.html#how-to-use-the-connection-context-manager),
and [`addCleanup` runs after teardown](https://docs.python.org/3.13/library/unittest.html#unittest.TestCase.addCleanup);
use explicit `finally` cleanup when teardown removes files first.
- Windows can reject unlink of an open file; Unix can remove its directory entry
while the handle remains usable. Preserve the shared behavior assertions and
exercise the actual native branch, then prove handle release and successful
cleanup. Do not hide failures with blanket skips or retries.
See Python's [remove/unlink semantics](https://docs.python.org/3.13/library/os.html#os.remove).
- Run native Windows CI early for these changes and include a Windows impact and
evidence note in the PR. All twelve OS/Python combinations and every applicable
exact-head check must pass before merge. Portable `ntpath`/`PureWindowsPath`
controls do not establish native filesystem, locking or cleanup success.
## Required merge checks
`.github/branch-protection.json` records the required checks: all twelve Python/OS
combinations, Python coverage, formatting, frontend modules, `viewerjs`, `real ComfyUI compatibility`,
and `bandit (high severity)`.
combinations, Python coverage, formatting, frontend modules, `viewerjs`, and `real ComfyUI compatibility`.
The branch must be current with main, and administrators follow the same checks.
It does not require an additional human approval.
@@ -158,346 +128,3 @@ gh api repos/ComfyAssets/ComfyUI_PromptManager/branches/main/protection/required
Update the manifest whenever required job names change. Keep the frontend check
free of path filters so a required check is never silently skipped.
## Unified workspace
The opt-in `/prompt_manager/workspace` page opens the native Images workspace.
Overview retains real `/prompt_manager/stats` counts. Both use the shipped toolkit. Existing admin, gallery and metadata URLs remain
available while replacement workspaces are developed. Browser smoke tests in
`tests/browser/workspace.spec.mjs` cover navigation/history, keyboard skip links,
mobile layout, theme persistence/fallback, loading/retry and stale responses.
The Images cases in `tests/browser/workspace-images.spec.mjs` additionally cover
server-side filters, cursor append/retry, Grid/List/Masonry, bounded DOM, keyboard
navigation, restored browsing context, Viewer.js cleanup and actual video playback.
`tests/test_api_workspace_images.py` checks signed cursors, source identities,
legacy compatibility and header-only dimensions. Frontend stream/layout units
cover cancellation, malformed responses and bounded visibility lookup. These run
automatically in the existing four-browser-project workflow and frontend coverage job. `tests/comfyui/workspace.spec.mjs` verifies the page against the
managed host with both renderer startup configurations.
The shared inline and collection inspector reads the current rooted file's supported
embedded records, exact generation facts and recorded or inferred prompt evidence.
`tests/browser/full-inspector.spec.mjs` covers original/metadata/workflow downloads,
response limits, source changes, nested sending, matched dark/light and mobile
geometry, and the loaded-collection viewer. The viewer never fetches another page
or preloads all originals. Its explicit slideshow waits 3.5 seconds after image
readiness, pauses at native video without advancing on `ended`, and resumes on a
manually selected image only while prior intent remains. Pause/Stop, inspector
reading/copy/download/send, hidden document, media failure, close, query/route
disposal and pagehide cancel that intent; returning visibility or closing the sender
does not resume it. Stop leaves native video playback alone. Video uses only the
existing normalized `gallerySettings` autoplay/mute/loop values, read without writes;
denied/malformed storage falls back to autoplay off, mute on and loop on. Rejected
autoplay keeps native controls usable.
The inspector does not establish historical file identity, infer run membership,
edit image tags, export all EXIF, or load/execute a workflow. Saved vocabulary is
labelled by its recipe or positive-prompt scope. Limited raw evidence is omitted
atomically, with JSON exports unavailable and the rooted original still downloadable.
The inspection API uses one bounded read (2 MiB aggregate metadata, 8 MiB non-PNG
header, 10,000 PNG chunks, three-second ffprobe, 500 graph nodes/semantic expansions,
64 link/JSON-container levels and an 8 MiB serialized response); retry does not raise
these limits. Installing the Python inspection/source-scope changes requires a
maintainer ComfyUI restart. Automated tests use disposable hosts and never restart
the running personal ComfyUI.
Run the focused page checks with:
```sh
npm run --prefix tools/vendor test:workflows -- workspace.spec.mjs workspace-images.spec.mjs
node --test tests/js/workspace-*.test.js
```
Use the disposable fixture and managed-host prerequisites above. A maintainer
live check needs a ComfyUI restart after adding the Python route, then opening
`/prompt_manager/workspace`; the mockup preview server is a separate design tool.
## Prompt sending and cross-pack compatibility
`tests/js/prompt-bridge-contracts.test.js` runs the Toolkit's shared protocol
contracts against the actual checked-in browser asset. Adapter and lifecycle
units cover role isolation, connected inputs, stale previews, callback recovery,
serialization normalization and cached-page restoration. Browser tab workflows
exercise real BroadcastChannel delivery; `tests/comfyui/prompt-bridge.spec.mjs`
checks the real widgets, execution serialization and renderer transitions.
The existing required real-ComfyUI job also runs `test:compat`: PromptManager
alone, pinned KikoTools alone and both together, each starting in Classic and
Nodes 2.0. It copies tracked source into disposable hosts, preserves independent
configuration and checks edits/reload with both packs present. KikoTools remains
on its current embedded foundation; this does not adopt the Toolkit in that pack.
For local runs, use a clean KikoTools checkout at the revision in
`tools/vendor/cross-pack-runtime.json`, plus the managed-host prerequisites above:
```sh
PM_COMFY_ROOT=/path/to/pinned/comfyui PM_KIKO_ROOT=/path/to/pinned/kikotools \
npm run --prefix tools/vendor test:compat
```
The compatibility runner reserves loopback ports 8193–8195; `PM_COMPAT_PORT`
selects another consecutive unprivileged range. Never run automated tests against
the maintainer's running ComfyUI. This JavaScript host extension needs a full
browser refresh after deployment; its first slice adds no gallery send controls.
### Workspace prompt inspection and sending
`tests/test_api_prompt_inspector.py` covers rooted physical-file identity,
execution/graph/parameter evidence, saved associations, ambiguity, empty negatives,
read-only behavior, bounded PNG/video metadata and corrupt inputs. Run it with
`python -m unittest discover -s tests -p test_api_prompt_inspector.py` in a disposable
checkout. `tests/js/prompt-inspector.test.js` and `prompt-sender.test.js` cover
request races, explicit destination selection, preview tickets and uncertain
acknowledgements. Browser workflows exercise the same controls in Images and
Viewer, including nested modal focus, mobile layouts and per-role sending.
`tests/comfyui/prompt-sender.spec.mjs` sends through the actual workspace UI into
real Classic and Nodes 2.0 widgets and verifies workflow serialization without
loading a workflow or queueing generation.
## Durable execution ledger
`npm run --prefix tools/vendor test:executions` runs 46 real execution cases
(23 each in Classic and Nodes 2.0, retaining all 42 prior execution cases),
alongside the existing 78 stock host cases and six pack compatibility cases. Run
these suites serially because they share report cleanup and owned host resources. Use the same pinned disposable `PM_COMFY_ROOT`,
`PM_TEST_PYTHON` and Playwright browser installation as `test:comfyui`:
```sh
PM_COMFY_ROOT=/path/to/disposable/ComfyUI \
PM_TEST_PYTHON=/path/to/cpu-env/bin/python \
PLAYWRIGHT_BROWSERS_PATH=/path/to/playwright-browsers \
npm run --prefix tools/vendor test:executions
```
This suite reserves `http://127.0.0.1:8196/` and never attaches to an existing
server. Each renderer worker launches a CPU/offline host with independent
configuration, database, user and output directories. The shared launcher installs
tracked snapshots of PromptManager and a tiny temporary Git-tracked fixture pack.
Only that owned test pack provides cached text, deliberate failure, an
interruptible queue gate, deterministic CLIP/model/latent and CPU tensor inputs,
and control routes.
It replaces external sampler inference and trigger injection only inside the
disposable host; real registered PromptManager classes, queue, executor and cache
remain active. Its cache control resets the real executor on its worker to exercise
Classic, LRU, RAM-pressure and disabled caching; production has no test nodes or
endpoints. Cleanup always releases the gate, stops the host and removes both
owned directories. Logs and Playwright failures appear under
`tools/vendor/test-results/executions/` and the existing CI artifact upload.
The request-only case creates no browser or WebSocket client. Other cases verify
real `execution_cached` node lists after a failed job, both renderer switches and
workflow reload, repeated public UUIDs, interruption, graph rejection, and queued
removal before dequeue. Actual host history is checked alongside finite polling
of SQLite after the background writer. The read helper uses SQLite read-only
mode, exact UUID job/session filters and a 256-row limit; absent ledger schema is
explicit, while missing/corrupt databases and other read errors fail the suite.
Sensitive diagnostic and extra-data sentinels must never appear in lifecycle
rows. Exact prompt fields are deliberately persisted only in context snapshots.
The same read-only helper calls the internal `get_execution_contexts` and
`get_execution_outputs` readers for requested run IDs and inspects their snapshot
identities and current usage counts. Its `outputs` map is keyed by immutable run
ID, alongside the existing `rows`, `captures`, `usage` and `snapshotIds` results.
`execution-outputs.spec.mjs` runs actual stock SaveImage and PreviewImage nodes
from tiny CPU tensors. It compares each attempt's persisted descriptors with that
attempt's final host history, including A,A,B batches, multiple output nodes,
cached locators, reused public UUIDs, PNG metadata disabled, graphs without PM
nodes and text-only outputs. The pinned SaveImage returns an IMAGE socket; linking
it to the owned failure/gate node orders the partial-run tests explicitly. A held
downstream gate proves completed image work does not finalize the run early.
Other cases verify real output capture across startup in each renderer, switching
both ways and workflow reload. PM usage counts retain their existing per-job
behavior. References identify reported locators only: file identity is unverified,
new generation and context membership are unknown, and temp references are
transient. The stock suite excludes both execution files; discovery retains its
78 cases and the six pack cases unchanged.
For a meaningful historical failure, create a separate disposable detached
checkout at `fdba6fd0126a8a40cbd6f3e50ed4c5de8991efd4` and pass its path to
`launchExecutionHost({ repository })`. That base also predates the #211 config
import fix: after confirming an actual headless job succeeded, use
`readExecutionRuns` on its explicitly constructed owned path
`host.stateDirectory/custom_nodes/promptmanager/prompts.db`. It reports
`schemaAvailable: false` and empty rows, failing the expected succeeded-row
assertion. This historical inspection stays outside the ordinary suite, which
requires the configured database path. The launcher also accepts
`PM_EXECUTION_REPOSITORY`; omit it for the implementation worktree. Stage new
files first because snapshots use `git ls-files`. Never reset the implementation
checkout or point this harness at a live ComfyUI checkout.
The package-qualified config import also corrects #211: the configured database
path is honored when PromptManager runs as a ComfyUI custom node. Follow the
[database path compatibility and safe backup sequence](README.md#database-path-compatibility-211)
before restarting an existing deployment.
The ledger records only new dequeued jobs and observed outcomes. Existing
counters, prompts, pairs and image links are preserved, with no reconstructed old
runs. Schema v2 migrates automatically when the configured database opens, adding
immutable prompt-context tables without backfilling old runs. The internal reader
returns `not_captured` for preexisting runs; a new capture can instead be complete,
partial, ambiguous or unavailable. Keep the existing database backup/restore
procedure: restored history changes storage epoch, and old library navigation
hints must never be rebound to unrelated current rows.
Context cases verify A,A,B as three jobs/two combinations/one positive snapshot
when every positive field matches; all five registered PM variants; exact raw
whitespace and Unicode; known empty negatives; Text negative roles and crossed
pairs; fully cached repeats after trigger configuration changes; successful source
text before downstream failure/interruption; unknown cached dynamic raw; unsupported
transforms and duplicate list invocation ambiguity. Fresh values use
`runtime_return`, cached effective STRING outputs use `host_cache_output`, and
submitted literals use graph inference. SQL NULL means unknown; empty text is
known. Text is never trimmed or normalized by capture. Cache availability and
inferred sampler roles prove neither sampler execution nor consumption, and list
indices do not identify generated images. External trigger configuration changes
must not rewrite the effective text of an existing cached result. The cache matrix
also checks that positive/pair usage increments once per dequeued job while a
negative-only component retains its existing zero standalone-use count.
Fault injection for restore fencing, inherited async capability revocation, queued
finalization, epoch and generation revocation, provider-local cache entries, eviction/reset, malformed or
oversized payloads and missing host interfaces lives in the focused Python
`test_execution*.py` suites. These use disposable storage and mocked host interfaces;
they are distinct from the pinned real executor/cache checks above. Run-owned
output descriptors do not create gallery image associations or a chronological
timeline. Deploying the Python changes
requires a ComfyUI restart. After restart, queue three new jobs A,A,B with identical
positive fields and two negative recipes; existing counters should advance as usual,
and internal context inspection should find three attempts and two combinations.
For a live output check after that manual restart:
- Queue SaveImage and PreviewImage with a batch of two, then submit an unchanged
cached repeat and a changed tensor/input under new jobs.
- Inspect each run's internal output capture against its final ComfyUI history;
cached references may repeat, while the attempts remain distinct.
- Switch Classic to Nodes 2.0 and back, reload the workflow and queue again.
- Verify prompt/pair counters and gallery links retain their existing behavior;
output references alone do not prove file bytes or prompt membership.
Automated checks use only managed disposable hosts. They never restart or upgrade
the personal ComfyUI server.
## Chronological Library history (#209)
`#/library/{positive_id}` retains saved negative selection, the full selected
negative directly beneath its positive, acknowledged role/pair sending, and one
pair-filtered shared gallery. Recorded executions are a separate projection.
Selecting an execution, anchor, Raw/Effective mode, or reported occurrence does
not select a saved variant or refetch its gallery. A,A,B means three attempts,
two frozen contexts, and one exact raw-positive group. Different composition or
evidence can produce different frozen contexts despite equal-looking previews.
The versioned public reads are:
| Route | Meaning |
| --- | --- |
| `GET /prompt_manager/library/{positive_id}/history` | Exact frozen raw-positive group; compact chronological windows |
| `GET /prompt_manager/history/runs/{run_id}` | Frozen fields, per-field provenance, capture and outcome for a known UUID |
| `GET /prompt_manager/history/runs/{run_id}/outputs` | Ordered host-reported occurrences, including repeated locations |
| `GET /prompt_manager/history/runs/{run_id}/output/current/{media,thumbnail,inspection}` | Authorized current file at that reported location, using the returned epoch/scope URL |
Run and output reads accept the returned `epoch`; current-file routes require
the returned epoch, current scope and occurrence coordinates. Run integers are
decimal strings, preserving SQLite's full signed 64-bit values. Windows and
receipt pages default to 40 with a 1–100 limit. Cursors are opaque, authenticated,
scope-bound and capped at 4096 bytes. Run JSON has an 8 MiB UTF-8 limit and receipt
pages a 1 MiB limit; exceeding a limit fails explicitly instead of truncating
exact evidence. The unchanged capture limits remain 16 anchors/256 occurrences.
A cursor freezes its upper ordering boundary, not an application-wide snapshot:
each page has one SQLite read transaction and a count as of that read. Later
pages can observe completed capture, deletion or other current changes. Newer
runs require Refresh history. Unknown positive raw text cannot enter an exact
Library group but remains readable through its known run UUID. Unknown (`null`),
known empty (`""`) and whitespace are distinct. Complete-zero reported outputs
means the host reported none; missing capture means unknown, and partial or
conflicting capture remains incomplete. Receipts prove reported locations, not
historical bytes, newly generated files or context-output ownership. Cached
attempts can repeat exactly the same receipts.
Each nonempty history page also returns an authenticated `resume_cursor`, including
an exhausted page with no continuation. Re-entry uses `resume` instead of `cursor`
to read from that prior upper boundary, then restores at most the prior number of
40-run pages. It retains horizontal/vertical scroll and the visible anchor as
in-memory visit intent. The API validates epoch and exact saved-text scope again;
DTOs and current candidates are never saved as visit intent. Explicit Refresh
loads the newest window/count and revalidates the selected run's exact raw-positive
membership, context and receipt. A valid selection outside that window has an
explicit label and **View selected run** control.
Every receipt can be selected, including missing/error candidates. **Retry current
availability** rereads its existing bounded output page; a reduced response limit
may require several reads within that one already loaded page. Earlier rows,
context/mode, exact selection and saved gallery remain. Recovery actions use the
shared client's strict `smaller-limit`, `reload-outputs`, `retry-read` and
`refresh-history` vocabulary. Active inspection epoch/identity failures immediately
reach the owning history recovery, clearing stale recorded actions before reload.
Restore acceptance depends on merged #223: incompatible online storage
replacement must safely refuse without harming another owner's WAL/connection.
History fencing does not repair unsafe replacement. For accepted replacement,
the read transaction ends before a fresh-connection epoch check; the final
epoch/generation validation is the publication linearization point. Browser
generations also ignore late success/error callbacks after route/scope changes.
Old cursors, epoch and current-scope URLs fail with bounded recovery instructions.
Restored numeric IDs never repair an original navigation hint. Saved Library
details are revalidated separately, and a changed owner requires explicit reload.
Current media/thumbnail responses use an already-opened, validated canonical
file handle, bounded streaming and `Cache-Control: no-store`, including HEAD and
range behavior. Inspection is a current metadata observation with pre/post file
facts, canonical reauthorization and a fresh scope/epoch check; it is not pinned
historical bytes or an OS filesystem snapshot. A deterministic change during the
metadata read is rejected. A later request can legitimately observe replacement
bytes at the same path without changing the immutable receipt.
The focused acceptance command retains all four projects:
```sh
PM_TEST_PORT=8766 npm run --prefix tools/vendor test:workflows -- \
workspace-history.spec.mjs workspace-visual.spec.mjs \
--project chromium --project touch --project firefox --project webkit
npm run --prefix tools/vendor test:executions
npm run --prefix tools/vendor test:comfyui
npm run --prefix tools/vendor test:compat
```
Use the pinned disposable prerequisites above; managed suites run serially.
The real execution helpers compare SQLite captures to these public routes for
stock SaveImage/PreviewImage batches, multiple nodes, metadata disabled, cached
headless repeats, partial failure and interruption. Additional cases verify the
actual PM A,A,B group, unknown-positive exclusion, and exact frozen Raw/Effective
pair sends through the acknowledged host bridge after Classic/Nodes 2.0 startup,
both switches and workflow reload, without queuing or loading a workflow on send.
All original 42 execution, 78 stock-host and six compatibility cases remain.
The browser fixture uses actual APIs/SQLite/files, fixed owned backup snapshots,
one-shot bounded publication barriers and fixed filesystem/configuration commands.
Reset and cleanup release barriers, restore seams and revoke commands. It never
attaches to a personal host. The canonical HTML and its six required assets are
served from a fixed test-only allowlist; the original concept index/app/style
bytes are hash-pinned and unmodified. At 1600×1100 and 390×844 in both themes,
with fixed locale/UTC/reduced motion and ready fonts, tests capture unmasked
concept/production PNG pairs and geometry, and pin actual branch padding,
surfaces, 2px role lines, 57px rhythm, staggering, colors, selected focus and native
dialog behavior. Controls and actions are checked independently of screenshots.
Keyboard focus/return, coarse targets, 200% layout, route return and persisted
page lifecycle recovery retain their separate behavioral tests.
Initial production acceptance requires maintainer inspection of paired PNGs and
geometry; never update snapshots automatically or mask content to bless a gap.
Data-dependent text/images/counts and the clearly labeled saved/frozen/current
distinctions are documented in the comparison helper. Broader Images card
activation/Info/tag/favorite/select parity and shared Viewer stage/toolbar polish
and shared role-block spacing/padding/glyph polish remain outside #209. The
responsive production Viewer keeps its toolbar in the media panel and prompt details readable rather than reproducing the concept's
mobile toolbar collision. These are explicit remaining visual differences, not
a whole-application or full-gallery/Viewer pixel-parity claim.
Preserve raw logs, failed traces, paired PNGs, geometry, runtime versions and
source hashes before later managed commands clear their output directories.
Frontend baseline updates are additive through `coverage:update` only after
every previous metric floor and each new file's 80% line requirement pass; rerun
the coverage gate using the committed baseline. Python retains its 85% combined
statement/branch gate. Deploying the history Python changes requires a maintainer
ComfyUI restart; automated acceptance never restarts the personal server.
+1 -42
View File
@@ -6,7 +6,6 @@ gallery system.
This module provides prompt node types:
- PromptManager: CLIP encoding node that outputs CONDITIONING
- PromptManagerFocused: Editor-first layout using the same encoding contract
- PromptManagerSplit: Side-by-side editor and library using the same contract
- PromptManagerText: Text-only node that outputs STRING with prepend/append
functionality
@@ -40,20 +39,13 @@ def get_version():
return "unknown"
from .prompt_manager import ( # noqa: E402
PromptManager,
PromptManagerFocused,
PromptManagerSplit,
)
from .prompt_manager_pair import PromptManagerPair # noqa: E402
from .prompt_manager import PromptManager, PromptManagerFocused # noqa: E402
from .prompt_manager_text import PromptManagerText # noqa: E402
from .prompt_search_list import PromptSearchList # noqa: E402
NODE_CLASS_MAPPINGS = {
"PromptManager": PromptManager,
"PromptManagerFocused": PromptManagerFocused,
"PromptManagerSplit": PromptManagerSplit,
"PromptManagerPair": PromptManagerPair,
"PromptManagerText": PromptManagerText,
"PromptSearchList": PromptSearchList,
}
@@ -61,8 +53,6 @@ NODE_CLASS_MAPPINGS = {
NODE_DISPLAY_NAME_MAPPINGS = {
"PromptManager": "Prompt Manager (Classic)",
"PromptManagerFocused": "Prompt Manager (Focused)",
"PromptManagerSplit": "Prompt Manager (Split)",
"PromptManagerPair": "Prompt Manager (+/-)",
"PromptManagerText": "Prompt Manager Text",
"PromptSearchList": "Prompt Search List",
}
@@ -87,9 +77,6 @@ try:
routes = config.routes
api = PromptManagerAPI()
api.add_routes(routes)
host_app = getattr(config.server_instance, "app", None)
if host_app is not None and hasattr(host_app, "on_cleanup"):
host_app.on_cleanup.append(api.cleanup_history_reads)
except Exception as e:
# Log to internal logging system without console spam
@@ -161,34 +148,6 @@ except Exception as e:
f"Failed to register usage hook: {e}"
)
def _history_db_factory() -> "PromptDatabase":
"""Initialize history on its worker even if monitoring initialization failed."""
if _global_db is not None:
return _global_db
from .database.operations import PromptDatabase
return PromptDatabase()
_history_registration = None
try:
from .py.config import server_instance
from .utils.execution_history import register_history_hook
_history_registration = register_history_hook(server_instance, _history_db_factory)
except Exception:
try:
from .utils.logging_config import get_logger
get_logger("prompt_manager.init").error("Failed to register execution history")
except Exception:
import logging
logging.getLogger("prompt_manager.init").error(
"Failed to register execution history"
)
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
# Print startup message with loaded tools
-406
View File
@@ -1,406 +0,0 @@
"""Owned execution-context v1 tables within execution-history schema v2.
Content IDs hash exact scalar values; evidence JSON contains metadata only.
All validation scans use cursors and bounded per-row payloads.
"""
import json
import sqlite3
from .execution_history_schema import _compact, _uuid_check
try:
from ..utils.execution_contexts import (
BUNDLE_STATES,
CAPTURE_REASONS,
EVIDENCE_SOURCES,
UNKNOWN_REASONS,
MAX_CONTEXT_BYTES,
ContextBundle,
PromptSnapshot,
RecipeContext,
TextEvidence,
validate_bundle,
content_digest,
text_size,
)
except ImportError:
from utils.execution_contexts import (
BUNDLE_STATES,
CAPTURE_REASONS,
EVIDENCE_SOURCES,
UNKNOWN_REASONS,
MAX_CONTEXT_BYTES,
ContextBundle,
PromptSnapshot,
RecipeContext,
TextEvidence,
validate_bundle,
content_digest,
text_size,
)
FIELDS = ("raw", "effective", "prepend", "append")
TABLE_COLUMNS = {
"execution_context_texts": (
("text_id", "TEXT", 1, 1),
("text_value", "TEXT", 1, 0),
),
"execution_prompt_snapshots": (
("snapshot_id", "TEXT", 1, 1),
*((f"{field}_text_id", "TEXT", 0, 0) for field in FIELDS),
),
"execution_contexts": (
("context_id", "TEXT", 1, 1),
("positive_snapshot_id", "TEXT", 0, 0),
("negative_snapshot_id", "TEXT", 0, 0),
),
"execution_run_contexts": (
("run_id", "TEXT", 1, 1),
("anchor_id", "TEXT", 1, 2),
("context_id", "TEXT", 1, 0),
("evidence_json", "TEXT", 1, 0),
("positive_prompt_id", "INTEGER", 0, 0),
("negative_prompt_id", "INTEGER", 0, 0),
("pair_id", "INTEGER", 0, 0),
("reference_epoch", "TEXT", 0, 0),
("capture_version", "INTEGER", 1, 0),
),
"execution_context_capture": (
("run_id", "TEXT", 1, 1),
("state", "TEXT", 1, 0),
("reason", "TEXT", 1, 0),
("format_version", "INTEGER", 1, 0),
),
}
def _digest_check(column: str, nullable: bool = False) -> str:
check = (
f"typeof({column}) = 'text' AND length({column}) = 64 "
f"AND length(CAST({column} AS BLOB)) = 64 "
f"AND {column} NOT GLOB '*[^0-9a-f]*'"
)
return f"{column} IS NULL OR ({check})" if nullable else check
def _bounded_uuid_check(column: str) -> str:
return f"{_uuid_check(column)} AND length(CAST({column} AS BLOB)) = 36"
def _enum_check(column: str, values: frozenset[str]) -> str:
return (
f"typeof({column}) = 'text' AND {column} IN ("
+ ",".join("'" + value + "'" for value in sorted(values))
+ ")"
)
TABLE_CHECKS = {
"execution_context_texts": (
_digest_check("text_id"),
"typeof(text_value) = 'text' AND length(CAST(text_value AS BLOB)) <= 65536",
),
"execution_prompt_snapshots": (
_digest_check("snapshot_id"),
*(_digest_check(f"{field}_text_id", True) for field in FIELDS),
),
"execution_contexts": (
_digest_check("context_id"),
_digest_check("positive_snapshot_id", True),
_digest_check("negative_snapshot_id", True),
"positive_snapshot_id IS NOT NULL OR negative_snapshot_id IS NOT NULL",
),
"execution_run_contexts": (
_bounded_uuid_check("run_id"),
"typeof(anchor_id) = 'text' AND length(anchor_id) BETWEEN 1 AND 128 "
"AND length(CAST(anchor_id AS BLOB)) <= 512",
_digest_check("context_id"),
"typeof(evidence_json) = 'text' AND length(CAST(evidence_json AS "
"BLOB)) <= 8192",
*(
f"{column} IS NULL OR (typeof({column}) = 'integer' AND {column} > 0)"
for column in ("positive_prompt_id", "negative_prompt_id", "pair_id")
),
f"reference_epoch IS NULL OR ({_bounded_uuid_check('reference_epoch')})",
"typeof(capture_version) = 'integer' AND capture_version = 1",
),
"execution_context_capture": (
_bounded_uuid_check("run_id"),
_enum_check("state", BUNDLE_STATES),
_enum_check("reason", CAPTURE_REASONS),
"length(CAST(reason AS BLOB)) <= 64",
"typeof(format_version) = 'integer' AND format_version = 1",
),
}
# (source column, destination table, destination column); actions always NO ACTION.
TABLE_FOREIGN_KEYS = {
"execution_context_texts": (),
"execution_prompt_snapshots": tuple(
(f"{field}_text_id", "execution_context_texts", "text_id") for field in FIELDS
),
"execution_contexts": tuple(
(f"{role}_snapshot_id", "execution_prompt_snapshots", "snapshot_id")
for role in ("positive", "negative")
),
"execution_run_contexts": (
("run_id", "execution_runs", "run_id"),
("context_id", "execution_contexts", "context_id"),
),
"execution_context_capture": (("run_id", "execution_runs", "run_id"),),
}
# Only SQLite-generated primary-key indexes are unique. No secondary indexes required.
def install_context_schema(conn: sqlite3.Connection) -> None:
"""Install tables inside the caller's transaction, without committing."""
for table, columns in TABLE_COLUMNS.items():
definitions = [
f"{name} {kind}{' NOT NULL' if required else ''}"
for name, kind, required, _ in columns
]
primary = [name for name, _, _, order in columns if order]
definitions.append(f"PRIMARY KEY ({', '.join(primary)})")
definitions.extend(f"CHECK({check})" for check in TABLE_CHECKS[table])
definitions.extend(
f"FOREIGN KEY ({column}) REFERENCES {target}({key}) "
"ON UPDATE NO ACTION ON DELETE NO ACTION"
for column, target, key in TABLE_FOREIGN_KEYS[table]
)
conn.execute(f"CREATE TABLE {table} ({', '.join(definitions)})")
def _unique_object(pairs: list[tuple[str, object]]) -> dict:
result = {}
for key, value in pairs:
if key in result:
raise ValueError("Duplicate evidence key")
result[key] = value
return result
def _snapshot_values(conn: sqlite3.Connection, snapshot_id: str) -> tuple:
row = conn.execute(
"SELECT raw_text_id,effective_text_id,prepend_text_id,append_text_id "
"FROM execution_prompt_snapshots WHERE snapshot_id=?",
(snapshot_id,),
).fetchone()
if row is None:
raise ValueError("Missing snapshot")
values = []
for text_id in row:
if text_id is None:
values.append(None)
else:
text = conn.execute(
"SELECT text_value FROM execution_context_texts WHERE text_id=?",
(text_id,),
).fetchone()
if text is None:
raise ValueError("Missing context text")
text_size(text[0])
values.append(text[0])
return tuple(values)
def _validate_evidence(
conn: sqlite3.Connection, anchor_id: str, context_id: str, encoded: str
) -> tuple[RecipeContext, bool]:
text_size(encoded, 8192)
evidence = json.loads(encoded, object_pairs_hook=_unique_object)
if (
type(evidence) is not dict
or set(evidence)
!= {"format_version", "role_provenance", "positive", "negative"}
or type(evidence["format_version"]) is not int
or evidence["format_version"] != 1
or evidence["role_provenance"] != "inferred"
):
raise ValueError("Invalid context evidence")
row = conn.execute(
"SELECT positive_snapshot_id,negative_snapshot_id FROM "
"execution_contexts WHERE context_id=?",
(context_id,),
).fetchone()
if row is None:
raise ValueError("Missing context")
complete = True
snapshots = []
for role, snapshot_id in zip(("positive", "negative"), row):
metadata = evidence[role]
if snapshot_id is None:
if metadata is not None:
raise ValueError("Unexpected role evidence")
complete = False
snapshots.append(None)
continue
values = _snapshot_values(conn, snapshot_id)
if type(metadata) is not dict or set(metadata) != set(FIELDS):
raise ValueError("Invalid snapshot evidence")
fields = []
for field, value in zip(FIELDS, values):
item = metadata[field]
if type(item) is not dict or set(item) != {
"provenance",
"source",
"reason",
}:
raise ValueError("Invalid field evidence")
provenance, source, reason = (
item["provenance"],
item["source"],
item["reason"],
)
if (
type(provenance) is not str
or type(source) is not str
or source not in EVIDENCE_SOURCES.get(provenance, ())
):
raise ValueError("Invalid evidence source")
if value is None:
complete = False
if (
provenance != "unknown"
or type(reason) is not str
or reason not in UNKNOWN_REASONS
):
raise ValueError("Unknown evidence requires reason")
elif provenance == "unknown" or reason is not None:
raise ValueError("Known evidence metadata mismatch")
fields.append(TextEvidence(value, provenance, source, reason))
snapshots.append(PromptSnapshot(*fields))
return RecipeContext(anchor_id, *snapshots, evidence["role_provenance"]), complete
def context_schema_error(conn: sqlite3.Connection) -> str | None:
"""Validate exact owned schema, bounded evidence, fingerprints and links."""
if conn.execute(
"SELECT 1 FROM sqlite_master WHERE type IN ('trigger','view') LIMIT 1"
).fetchone():
return "Unsupported execution context trigger/view"
tables = {
row[0]: row[1]
for row in conn.execute("SELECT name,sql FROM sqlite_master WHERE type='table'")
if row[0] in TABLE_COLUMNS
}
if tables.keys() != TABLE_COLUMNS.keys():
return "Incomplete execution context schema"
for table, expected in TABLE_COLUMNS.items():
columns = tuple(
(row[1], row[2].upper(), row[3], row[5])
for row in conn.execute(f"PRAGMA table_xinfo({table})")
)
if columns != expected:
return f"{table} has invalid execution context columns"
sql = _compact(tables[table])
checks = TABLE_CHECKS[table]
if sql.count("check(") != len(checks) or any(
f"check({_compact(check)})" not in sql for check in checks
):
return f"{table} has invalid execution context checks"
foreign = tuple(
sorted(
(row[3], row[2], row[4], row[5], row[6], row[7])
for row in conn.execute(f"PRAGMA foreign_key_list({table})")
)
)
expected_foreign = tuple(
sorted(
(*key, "NO ACTION", "NO ACTION", "NONE")
for key in TABLE_FOREIGN_KEYS[table]
)
)
if foreign != expected_foreign:
return f"{table} has invalid execution context foreign keys"
for index in conn.execute(f"PRAGMA index_list({table})"):
if not index[2]:
continue
name = index[1].replace('"', '""')
keys = tuple(
row for row in conn.execute(f'PRAGMA index_xinfo("{name}")') if row[5]
)
primary = tuple(column[0] for column in expected if column[3])
if (
index[3] != "pk"
or index[4]
or tuple(key[2] for key in keys) != primary
or any(key[3] or key[4] != "BINARY" for key in keys)
):
return f"{table} has unexpected execution context uniqueness"
predicate = " AND ".join(f"({check})" for check in checks)
if conn.execute(
f"SELECT 1 FROM {table} WHERE NOT ({predicate}) LIMIT 1"
).fetchone():
return f"{table} contains invalid execution context records"
if conn.execute(f"PRAGMA foreign_key_check({table})").fetchone():
return f"{table} contains orphan execution context records"
if conn.execute(
"SELECT 1 FROM execution_run_contexts GROUP BY run_id HAVING "
"COUNT(*) > 16 LIMIT 1"
).fetchone():
return "Too many execution contexts per run"
try:
for text_id, text in conn.execute(
"SELECT text_id,text_value FROM execution_context_texts"
):
text_size(text)
if content_digest("text", (text,)) != text_id:
raise ValueError("Invalid text fingerprint")
for (snapshot_id,) in conn.execute(
"SELECT snapshot_id FROM execution_prompt_snapshots"
):
if (
content_digest("snapshot", _snapshot_values(conn, snapshot_id))
!= snapshot_id
):
raise ValueError("Invalid snapshot fingerprint")
for context_id, positive, negative in conn.execute(
"SELECT context_id,positive_snapshot_id,negative_snapshot_id FROM "
"execution_contexts"
):
if content_digest("context", (positive, negative)) != context_id:
raise ValueError("Invalid context fingerprint")
payload_size = 0
for snapshot_id in (positive, negative):
if snapshot_id is not None:
payload_size += sum(
text_size(value)
for value in _snapshot_values(conn, snapshot_id)
if value is not None
)
if payload_size > MAX_CONTEXT_BYTES:
raise ValueError("Oversized detached context")
previous_run = None
contexts = ()
for run_id, anchor_id, context_id, evidence in conn.execute(
"SELECT run_id,anchor_id,context_id,evidence_json FROM "
"execution_run_contexts ORDER BY run_id,anchor_id"
):
if run_id != previous_run:
contexts = ()
previous_run = run_id
context, complete = _validate_evidence(
conn, anchor_id, context_id, evidence
)
status = conn.execute(
"SELECT state,reason FROM execution_context_capture WHERE run_id=?",
(run_id,),
).fetchone()
if status is None:
raise ValueError("Context link has no capture header")
state, reason = status
if state == "complete" and not complete:
raise ValueError("Incomplete evidence marked complete")
candidate = (*contexts, context)
validate_bundle(ContextBundle(candidate, state, reason))
contexts = candidate
if conn.execute(
"SELECT 1 FROM execution_context_capture c WHERE state='complete' "
"AND NOT EXISTS (SELECT 1 FROM execution_run_contexts r WHERE "
"r.run_id=c.run_id) LIMIT 1"
).fetchone():
raise ValueError("Complete capture has no contexts")
except (ValueError, TypeError, RecursionError, OverflowError) as exc:
return f"Invalid execution context content: {type(exc).__name__}"
return None
-376
View File
@@ -1,376 +0,0 @@
"""Immutable execution capture and exact, epoch-fenced library navigation hints."""
from contextlib import ExitStack
import json
import sqlite3
from typing import Callable, ContextManager, Iterator
try:
from ..utils.execution_history_events import RunFinish, encode_command
from ..utils.execution_contexts import (
CAPTURE_REASONS,
ContextBundle,
content_digest,
validate_bundle,
)
except ImportError:
from utils.execution_history_events import RunFinish, encode_command
from utils.execution_contexts import (
CAPTURE_REASONS,
ContextBundle,
content_digest,
validate_bundle,
)
FIELDS = ("raw", "effective", "prepend", "append")
ROLES = ("positive", "negative")
def _prepare(bundle: ContextBundle) -> list[dict]:
validate_bundle(bundle)
if bundle.reason not in CAPTURE_REASONS:
raise ValueError("Invalid capture reason")
prepared = []
complete = bool(bundle.contexts)
for context in bundle.contexts:
evidence = {"format_version": 1, "role_provenance": context.role_provenance}
snapshots = []
for role in ROLES:
snapshot = getattr(context, role)
if snapshot is None:
snapshots.append(None)
evidence[role] = None
complete = False
continue
values = tuple(getattr(snapshot, field).value for field in FIELDS)
complete = complete and all(value is not None for value in values)
snapshots.append((content_digest("snapshot", values), values))
evidence[role] = {
field: {
"provenance": getattr(snapshot, field).provenance,
"source": getattr(snapshot, field).source,
"reason": getattr(snapshot, field).reason,
}
for field in FIELDS
}
if all(snapshot is None for snapshot in snapshots):
raise ValueError("Context must contain a snapshot")
context_id = content_digest(
"context", tuple(s[0] if s else None for s in snapshots)
)
encoded = json.dumps(
evidence,
ensure_ascii=False,
allow_nan=False,
separators=(",", ":"),
sort_keys=True,
)
if len(encoded.encode("utf-8")) > 8192:
raise ValueError("Oversized context evidence")
prepared.append(
{
"anchor_id": context.anchor_id,
"context_id": context_id,
"snapshots": snapshots,
"evidence_json": encoded,
}
)
if bundle.state == "complete" and not complete:
raise ValueError("Incomplete scalar capture")
return sorted(prepared, key=lambda item: item["anchor_id"])
def _existing_row(
conn: sqlite3.Connection, table: str, columns: tuple[str, ...], values: tuple
) -> bool:
row = conn.execute(
f"SELECT {','.join(columns[1:])} FROM {table} WHERE {columns[0]}=?",
(values[0],),
).fetchone()
if row is None:
return False
if tuple(row) != values[1:]:
raise ValueError("Execution context digest collision")
return True
def _content_rows(item: dict) -> Iterator[tuple[str, tuple[str, ...], tuple]]:
snapshot_ids = []
for snapshot in item["snapshots"]:
if snapshot is None:
snapshot_ids.append(None)
continue
snapshot_id, values = snapshot
text_ids = []
for value in values:
text_id = None if value is None else content_digest("text", (value,))
if value is not None:
yield "execution_context_texts", ("text_id", "text_value"), (
text_id,
value,
)
text_ids.append(text_id)
yield "execution_prompt_snapshots", (
"snapshot_id",
*(f"{field}_text_id" for field in FIELDS),
), (snapshot_id, *text_ids)
snapshot_ids.append(snapshot_id)
yield "execution_contexts", (
"context_id",
"positive_snapshot_id",
"negative_snapshot_id",
), (item["context_id"], *snapshot_ids)
def _insert_exact(
conn: sqlite3.Connection, table: str, columns: tuple[str, ...], values: tuple
) -> None:
if not _existing_row(conn, table, columns, values):
names = ",".join(columns)
placeholders = ",".join("?" for _ in columns)
conn.execute(f"INSERT INTO {table} ({names}) VALUES ({placeholders})", values)
def _prompt_hint(conn: sqlite3.Connection, raw: str | None) -> int | None:
if raw is None:
return None
rows = conn.execute(
"SELECT id FROM prompts WHERE text=? COLLATE BINARY LIMIT 2", (raw,)
).fetchall()
return rows[0][0] if len(rows) == 1 else None
def _hints(
conn: sqlite3.Connection, positive: str | None, negative: str | None
) -> tuple:
pos_id, neg_id = _prompt_hint(conn, positive), _prompt_hint(conn, negative)
pair_id = None
negative_ambiguous = (
negative is not None
and neg_id is None
and conn.execute(
"SELECT 1 FROM prompts WHERE text=? COLLATE BINARY LIMIT 1", (negative,)
).fetchone()
is not None
)
if pos_id is not None and negative is not None and not negative_ambiguous:
rows = conn.execute(
"SELECT pp.id FROM prompt_pairs pp LEFT JOIN prompts n ON "
"n.id=pp.negative_prompt_id "
"WHERE pp.positive_prompt_id=? AND ((pp.negative_prompt_id IS NULL AND "
"?='') "
"OR (pp.negative_prompt_id=? AND n.text=? COLLATE BINARY)) LIMIT 2",
(pos_id, negative, neg_id, negative),
).fetchall()
if len(rows) == 1:
pair_id = rows[0][0]
return pos_id, neg_id, pair_id
def _values(conn: sqlite3.Connection, snapshot_id: str | None) -> dict | None:
if snapshot_id is None:
return None
row = conn.execute(
"SELECT r.text_value,e.text_value,p.text_value,a.text_value "
"FROM execution_prompt_snapshots s "
"LEFT JOIN execution_context_texts r ON r.text_id=s.raw_text_id "
"LEFT JOIN execution_context_texts e ON e.text_id=s.effective_text_id "
"LEFT JOIN execution_context_texts p ON p.text_id=s.prepend_text_id "
"LEFT JOIN execution_context_texts a ON a.text_id=s.append_text_id "
"WHERE s.snapshot_id=?",
(snapshot_id,),
).fetchone()
if row is None:
raise sqlite3.DatabaseError("Missing execution context snapshot")
return dict(zip(FIELDS, row))
def _write_capture(
conn: sqlite3.Connection,
run_id: str,
expected_epoch: str,
bundle: ContextBundle,
prepared: list[dict],
) -> bool:
capture = conn.execute(
"SELECT state,reason,format_version FROM execution_context_capture "
"WHERE run_id=?",
(run_id,),
).fetchone()
if capture is not None:
for item in prepared:
for table, columns, values in _content_rows(item):
_existing_row(conn, table, columns, values)
links = conn.execute(
"SELECT anchor_id,context_id,evidence_json FROM "
"execution_run_contexts WHERE run_id=? ORDER BY anchor_id",
(run_id,),
).fetchall()
expected = [
(item["anchor_id"], item["context_id"], item["evidence_json"])
for item in prepared
]
# Compare JSON objects so serialization order never creates a conflict.
original = [(row[0], row[1], json.loads(row[2])) for row in links]
proposed = [
(anchor, context, json.loads(evidence))
for anchor, context, evidence in expected
]
if capture[0] != "conflicting" and (
tuple(capture) != (bundle.state, bundle.reason, bundle.format_version)
or original != proposed
):
conn.execute(
"UPDATE execution_context_capture SET "
"state='conflicting',reason='conflicting' WHERE run_id=?",
(run_id,),
)
return True
for item in prepared:
for table, columns, values in _content_rows(item):
_insert_exact(conn, table, columns, values)
raw = [snapshot[1][0] if snapshot else None for snapshot in item["snapshots"]]
positive_id, negative_id, pair_id = _hints(conn, *raw)
conn.execute(
"INSERT INTO "
"execution_run_contexts(run_id,anchor_id,context_id,evidence_json,"
"positive_prompt_id,negative_prompt_id,pair_id,reference_epoch,"
"capture_version) VALUES(?,?,?,?,?,?,?,?,1)",
(
run_id,
item["anchor_id"],
item["context_id"],
item["evidence_json"],
positive_id,
negative_id,
pair_id,
expected_epoch,
),
)
conn.execute(
"INSERT INTO "
"execution_context_capture(run_id,state,reason,format_version) "
"VALUES(?,?,?,?)",
(run_id, bundle.state, bundle.reason, bundle.format_version),
)
return True
class ExecutionContextOperations:
"""Compose with ExecutionHistoryOperations on PromptDatabase."""
def write_execution_contexts(
self, run_id: str, expected_epoch: str, bundle: ContextBundle
) -> bool:
prepared = _prepare(bundle)
with self._execution_transaction(expected_epoch) as conn:
if (
conn.execute(
"SELECT 1 FROM execution_runs WHERE run_id=? AND storage_epoch=?",
(run_id, expected_epoch),
).fetchone()
is None
):
return False
return _write_capture(conn, run_id, expected_epoch, bundle, prepared)
def write_execution_contexts_for_finish(
self,
command: RunFinish,
bundle: ContextBundle,
*,
generation_guard: Callable[[], ContextManager[bool]] | None = None,
) -> bool:
encode_command(command)
if type(command) is not RunFinish:
raise ValueError("Expected execution finish command")
prepared = _prepare(bundle)
# Enter the guard after BEGIN, and keep it until COMMIT/ROLLBACK exits.
with ExitStack() as guards:
with self._execution_transaction(command.storage_epoch) as conn:
if generation_guard is not None and not guards.enter_context(
generation_guard()
):
return False
row = conn.execute(
"SELECT run_id FROM execution_runs WHERE session_id=? "
"AND queue_item_id=? AND storage_epoch=?",
(command.session_id, command.queue_item_id, command.storage_epoch),
).fetchone()
if row is None:
return False
return _write_capture(
conn, row[0], command.storage_epoch, bundle, prepared
)
def get_execution_contexts(self, run_id: str) -> dict:
"""Return first-capture scalars; hints are None unless currently valid.
reference_epoch remains historical evidence even when hints are invalidated.
Reads never rebind a first-capture reference or alter stored capture data.
"""
conn = self.model.get_connection()
own_transaction = not conn.in_transaction
if own_transaction:
conn.execute("BEGIN")
try:
capture = conn.execute(
"SELECT state,reason,format_version FROM execution_context_capture "
"WHERE run_id=?",
(run_id,),
).fetchone()
if capture is None:
return {
"state": "not_captured",
"reason": "missing",
"format_version": None,
"contexts": [],
}
epoch = self.get_execution_epoch()
contexts = []
rows = conn.execute(
"SELECT r.*,c.positive_snapshot_id,c.negative_snapshot_id FROM "
"execution_run_contexts r JOIN execution_contexts c ON "
"c.context_id=r.context_id WHERE r.run_id=? ORDER BY r.anchor_id",
(run_id,),
)
for row in rows:
positive = _values(conn, row["positive_snapshot_id"])
negative = _values(conn, row["negative_snapshot_id"])
hints = (None, None, None)
if row["reference_epoch"] == epoch:
current = _hints(
conn,
positive["raw"] if positive else None,
negative["raw"] if negative else None,
)
hints = tuple(
row[key] if row[key] == value else None
for key, value in zip(
("positive_prompt_id", "negative_prompt_id", "pair_id"),
current,
)
)
contexts.append(
{
"anchor_id": row["anchor_id"],
"context_id": row["context_id"],
"positive": positive,
"negative": negative,
"evidence": json.loads(row["evidence_json"]),
"positive_prompt_id": hints[0],
"negative_prompt_id": hints[1],
"pair_id": hints[2],
"reference_epoch": row["reference_epoch"],
}
)
return {
"state": capture[0],
"reason": capture[1],
"format_version": capture[2],
"contexts": contexts,
}
finally:
if own_transaction:
conn.rollback()
-194
View File
@@ -1,194 +0,0 @@
"""Transactional attempt writes and process-local restore invalidation."""
import os
import sqlite3
import threading
from contextlib import contextmanager
from typing import Callable, Iterator
try:
from ..utils.execution_history_events import (
RunFinish,
RunStart,
classify_evidence,
encode_command,
)
from ..utils.logging_config import get_logger
except ImportError:
from utils.execution_history_events import (
RunFinish,
RunStart,
classify_evidence,
encode_command,
)
from utils.logging_config import get_logger
_logger = get_logger("prompt_manager.database.execution_history")
_restore_lock = threading.Lock()
_restore_observers: dict[str, dict[object, Callable[[], None]]] = {}
def _database_identity(db_path: str) -> str:
return os.path.normcase(os.path.realpath(db_path))
def subscribe_history_restore(
db_path: str, callback: Callable[[], None]
) -> Callable[[], None]:
"""Observe successful restores of the canonical database path."""
identity = _database_identity(db_path)
token = object()
with _restore_lock:
_restore_observers.setdefault(identity, {})[token] = callback
def unsubscribe() -> None:
with _restore_lock:
observers = _restore_observers.get(identity)
if observers is not None:
observers.pop(token, None)
if not observers:
_restore_observers.pop(identity, None)
return unsubscribe
def notify_history_restore(db_path: str) -> None:
"""Notify outside the registry lock; observers cannot fail a restore."""
with _restore_lock:
callbacks = tuple(
_restore_observers.get(_database_identity(db_path), {}).values()
)
for callback in callbacks:
try:
callback()
except Exception:
_logger.warning("Execution history restore observer failed", exc_info=True)
class HistoryEpochChanged(Exception):
"""A command belongs to storage replaced by a completed restore."""
def __init__(self, storage_epoch: str):
self.storage_epoch = storage_epoch
super().__init__("Execution history storage epoch changed")
class ExecutionHistoryOperations:
"""Mixin using the calling thread's existing managed model connection."""
def get_execution_epoch(self) -> str:
row = (
self.model.get_connection()
.execute(
"SELECT storage_epoch FROM execution_history_metadata "
"WHERE singleton_id=1"
)
.fetchone()
)
if row is None:
raise sqlite3.DatabaseError("Missing execution history metadata")
return row[0]
@contextmanager
def _execution_transaction(
self, expected_epoch: str
) -> Iterator[sqlite3.Connection]:
conn = self.model.get_connection()
if conn.in_transaction:
raise sqlite3.ProgrammingError(
"Execution history requires its own transaction"
)
timeout = conn.execute("PRAGMA busy_timeout").fetchone()[0]
conn.execute("PRAGMA busy_timeout=250")
try:
conn.execute("BEGIN IMMEDIATE")
try:
epoch = self.get_execution_epoch()
if epoch != expected_epoch:
raise HistoryEpochChanged(epoch)
yield conn
conn.commit()
except BaseException:
conn.rollback()
raise
finally:
conn.execute(f"PRAGMA busy_timeout={timeout}")
def write_execution_start(self, command: RunStart) -> bool:
encode_command(command)
if type(command) is not RunStart:
raise ValueError("Expected execution start command")
with self._execution_transaction(command.storage_epoch) as conn:
conn.execute(
"INSERT INTO execution_runs "
"(run_id,session_id,queue_item_id,job_id,storage_epoch,record_version,"
"observed_dequeue_at,outcome,diagnostics_omitted) "
"VALUES(?,?,?,?,?,1,?,'observed',0) "
"ON CONFLICT(session_id,queue_item_id) DO NOTHING",
(
command.run_id,
command.session_id,
command.queue_item_id,
command.job_id,
command.storage_epoch,
command.observed_dequeue_at,
),
)
return True
def write_execution_finish(self, command: RunFinish) -> bool:
encode_command(command)
if type(command) is not RunFinish:
raise ValueError("Expected execution finish command")
with self._execution_transaction(command.storage_epoch) as conn:
row = conn.execute(
"SELECT * FROM execution_runs WHERE session_id=? AND queue_item_id=? "
"AND storage_epoch=?",
(command.session_id, command.queue_item_id, command.storage_epoch),
).fetchone()
if row is None:
return False
completion = classify_evidence(row["job_id"], command.evidence)
if row["outcome"] == "unknown":
return True
if row["outcome"] != "observed":
contradictory = completion.reason == "conflicting_terminal" or (
completion.outcome in ("succeeded", "failed", "interrupted")
and completion.outcome != row["outcome"]
)
if contradictory:
conn.execute(
"UPDATE execution_runs SET outcome='unknown',"
"reason='conflicting_terminal' WHERE run_id=?",
(row["run_id"],),
)
return True
conn.execute(
"UPDATE execution_runs SET observed_callback_at=?,"
"executor_started_at=?,"
"terminal_event_at=?,outcome=?,reason=?,diagnostics_omitted=?,"
"node_id=?,"
"node_type=?,exception_type=? WHERE run_id=?",
(
command.observed_callback_at,
completion.executor_started_at,
completion.terminal_event_at,
completion.outcome,
completion.reason,
int(completion.diagnostics_omitted),
completion.node_id,
completion.node_type,
completion.exception_type,
row["run_id"],
),
)
return True
def get_execution_run(self, run_id: str) -> dict | None:
row = (
self.model.get_connection()
.execute("SELECT * FROM execution_runs WHERE run_id=?", (run_id,))
.fetchone()
)
return dict(row) if row is not None else None
-349
View File
@@ -1,349 +0,0 @@
"""Versioned additive schema for observed execution attempts."""
import re
import sqlite3
from uuid import uuid4
SCHEMA_VERSION = 3
def _uuid_check(column: str) -> str:
return (
f"typeof({column}) = 'text' AND length({column}) = 36 "
f"AND substr({column}, 9, 1) = '-' AND substr({column}, 14, 1) = '-' "
f"AND substr({column}, 19, 1) = '-' AND substr({column}, 24, 1) = '-' "
f"AND length(replace({column}, '-', '')) = 32 "
f"AND replace({column}, '-', '') NOT GLOB '*[^0-9a-f]*'"
)
def _integer_check(column: str, *, nullable: bool = False) -> str:
expression = f"typeof({column}) = 'integer' AND {column} >= 0"
return f"{column} IS NULL OR ({expression})" if nullable else expression
_METADATA_CHECKS = (
"singleton_id = 1",
"schema_version = 3",
_uuid_check("storage_epoch"),
)
_RUN_CHECKS = (
*(
_uuid_check(column)
for column in ("run_id", "session_id", "job_id", "storage_epoch")
),
_integer_check("queue_item_id"),
"record_version = 1",
_integer_check("observed_dequeue_at"),
*(
_integer_check(column, nullable=True)
for column in (
"observed_callback_at",
"executor_started_at",
"terminal_event_at",
)
),
"outcome IN ('observed', 'succeeded', 'failed', 'interrupted', 'unknown')",
"(outcome = 'observed' AND reason IS NULL AND "
"observed_callback_at IS NULL) OR (outcome != 'observed' AND "
"reason IS NOT NULL AND observed_callback_at IS NOT NULL AND "
"((outcome = 'succeeded' AND reason = 'terminal_success') OR "
"(outcome = 'failed' AND reason = 'terminal_error') OR (outcome = "
"'interrupted' AND reason = 'terminal_interrupted') OR (outcome = "
"'unknown' AND reason IN ('missing_terminal', "
"'conflicting_terminal', 'invalid_evidence', 'evidence_limit'))))",
"diagnostics_omitted IN (0, 1)",
*(
f"{column} IS NULL OR (typeof({column}) = 'text' "
f"AND length({column}) <= {limit})"
for column, limit in (
("node_id", 128),
("node_type", 128),
("exception_type", 256),
)
),
)
_TABLE_COLUMNS = {
"execution_history_metadata": (
("singleton_id", "INTEGER", 1, 1),
("schema_version", "INTEGER", 1, 0),
("storage_epoch", "TEXT", 1, 0),
),
"execution_runs": (
("run_id", "TEXT", 1, 1),
("session_id", "TEXT", 1, 0),
("queue_item_id", "INTEGER", 1, 0),
("job_id", "TEXT", 1, 0),
("storage_epoch", "TEXT", 1, 0),
("record_version", "INTEGER", 1, 0),
("observed_dequeue_at", "INTEGER", 1, 0),
("observed_callback_at", "INTEGER", 0, 0),
("executor_started_at", "INTEGER", 0, 0),
("terminal_event_at", "INTEGER", 0, 0),
("outcome", "TEXT", 1, 0),
("reason", "TEXT", 0, 0),
("diagnostics_omitted", "INTEGER", 1, 0),
("node_id", "TEXT", 0, 0),
("node_type", "TEXT", 0, 0),
("exception_type", "TEXT", 0, 0),
),
}
_TABLE_CHECKS = {
"execution_history_metadata": _METADATA_CHECKS,
"execution_runs": _RUN_CHECKS,
}
# Row-only guards preserve the supported v1 DDL fingerprint while bounding reads.
_ROW_BYTE_CHECKS = {
"execution_history_metadata": ("length(CAST(storage_epoch AS BLOB)) = 36",),
"execution_runs": (
*(
f"length(CAST({column} AS BLOB)) = 36"
for column in (
"run_id",
"session_id",
"job_id",
"storage_epoch",
)
),
*(
f"{column} IS NULL OR length(CAST({column} AS BLOB)) <= {limit}"
for column, limit in (
("node_id", 512),
("node_type", 512),
("exception_type", 1024),
)
),
),
}
_INDEXES = {
"idx_execution_runs_identity": (True, ("session_id", "queue_item_id")),
"idx_execution_runs_observed": (False, ("observed_dequeue_at", "run_id")),
}
def _compact(sql: str) -> str:
return "".join(
token if token.startswith("'") else re.sub(r"\s+", "", token).lower()
for token in re.findall(r"'(?:''|[^'])*'|[^']+", sql)
)
def history_schema_error(
conn: sqlite3.Connection, *, allow_missing: bool = True
) -> str | None:
"""Reject partial/unsupported history schemas and invalid persisted records."""
tables = {
row[0]: row[1]
for row in conn.execute("SELECT name,sql FROM sqlite_master WHERE type='table'")
if row[0] in _TABLE_COLUMNS
}
from .execution_context_schema import TABLE_COLUMNS, context_schema_error
from .execution_output_schema import (
TABLE_COLUMNS as OUTPUT_COLUMNS,
output_schema_error,
)
output_present = any(
row[0] in OUTPUT_COLUMNS
for row in conn.execute("SELECT name FROM sqlite_master")
)
if not tables and output_present:
return "Execution outputs require execution history"
context_present = any(
row[0] in TABLE_COLUMNS
for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
)
if not tables and context_present:
return "Execution contexts require execution history"
if not tables:
return None if allow_missing else "Missing execution history schema"
if tables.keys() != _TABLE_COLUMNS.keys():
return "Incomplete execution history schema"
metadata = conn.execute(
"SELECT CASE WHEN typeof(schema_version) = 'integer' "
"THEN schema_version ELSE NULL END FROM execution_history_metadata LIMIT 2"
).fetchall()
if len(metadata) != 1 or len(metadata[0]) != 1 or metadata[0][0] not in (1, 2, 3):
return "Unsupported execution history metadata/version"
version = metadata[0][0]
if version < 3 and output_present:
return "Unexpected execution outputs for history v1/v2"
if version == 1 and context_present:
return "Unexpected execution contexts for history v1"
if not allow_missing and version != SCHEMA_VERSION:
return "Execution history migration required"
for table, expected in _TABLE_COLUMNS.items():
columns = tuple(
(row[1], row[2].upper(), row[3], row[5])
for row in conn.execute(f"PRAGMA table_info({table})")
)
if columns != expected:
return f"{table} has invalid execution history columns"
sql = _compact(tables[table])
checks = _TABLE_CHECKS[table]
if table == "execution_history_metadata":
checks = tuple(
check.replace("schema_version = 3", f"schema_version = {version}")
for check in checks
)
if sql.count("check(") != len(checks) or any(
f"check({_compact(check)})" not in sql for check in checks
):
return f"{table} has invalid execution history checks"
if conn.execute(f"PRAGMA foreign_key_list({table})").fetchone():
return f"{table} has unexpected execution history foreign keys"
for index in conn.execute(f"PRAGMA index_list({table})"):
if not index[2]:
continue
if table == "execution_runs" and index[1] == "idx_execution_runs_identity":
continue
name = index[1].replace('"', '""')
keys = tuple(
row for row in conn.execute(f'PRAGMA index_xinfo("{name}")') if row[5]
)
primary = tuple(column[0] for column in expected if column[3])
if (
index[3] != "pk"
or index[4]
or tuple(key[2] for key in keys) != primary
or any(key[3] or key[4] != "BINARY" for key in keys)
):
return f"{table} has unexpected execution history uniqueness"
predicate = " AND ".join(
f"({check})" for check in (*checks, *_ROW_BYTE_CHECKS[table])
)
if conn.execute(
f"SELECT 1 FROM {table} WHERE NOT ({predicate}) LIMIT 1"
).fetchone():
return f"{table} contains invalid execution history records"
metadata = conn.execute(
"SELECT singleton_id,schema_version,storage_epoch FROM "
"execution_history_metadata"
).fetchall()
if len(metadata) != 1 or metadata[0][0] != 1 or metadata[0][1] != version:
return "Unsupported execution history metadata/version"
indexes = {row[1]: row for row in conn.execute("PRAGMA index_list(execution_runs)")}
for name, (unique, columns) in _INDEXES.items():
row = indexes.get(name)
if row is None or bool(row[2]) != unique or row[4]:
return f"Missing or invalid execution history index {name}"
actual = tuple(conn.execute(f"PRAGMA index_xinfo({name})"))
keys = tuple(item for item in actual if item[5])
if tuple(item[2] for item in keys) != columns or any(
item[3] or item[4] != "BINARY" for item in keys
):
return f"Invalid execution history index {name}"
if version >= 2:
error = context_schema_error(conn)
if error:
return error
return output_schema_error(conn) if version == 3 else None
def install_context_schema(conn: sqlite3.Connection) -> None:
from .execution_context_schema import install_context_schema as install
install(conn)
def install_output_schema(conn: sqlite3.Connection) -> None:
from .execution_output_schema import install_output_schema as install
install(conn)
def migrate_execution_history(conn: sqlite3.Connection) -> None:
"""Install history, contexts and outputs atomically, without backfill."""
conn.execute("SAVEPOINT execution_history_schema")
try:
error = history_schema_error(conn)
if error:
raise sqlite3.DatabaseError(error)
present = conn.execute(
"SELECT 1 FROM sqlite_master WHERE name IN "
"('execution_runs','execution_history_metadata') LIMIT 1"
).fetchone()
if not present:
for table, columns in _TABLE_COLUMNS.items():
definitions = [
f"{name} {kind}{' NOT NULL' if required else ''}"
f"{' PRIMARY KEY' if primary else ''}"
for name, kind, required, primary in columns
]
definitions.extend(f"CHECK({check})" for check in _TABLE_CHECKS[table])
conn.execute(f"CREATE TABLE {table} ({', '.join(definitions)})")
for name, (unique, columns) in _INDEXES.items():
conn.execute(
f"CREATE {'UNIQUE ' if unique else ''}INDEX {name} "
f"ON execution_runs({', '.join(columns)})"
)
conn.execute(
"INSERT INTO execution_history_metadata VALUES(1,?,?)",
(SCHEMA_VERSION, str(uuid4())),
)
install_context_schema(conn)
install_output_schema(conn)
else:
version, epoch = conn.execute(
"SELECT schema_version,storage_epoch FROM execution_history_metadata"
).fetchone()
if version < SCHEMA_VERSION:
conn.execute(
"ALTER TABLE execution_history_metadata RENAME TO "
"execution_history_metadata_old"
)
definitions = [
f"{name} {kind}{' NOT NULL' if required else ''}"
f"{' PRIMARY KEY' if primary else ''}"
for name, kind, required, primary in _TABLE_COLUMNS[
"execution_history_metadata"
]
]
definitions.extend(f"CHECK({check})" for check in _METADATA_CHECKS)
conn.execute(
"CREATE TABLE execution_history_metadata "
f"({', '.join(definitions)})"
)
conn.execute(
"INSERT INTO execution_history_metadata VALUES(1,?,?)",
(SCHEMA_VERSION, epoch),
)
conn.execute("DROP TABLE execution_history_metadata_old")
if version == 1:
install_context_schema(conn)
install_output_schema(conn)
# Read projections need reverse content lookup and the complete run order.
for table, columns in (
("execution_prompt_snapshots", "raw_text_id,snapshot_id"),
("execution_contexts", "positive_snapshot_id,context_id"),
("execution_run_contexts", "context_id,run_id,anchor_id"),
("execution_runs", "observed_dequeue_at,session_id,queue_item_id,run_id"),
):
conn.execute(
f"CREATE INDEX IF NOT EXISTS idx_history_read_{table} "
f"ON {table}({columns})"
)
error = history_schema_error(conn, allow_missing=False)
if error:
raise sqlite3.DatabaseError(error)
conn.execute("RELEASE execution_history_schema")
except BaseException:
conn.execute("ROLLBACK TO execution_history_schema")
conn.execute("RELEASE execution_history_schema")
raise
def rotate_history_epoch(conn: sqlite3.Connection) -> str:
"""Publish a fresh staging epoch without rewriting restored run epochs."""
epoch = str(uuid4())
with conn:
cursor = conn.execute(
"UPDATE execution_history_metadata SET storage_epoch=? WHERE "
"singleton_id=1",
(epoch,),
)
if cursor.rowcount != 1:
raise sqlite3.DatabaseError("Missing execution history metadata")
return epoch
-218
View File
@@ -1,218 +0,0 @@
"""Owned schema and bounded restore validation for frozen output locators."""
import sqlite3
from .execution_history_schema import _compact, _uuid_check
try:
from ..utils.execution_outputs import (
BUNDLE_OVERHEAD,
MAX_BUNDLE_BYTES,
MAX_IMAGE_POSITIONS,
MAX_REFERENCE_BYTES,
REFERENCE_OVERHEAD,
STATE_REASONS,
OutputBundle,
OutputReference,
validate_output_bundle,
)
except ImportError:
from utils.execution_outputs import (
BUNDLE_OVERHEAD,
MAX_BUNDLE_BYTES,
MAX_IMAGE_POSITIONS,
MAX_REFERENCE_BYTES,
REFERENCE_OVERHEAD,
STATE_REASONS,
OutputBundle,
OutputReference,
validate_output_bundle,
)
TABLE_COLUMNS = {
"execution_output_capture": (
("run_id", "TEXT", 1, 1),
("state", "TEXT", 1, 0),
("reason", "TEXT", 1, 0),
("format_version", "INTEGER", 1, 0),
("source", "TEXT", 1, 0),
),
"execution_run_outputs": (
("run_id", "TEXT", 1, 1),
("output_node_id", "TEXT", 1, 2),
("image_ordinal", "INTEGER", 1, 3),
("folder_type", "TEXT", 1, 0),
("subfolder", "TEXT", 1, 0),
("filename", "TEXT", 1, 0),
("path_style", "TEXT", 1, 0),
("root_key", "TEXT", 0, 0),
),
}
TABLE_FOREIGN_KEYS = {
"execution_output_capture": (("run_id", "execution_runs", "run_id"),),
"execution_run_outputs": (("run_id", "execution_output_capture", "run_id"),),
}
def _text_check(column: str, maximum: int, *, characters: int | None = None) -> str:
return (
f"typeof({column}) = 'text' AND length({column}) <= "
f"{maximum if characters is None else characters} "
f"AND length(CAST({column} AS BLOB)) <= {maximum} "
f"AND instr(CAST({column} AS BLOB), x'00') = 0"
)
def _enum_check(column: str, values: tuple | frozenset) -> str:
return (
f"{_text_check(column, 32)} AND {column} IN ("
+ ",".join("'" + value + "'" for value in sorted(values))
+ ")"
)
_RUN_ID_CHECK = f"{_uuid_check('run_id')} AND {_text_check('run_id', 36)}"
TABLE_CHECKS = {
"execution_output_capture": (
_RUN_ID_CHECK,
_enum_check("state", tuple(STATE_REASONS)),
_enum_check("reason", frozenset.union(*STATE_REASONS.values())),
"typeof(format_version) = 'integer' AND format_version = 1",
f"{_text_check('source', 18)} AND source = 'host_final_history'",
),
"execution_run_outputs": (
_RUN_ID_CHECK,
f"{_text_check('output_node_id', 512, characters=128)} "
"AND length(output_node_id) >= 1",
"typeof(image_ordinal) = 'integer' AND image_ordinal BETWEEN 0 AND 255",
_enum_check("folder_type", ("output", "temp")),
_text_check("subfolder", 2048),
f"{_text_check('filename', 1024)} AND length(filename) >= 1",
_enum_check("path_style", ("posix", "windows")),
f"root_key IS NULL OR ({_text_check('root_key', 64)} "
"AND length(root_key) = 64 AND root_key NOT GLOB '*[^0-9a-f]*')",
),
}
def _table_sql(table: str) -> str:
columns = TABLE_COLUMNS[table]
definitions = [
f"{name} {kind}{' NOT NULL' if required else ''}"
for name, kind, required, _ in columns
]
primary = [name for name, _, _, order in columns if order]
definitions.append(f"PRIMARY KEY ({', '.join(primary)})")
definitions.extend(f"CHECK({check})" for check in TABLE_CHECKS[table])
definitions.extend(
f"FOREIGN KEY ({column}) REFERENCES {target}({key}) "
"ON UPDATE NO ACTION ON DELETE NO ACTION"
for column, target, key in TABLE_FOREIGN_KEYS[table]
)
return f"CREATE TABLE {table} ({', '.join(definitions)})"
def install_output_schema(conn: sqlite3.Connection) -> None:
"""Install the two owned tables within the caller's migration transaction."""
for table in TABLE_COLUMNS:
conn.execute(_table_sql(table))
def output_schema_error(conn: sqlite3.Connection) -> str | None:
"""Reject altered schemas and invalid rows before returning bounded DTOs."""
if conn.execute(
"SELECT 1 FROM sqlite_master WHERE type IN ('trigger','view') LIMIT 1"
).fetchone():
return "Unsupported execution output trigger/view"
tables = {
row[0]: row[1]
for row in conn.execute("SELECT name,sql FROM sqlite_master WHERE type='table'")
if row[0] in TABLE_COLUMNS
}
if tables.keys() != TABLE_COLUMNS.keys():
return "Incomplete execution output schema"
for table, expected in TABLE_COLUMNS.items():
columns = tuple(
(row[1], row[2].upper(), row[3], row[5])
for row in conn.execute(f"PRAGMA table_xinfo({table})")
)
if columns != expected or _compact(tables[table]) != _compact(
_table_sql(table)
):
return f"{table} has invalid execution output schema"
foreign = tuple(
sorted(
(row[3], row[2], row[4], row[5], row[6], row[7])
for row in conn.execute(f"PRAGMA foreign_key_list({table})")
)
)
if foreign != tuple(
(*key, "NO ACTION", "NO ACTION", "NONE")
for key in TABLE_FOREIGN_KEYS[table]
):
return f"{table} has invalid execution output foreign keys"
for index in conn.execute(f"PRAGMA index_list({table})"):
name = index[1].replace('"', '""')
keys = tuple(
row for row in conn.execute(f'PRAGMA index_xinfo("{name}")') if row[5]
)
if (
not index[2]
or index[3] != "pk"
or index[4]
or tuple(key[2] for key in keys)
!= tuple(column[0] for column in expected if column[3])
or any(key[3] or key[4] != "BINARY" for key in keys)
):
return f"{table} has unexpected execution output index/uniqueness"
# Run every scalar guard in SQLite before any evidence text is fetched.
predicate = " AND ".join(f"({check})" for check in TABLE_CHECKS[table])
if conn.execute(
f"SELECT 1 FROM {table} WHERE NOT ({predicate}) LIMIT 1"
).fetchone():
return f"{table} contains invalid execution output records"
if conn.execute(f"PRAGMA foreign_key_check({table})").fetchone():
return f"{table} contains orphan execution output records"
reference_bytes = f"{REFERENCE_OVERHEAD} + " + " + ".join(
f"coalesce(length(CAST({column} AS BLOB)), 0)"
for column in (
"output_node_id",
"folder_type",
"subfolder",
"filename",
"path_style",
"root_key",
)
)
if conn.execute(
"SELECT 1 FROM execution_run_outputs "
f"WHERE ({reference_bytes}) > {MAX_REFERENCE_BYTES} LIMIT 1"
).fetchone():
return "Oversized execution output reference"
if conn.execute(
"SELECT 1 FROM execution_run_outputs GROUP BY run_id "
f"HAVING COUNT(*) > {MAX_IMAGE_POSITIONS} OR "
f"{BUNDLE_OVERHEAD} + SUM({reference_bytes}) > {MAX_BUNDLE_BYTES} LIMIT 1"
).fetchone():
return "Oversized execution output bundle"
try:
for run_id, state, reason, version, source in conn.execute(
"SELECT run_id,state,reason,format_version,source "
"FROM execution_output_capture"
):
references = tuple(
OutputReference(*row)
for row in conn.execute(
"SELECT output_node_id,image_ordinal,folder_type,subfolder,"
"filename,path_style,root_key FROM execution_run_outputs "
"WHERE run_id=? ORDER BY output_node_id,image_ordinal",
(run_id,),
)
)
validate_output_bundle(
OutputBundle(references, state, reason, version, source)
)
except (ValueError, TypeError, OverflowError, sqlite3.Error) as exc:
return f"Invalid execution output content: {type(exc).__name__}"
return None
-153
View File
@@ -1,153 +0,0 @@
"""Immutable run-owned output references, independent of mutable galleries."""
import sqlite3
from contextlib import ExitStack
from typing import Callable, ContextManager
try:
from ..utils.execution_history_events import RunFinish, encode_command
from ..utils.execution_outputs import OutputBundle, validate_output_bundle
except ImportError:
from utils.execution_history_events import RunFinish, encode_command
from utils.execution_outputs import OutputBundle, validate_output_bundle
_REFERENCE_FIELDS = (
"output_node_id",
"image_ordinal",
"folder_type",
"subfolder",
"filename",
"path_style",
"root_key",
)
_REFERENCE_COLUMNS = ",".join(_REFERENCE_FIELDS)
def _capture(conn: sqlite3.Connection, run_id: str) -> sqlite3.Row | None:
capture = conn.execute(
"SELECT state,reason,format_version,source FROM execution_output_capture "
"WHERE run_id=?",
(run_id,),
).fetchone()
# Legacy callers may disable FK enforcement; never adopt orphan evidence.
if capture is None:
if conn.execute(
"SELECT 1 FROM execution_run_outputs WHERE run_id=? LIMIT 1", (run_id,)
).fetchone():
raise sqlite3.DatabaseError("Output references have no capture header")
elif (
conn.execute(
"SELECT 1 FROM execution_runs WHERE run_id=?", (run_id,)
).fetchone()
is None
):
raise sqlite3.DatabaseError("Output capture has no execution parent")
return capture
def _references(conn: sqlite3.Connection, run_id: str) -> list[tuple]:
return [
tuple(row)
for row in conn.execute(
f"SELECT {_REFERENCE_COLUMNS} FROM execution_run_outputs "
"WHERE run_id=? ORDER BY output_node_id,image_ordinal",
(run_id,),
)
]
class ExecutionOutputOperations:
"""Compose with ExecutionHistoryOperations on PromptDatabase."""
def write_execution_outputs_for_finish(
self,
command: RunFinish,
bundle: OutputBundle,
*,
generation_guard: Callable[[], ContextManager[bool]] | None = None,
) -> bool:
encode_command(command)
if type(command) is not RunFinish:
raise ValueError("Expected execution finish command")
validate_output_bundle(bundle)
header = (bundle.state, bundle.reason, bundle.format_version, bundle.source)
prepared = [
tuple(getattr(reference, field) for field in _REFERENCE_FIELDS)
for reference in bundle.references
]
# Enter after BEGIN and retain the guard until COMMIT/ROLLBACK finishes.
with ExitStack() as guards:
with self._execution_transaction(command.storage_epoch) as conn:
if generation_guard is not None and not guards.enter_context(
generation_guard()
):
return False
parent = conn.execute(
"SELECT run_id FROM execution_runs WHERE session_id=? "
"AND queue_item_id=? AND storage_epoch=?",
(command.session_id, command.queue_item_id, command.storage_epoch),
).fetchone()
if parent is None:
return False
run_id = parent[0]
capture = _capture(conn, run_id)
if capture is not None:
if capture[0] != "conflicting" and (
tuple(capture) != header
or _references(conn, run_id) != prepared
):
conn.execute(
"UPDATE execution_output_capture SET "
"state='conflicting',reason='conflicting' WHERE run_id=?",
(run_id,),
)
return True
conn.execute(
"INSERT INTO execution_output_capture "
"(run_id,state,reason,format_version,source) VALUES(?,?,?,?,?)",
(run_id, *header),
)
conn.executemany(
f"INSERT INTO execution_run_outputs (run_id,{_REFERENCE_COLUMNS}) "
"VALUES(?,?,?,?,?,?,?,?)",
[(run_id, *reference) for reference in prepared],
)
return True
def get_execution_outputs(self, run_id: str) -> dict:
"""Read frozen evidence in one snapshot without resolving its locators."""
conn = self.model.get_connection()
own_transaction = not conn.in_transaction
if own_transaction:
conn.execute("BEGIN")
try:
capture = _capture(conn, run_id)
if capture is None:
return {
"state": "not_captured",
"reason": None,
"format_version": None,
"source": None,
"references": [],
}
references = []
for row in _references(conn, run_id):
reference = dict(zip(_REFERENCE_FIELDS, row))
reference.update(
file_identity="unverified",
newly_generated="unknown",
context_membership="unknown",
transient=reference["folder_type"] == "temp",
)
references.append(reference)
return {
"state": capture[0],
"reason": capture[1],
"format_version": capture[2],
"source": capture[3],
"references": references,
}
finally:
if own_transaction:
conn.rollback()
-127
View File
@@ -1,127 +0,0 @@
"""Bounded exact-path saved display within the caller's history snapshot."""
import os
import sqlite3
try:
from ..utils.inspection_response import MAX_LIBRARY_BYTES, _library_projection
except ImportError:
from utils.inspection_response import MAX_LIBRARY_BYTES, _library_projection
def _display_bytes(value: bytes | None) -> str | None:
if value is None:
return None
return value.decode("utf-8", "replace")
def _tags(
conn: sqlite3.Connection, owner: int, *, pair: bool
) -> tuple[list[str], bool]:
table, column = (
("prompt_pair_tags", "pair_id") if pair else ("prompt_tags", "prompt_id")
)
tags = []
used = 0
cursor = conn.execute(
f"SELECT substr(CAST(t.name AS BLOB),1,?) FROM {table} pt "
f"JOIN tags t ON t.id=pt.tag_id WHERE pt.{column}=? LIMIT 129",
(MAX_LIBRARY_BYTES + 1, owner),
)
for row in cursor:
used += len(row[0])
if used > MAX_LIBRARY_BYTES or len(tags) == 128:
return [], True
tags.append(_display_bytes(row[0]))
return tags, False
def _saved_projection(conn: sqlite3.Connection, row: sqlite3.Row) -> tuple[dict, dict]:
saved = {"prompt_id": row["prompt_id"]}
pairs = list(
conn.execute(
"SELECT pp.id,pp.positive_prompt_id,"
"substr(CAST(pp.title AS BLOB),1,?) AS title,"
"substr(CAST(pp.category AS BLOB),1,?) AS category "
"FROM image_prompt_pairs ip JOIN prompt_pairs pp ON pp.id=ip.pair_id "
"WHERE ip.image_id=? LIMIT 2",
(MAX_LIBRARY_BYTES + 1, MAX_LIBRARY_BYTES + 1, row["id"]),
)
)
if len(pairs) > 1:
return saved, _library_projection({"status": "limited"})
pair = pairs[0] if pairs else None
title = pair["title"] if pair is not None else None
category = pair["category"] if pair is not None else row["category"]
tags, limited = _tags(
conn,
pair["id"] if pair is not None else row["prompt_id"],
pair=pair is not None,
)
limited = limited or any(
value is not None and len(value) > MAX_LIBRARY_BYTES
for value in (title, category)
)
if pair is not None:
saved["pair"] = {
"id": pair["id"],
"positive_prompt_id": pair["positive_prompt_id"],
}
library = _library_projection(
{
"status": "linked",
"positive_id": row["prompt_id"],
"pair_id": pair["id"] if pair is not None else None,
"title": _display_bytes(title),
"category": _display_bytes(category),
"tags": tags,
"metadata_scope": "pair" if pair is not None else "positive",
}
)
if limited:
library = _library_projection({"status": "limited", "tags": []})
return saved, library
def read_history_gallery(
conn: sqlite3.Connection, paths: tuple[str, ...]
) -> dict[str, dict]:
"""Read at most two rows/path and two pair associations/unique row; no writes."""
if not conn.in_transaction:
raise sqlite3.ProgrammingError("Saved history display requires caller snapshot")
if (
type(paths) is not tuple
or len(paths) > 100
or any(
type(path) is not str
or not os.path.isabs(path)
or path != os.path.normcase(os.path.normpath(path))
or "\0" in path
for path in paths
)
):
raise ValueError("Invalid history gallery paths")
result = {}
for path in dict.fromkeys(paths):
rows = list(
conn.execute(
"SELECT gi.id,gi.prompt_id,"
"substr(CAST(p.category AS BLOB),1,?) AS category "
"FROM generated_images gi JOIN prompts p ON p.id=gi.prompt_id "
"WHERE gi.file_path=? OR gi.image_path=? LIMIT 2",
(MAX_LIBRARY_BYTES + 1, path, path),
)
)
match = "none" if not rows else "unique" if len(rows) == 1 else "ambiguous"
saved, library = None, _library_projection(
{"status": "none" if not rows else "ambiguous"}
)
if match == "unique":
saved, library = _saved_projection(conn, rows[0])
result[path] = {
"gallery_match": match,
"gallery_image_id": rows[0]["id"] if match == "unique" else None,
"saved": saved,
"library": library,
}
return result
-527
View File
@@ -1,527 +0,0 @@
"""Snapshot-owned, bounded projections of immutable execution evidence."""
import codecs
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass
import hashlib
import json
import re
import sqlite3
from typing import Iterator
from .execution_contexts import content_digest
from .execution_history import HistoryEpochChanged
from .execution_outputs import _capture, _REFERENCE_COLUMNS
from .library import _ELIGIBLE
RunKey = tuple[int, str, int, str]
class HistoryCaptureChanged(ValueError):
"""A continuation no longer describes the stored output capture header."""
@dataclass(frozen=True)
class WindowPosition:
raw_text_id: str
upper: RunKey
before: RunKey
@dataclass(frozen=True)
class OutputPosition:
capture_fingerprint: str
after: tuple[str, int]
_DIGEST = re.compile(r"[0-9a-f]{64}")
_UUID = re.compile(r"[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}")
def _valid_digest(value: object) -> bool:
return type(value) is str and _DIGEST.fullmatch(value) is not None
def _valid_run_key(value: object) -> bool:
return (
type(value) is tuple
and len(value) == 4
and all(type(value[i]) is int and 0 <= value[i] <= 2**63 - 1 for i in (0, 2))
and all(
type(value[i]) is str and _UUID.fullmatch(value[i]) is not None
for i in (1, 3)
)
)
def _valid_coordinate(value: object) -> bool:
if type(value) is not tuple or len(value) != 2:
return False
node, ordinal = value
if not (
type(node) is str
and 1 <= len(node) <= 128
and "\0" not in node
and type(ordinal) is int
and 0 <= ordinal <= 255
):
return False
try:
return len(node.encode("utf-8")) <= 512
except UnicodeError:
return False
def _validate_window_position(position: WindowPosition | None) -> None:
if position is not None and not (
type(position) is WindowPosition
and _valid_digest(position.raw_text_id)
and _valid_run_key(position.upper)
and _valid_run_key(position.before)
):
raise ValueError("Invalid history window position")
def _validate_output_position(position: OutputPosition | None) -> None:
if position is not None and not (
type(position) is OutputPosition
and _valid_digest(position.capture_fingerprint)
and _valid_coordinate(position.after)
):
raise ValueError("Invalid history output position")
_snapshots: ContextVar[tuple] = ContextVar("history_read_snapshots", default=())
_KEY = "(r.observed_dequeue_at,r.session_id,r.queue_item_id,r.run_id)"
_ORDER = (
"r.observed_dequeue_at DESC,r.session_id COLLATE BINARY DESC,"
"r.queue_item_id DESC,r.run_id COLLATE BINARY DESC"
)
_MATCHING = (
"SELECT DISTINCT rc.run_id FROM execution_prompt_snapshots s "
"JOIN execution_contexts c ON c.positive_snapshot_id=s.snapshot_id "
"JOIN execution_run_contexts rc ON rc.context_id=c.context_id "
"WHERE s.raw_text_id=?"
)
def _limit(limit: int) -> None:
if type(limit) is not int or not 1 <= limit <= 100:
raise ValueError("History limit must be an integer from 1 to 100")
def _key(row: dict) -> RunKey:
return tuple(
row[name]
for name in ("observed_dequeue_at", "session_id", "queue_item_id", "run_id")
)
def _context_capture(conn: sqlite3.Connection, run_id: str) -> dict:
row = conn.execute(
"SELECT state,reason,format_version FROM execution_context_capture "
"WHERE run_id=?",
(run_id,),
).fetchone()
return (
dict(row)
if row is not None
else {"state": "not_captured", "reason": "missing", "format_version": None}
)
def _output_header(conn: sqlite3.Connection, run_id: str) -> tuple[dict, int | None]:
row = _capture(conn, run_id)
if row is None:
return (
dict(state="not_captured", reason=None, format_version=None, source=None),
None,
)
count = conn.execute(
"SELECT COUNT(*) FROM execution_run_outputs WHERE run_id=?", (run_id,)
).fetchone()[0]
return dict(row), count
def _list_status(capture: dict) -> str:
if capture["state"] == "not_captured":
return "not_captured"
return "complete" if capture["state"] == "complete" else "incomplete"
def _output_summary(conn: sqlite3.Connection, run_id: str) -> dict:
capture, count = _output_header(conn, run_id)
return dict(
capture, retained_occurrence_count=count, list_status=_list_status(capture)
)
def _fingerprint(capture: dict, count: int | None) -> str:
return hashlib.sha256(
json.dumps(
[
1,
*(capture[k] for k in ("state", "reason", "format_version", "source")),
count,
],
ensure_ascii=False,
allow_nan=False,
separators=(",", ":"),
).encode("utf-8")
).hexdigest()
def _receipt(run_id: str, row: sqlite3.Row) -> dict:
return {
"occurrence": {
"run_id": run_id,
"output_node_id": row["output_node_id"],
"image_ordinal": row["image_ordinal"],
},
"reference": {
key: row[key]
for key in (
"folder_type",
"subfolder",
"filename",
"path_style",
"root_key",
)
},
"file_identity": "unverified",
"newly_generated": "unknown",
"context_membership": "unknown",
"transient": row["folder_type"] == "temp",
}
class HistoryReadOperations:
"""Read evidence only inside an explicitly owned, epoch-first snapshot."""
@contextmanager
def recorded_history_snapshot(
self, expected_epoch: str | None = None
) -> Iterator[str]:
conn = self.model.get_connection()
if conn.in_transaction:
raise sqlite3.ProgrammingError(
"History reads require their own transaction"
)
conn.execute("BEGIN")
token = _snapshots.set((*_snapshots.get(), conn))
try:
epoch = self.get_execution_epoch()
if expected_epoch is not None and epoch != expected_epoch:
raise HistoryEpochChanged(epoch)
yield epoch
finally:
try:
conn.rollback()
finally:
_snapshots.reset(token)
def _history_connection(self) -> sqlite3.Connection:
conn = self.model.get_connection()
if not conn.in_transaction or conn not in _snapshots.get():
raise sqlite3.ProgrammingError(
"History read requires recorded_history_snapshot"
)
return conn
def list_history_window(
self,
positive_id: int,
*,
limit: int = 40,
after: WindowPosition | None = None,
restart: bool = False,
) -> dict | None:
conn = self._history_connection()
_limit(limit)
_validate_window_position(after)
if type(restart) is not bool or (
restart and (after is None or after.before != after.upper)
):
raise ValueError("Invalid history restart position")
saved = conn.execute(
"SELECT CASE WHEN typeof(p.text)='text' AND "
"length(CAST(p.text AS BLOB))<=65536 THEN p.text ELSE NULL END AS text "
f"FROM prompts p WHERE p.id=? AND ({_ELIGIBLE})",
(positive_id,),
).fetchone()
if saved is None:
return None
text = saved["text"]
raw_id = None if text is None else content_digest("text", (text,))
if after is not None and after.raw_text_id != raw_id:
raise ValueError("History position text scope changed")
group = {
"positive_id": positive_id,
"raw_text_id": raw_id,
"relation": "exact_frozen_raw_text",
"scope_status": "uncapturable_text" if text is None else "no_matches",
}
result = dict(
group=group,
recorded_run_count=0,
items=[],
context_summaries=[],
has_more=False,
next_position=None,
resume_position=None,
)
if (
raw_id is None
or conn.execute(
"SELECT 1 FROM execution_context_texts WHERE text_id=? "
"AND text_value=? COLLATE BINARY",
(raw_id, text),
).fetchone()
is None
):
return result
where = f"r.run_id IN ({_MATCHING})"
params = [raw_id]
if after is not None:
where += f" AND {_KEY}<=(?,?,?,?)"
params.extend(after.upper)
count = conn.execute(
f"SELECT COUNT(*) FROM execution_runs r WHERE {where}", params
).fetchone()[0]
result["recorded_run_count"] = count
group["scope_status"] = "matched" if count else "no_matches"
if after is not None and not restart:
where += f" AND {_KEY}<(?,?,?,?)"
params.extend(after.before)
rows = [
dict(row)
for row in conn.execute(
f"SELECT r.* FROM execution_runs r WHERE {where} "
f"ORDER BY {_ORDER} LIMIT ?",
(*params, limit + 1),
)
]
more = len(rows) > limit
rows = rows[:limit]
if not rows:
return result
upper = after.upper if after is not None else _key(rows[0])
result["resume_position"] = WindowPosition(raw_id, upper, upper)
if more:
result["next_position"] = WindowPosition(raw_id, upper, _key(rows[-1]))
result["has_more"] = more
rows.reverse()
by_run = {row["run_id"]: row for row in rows}
for row in rows:
row["diagnostics_available"] = any(
row[field] is not None
for field in ("node_id", "node_type", "exception_type")
)
for field in ("node_id", "node_type", "exception_type"):
del row[field]
row["diagnostics_omitted"] = bool(row["diagnostics_omitted"])
row["context_capture"] = _context_capture(conn, row["run_id"])
row["output_summary"] = _output_summary(conn, row["run_id"])
row["matching_anchors"] = []
placeholders = ",".join("?" for _ in rows)
anchors = conn.execute(
"SELECT rc.run_id,rc.anchor_id,rc.context_id,rc.evidence_json,"
"c.positive_snapshot_id,c.negative_snapshot_id,"
"substr(CAST(t.text_value AS BLOB),1,512) AS prefix,"
"length(CAST(t.text_value AS BLOB)) AS byte_length "
"FROM execution_run_contexts rc JOIN execution_contexts c "
"ON c.context_id=rc.context_id JOIN execution_prompt_snapshots p "
"ON p.snapshot_id=c.positive_snapshot_id "
"LEFT JOIN execution_prompt_snapshots n "
"ON n.snapshot_id=c.negative_snapshot_id "
"LEFT JOIN execution_context_texts t ON t.text_id=n.raw_text_id "
f"WHERE rc.run_id IN ({placeholders}) AND p.raw_text_id=? "
"ORDER BY rc.anchor_id COLLATE BINARY,rc.run_id COLLATE BINARY",
(*by_run, raw_id),
)
summaries = {}
evidence_by_context = {}
for anchor in anchors:
reference = {
key: anchor[key]
for key in (
"anchor_id",
"context_id",
"positive_snapshot_id",
"negative_snapshot_id",
)
}
by_run[anchor["run_id"]]["matching_anchors"].append(reference)
context_id = anchor["context_id"]
if context_id not in summaries:
prefix = b"" if anchor["byte_length"] == 0 else anchor["prefix"]
preview = (
None
if prefix is None
else codecs.getincrementaldecoder("utf-8")().decode(
prefix, final=False
)[:100]
)
evidence_by_context[context_id] = []
summaries[context_id] = {
key: anchor[key]
for key in (
"context_id",
"positive_snapshot_id",
"negative_snapshot_id",
)
}
summaries[context_id]["negative_raw"] = {
"state": (
"unknown"
if preview is None
else "explicit_empty" if not preview else "known"
),
"preview": preview,
"truncated": (
False
if preview is None
else len(preview.encode("utf-8")) < anchor["byte_length"]
),
"evidence": evidence_by_context[context_id],
}
negative = json.loads(anchor["evidence_json"])["negative"]
evidence = (
negative["raw"]
if negative is not None
else dict(provenance="unknown", source="unknown", reason="missing")
)
evidence_by_context[context_id].append(
{
"run_id": anchor["run_id"],
"anchor_id": anchor["anchor_id"],
**evidence,
}
)
# Context order follows its first chronological matching run, then anchor.
ordered = {}
for row in rows:
for anchor in row["matching_anchors"]:
ordered.setdefault(
anchor["context_id"], summaries[anchor["context_id"]]
)
result["items"] = rows
result["context_summaries"] = list(ordered.values())
return result
def get_history_run(self, run_id: str) -> dict | None:
conn = self._history_connection()
run = self.get_execution_run(run_id)
if run is None:
return None
capture = self.get_execution_contexts(run_id)
snapshots = {
row["anchor_id"]: row
for row in conn.execute(
"SELECT rc.anchor_id,c.positive_snapshot_id,c.negative_snapshot_id,"
"p.raw_text_id AS positive_raw_text_id "
"FROM execution_run_contexts rc JOIN execution_contexts c "
"ON c.context_id=rc.context_id LEFT JOIN execution_prompt_snapshots p "
"ON p.snapshot_id=c.positive_snapshot_id WHERE rc.run_id=?",
(run_id,),
)
}
contexts = capture.pop("contexts")
for context in contexts:
row = snapshots[context["anchor_id"]]
context.update(
{
key: row[key]
for key in (
"positive_snapshot_id",
"negative_snapshot_id",
"positive_raw_text_id",
)
}
)
pair = context["pair_id"]
context["navigation"] = {
"positive_available": context["positive_prompt_id"] is not None
and conn.execute(
f"SELECT 1 FROM prompts p WHERE p.id=? AND ({_ELIGIBLE})",
(context["positive_prompt_id"],),
).fetchone()
is not None,
"negative_available": context["negative_prompt_id"] is not None,
"pair_available": pair is not None
and conn.execute(
"SELECT 1 FROM prompt_pairs WHERE id=? AND archived=0", (pair,)
).fetchone()
is not None,
}
return dict(
run=run,
context_capture=capture,
contexts=contexts,
output_summary=_output_summary(conn, run_id),
)
def list_history_outputs(
self,
run_id: str,
*,
limit: int = 40,
after: OutputPosition | None = None,
) -> dict | None:
conn = self._history_connection()
_limit(limit)
_validate_output_position(after)
# Validate header/orphans even when the requested parent does not exist.
capture, count = _output_header(conn, run_id)
if self.get_execution_run(run_id) is None:
return None
fingerprint = _fingerprint(capture, count)
if after is not None and after.capture_fingerprint != fingerprint:
raise HistoryCaptureChanged("History output capture changed")
where = "run_id=?"
params = [run_id]
if after is not None:
where += " AND (output_node_id COLLATE BINARY,image_ordinal)>(?,?)"
params.extend(after.after)
rows = list(
conn.execute(
f"SELECT {_REFERENCE_COLUMNS} FROM execution_run_outputs WHERE {where} "
"ORDER BY output_node_id COLLATE BINARY,image_ordinal LIMIT ?",
(*params, limit + 1),
)
)
more = len(rows) > limit
rows = rows[:limit]
position = (
OutputPosition(
fingerprint, (rows[-1]["output_node_id"], rows[-1]["image_ordinal"])
)
if more
else None
)
return dict(
capture=capture,
retained_occurrence_count=count,
list_status=_list_status(capture),
capture_fingerprint=fingerprint,
items=[_receipt(run_id, row) for row in rows],
has_more=more,
next_position=position,
)
def get_history_occurrence(
self, run_id: str, node: str, ordinal: int
) -> dict | None:
conn = self._history_connection()
capture, _ = _output_header(conn, run_id)
row = conn.execute(
f"SELECT {_REFERENCE_COLUMNS} FROM execution_run_outputs "
"WHERE run_id=? AND output_node_id=? COLLATE BINARY AND image_ordinal=?",
(run_id, node, ordinal),
).fetchone()
return (
dict(capture=capture, receipt=_receipt(run_id, row))
if row is not None
else None
)
-321
View File
@@ -1,321 +0,0 @@
"""Read-only positive-parent views over existing prompt and pair tables."""
_ELIGIBLE = """(EXISTS(SELECT 1 FROM prompt_pairs ep WHERE ep.positive_prompt_id=p.id)
OR EXISTS(SELECT 1 FROM generated_images eg WHERE eg.prompt_id=p.id)
OR NOT EXISTS(SELECT 1 FROM prompt_pairs en WHERE en.negative_prompt_id=p.id))"""
_ORDERS = {
"newest": ("COALESCE(julianday(p.created_at),0)", "DESC"),
"oldest": ("COALESCE(julianday(p.created_at),0)", "ASC"),
"name": ("LOWER(p.text)", "ASC"),
"rating": ("COALESCE(p.rating,0)", "DESC"),
}
def _escape(value: str) -> str:
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def _limit(value: int) -> None:
if type(value) is not int or not 1 <= value <= 200:
raise ValueError("limit must be between 1 and 200")
def _filter(text: str, category: str | None, tags: list[str]) -> tuple[str, list]:
if not isinstance(text, str) or len(text) > 500:
raise ValueError("Search text must be a string of at most 500 characters")
if category is not None and (not isinstance(category, str) or len(category) > 200):
raise ValueError("Invalid library category")
if (
not isinstance(tags, list)
or len(tags) > 30
or any(not isinstance(tag, str) or not tag or len(tag) > 200 for tag in tags)
):
raise ValueError("Invalid library tags")
parent, pair, parent_params, pair_params = [], ["pp.archived=0"], [], []
if text:
parent.append("p.text LIKE ? ESCAPE '\\'")
parent_params.append(f"%{_escape(text)}%")
pair.append(
"(p.text LIKE ? ESCAPE '\\' OR n.text LIKE ? ESCAPE '\\' "
"OR pp.title LIKE ? ESCAPE '\\')"
)
pair_params.extend([f"%{_escape(text)}%"] * 3)
if category is not None:
parent.append("p.category=?")
pair.append("pp.category=?")
parent_params.append(category)
pair_params.append(category)
for tag in tags:
parent.append(
"EXISTS(SELECT 1 FROM prompt_tags pt JOIN tags t ON t.id=pt.tag_id "
"WHERE pt.prompt_id=p.id AND t.name=?)"
)
pair.append(
"EXISTS(SELECT 1 FROM prompt_pair_tags pt JOIN tags t ON t.id=pt.tag_id "
"WHERE pt.pair_id=pp.id AND t.name=?)"
)
parent_params.append(tag)
pair_params.append(tag)
condition = (
"("
+ (" AND ".join(parent) or "1=1")
+ (
") OR EXISTS(SELECT 1 FROM prompt_pairs pp "
"LEFT JOIN prompts n ON n.id=pp.negative_prompt_id "
"WHERE pp.positive_prompt_id=p.id AND "
)
+ " AND ".join(pair)
+ ")"
)
return f"({_ELIGIBLE}) AND ({condition})", [*parent_params, *pair_params]
class LibraryOperations:
"""Group reusable negative variants without changing legacy prompt queries."""
def _library_parents(
self,
where: str,
params: list,
suffix: str = "",
sort_expression: str | None = None,
) -> list[dict]:
conn = self.model.get_connection()
rows = conn.execute(
"SELECT p.id,p.text,p.category,p.rating,p.created_at,"
+ (f"{sort_expression} AS _sort," if sort_expression else "")
+ "(SELECT COUNT(*) FROM prompt_pairs v WHERE v.positive_prompt_id=p.id "
"AND v.archived=0) AS variant_count,"
"(SELECT COUNT(*) FROM generated_images g "
"WHERE g.prompt_id=p.id) AS image_count "
"FROM prompts p WHERE " + where + suffix,
params,
).fetchall()
result = [{**dict(row), "tags": []} for row in rows]
if result:
by_id = {row["id"]: row for row in result}
marks = ",".join("?" for _ in result)
for row in conn.execute(
"SELECT pt.prompt_id,t.name FROM prompt_tags pt "
"JOIN tags t ON t.id=pt.tag_id "
f"WHERE pt.prompt_id IN ({marks}) ORDER BY t.name",
list(by_id),
):
by_id[row["prompt_id"]]["tags"].append(row["name"])
return result
def list_library(
self,
*,
text: str = "",
category: str | None = None,
tags: list[str] | None = None,
sort: str = "newest",
limit: int = 40,
after: dict | None = None,
) -> dict:
_limit(limit)
if sort not in _ORDERS:
raise ValueError("Unknown library sort order")
where, params = _filter(text, category, [] if tags is None else tags)
conn = self.model.get_connection()
ceiling = conn.execute("SELECT COALESCE(MAX(id),0) FROM prompts").fetchone()[0]
if after is not None:
if (
not isinstance(after, dict)
or set(after) != {"ceiling", "value", "id"}
or type(after["ceiling"]) is not int
or type(after["id"]) is not int
):
raise ValueError("Invalid library cursor")
ceiling = after["ceiling"]
where += " AND p.id<=?"
params.append(ceiling)
total = conn.execute(
"SELECT COUNT(*) FROM prompts p WHERE " + where, params
).fetchone()[0]
expression, direction = _ORDERS[sort]
if after is not None:
operator = ">" if direction == "ASC" else "<"
where += (
f" AND ({expression}{operator}? OR "
f"({expression}=? AND p.id{operator}?))"
)
params.extend([after["value"], after["value"], after["id"]])
rows = self._library_parents(
where,
[*params, limit + 1],
f" ORDER BY {expression} {direction},p.id {direction} LIMIT ?",
sort_expression=expression,
)
has_more = len(rows) > limit
rows = rows[:limit]
next_key = None
if has_more:
next_key = {
"ceiling": ceiling,
"value": rows[-1]["_sort"],
"id": rows[-1]["id"],
}
for row in rows:
row.pop("_sort")
return {
"items": rows,
"total": total,
"has_more": has_more,
"next_key": next_key,
}
def get_library_positive(
self,
positive_id: int,
*,
variant_limit: int = 40,
variant_after: int | None = None,
) -> dict | None:
_limit(variant_limit)
if variant_after is not None and (
type(variant_after) is not int or variant_after <= 0
):
raise ValueError("Invalid variant cursor")
rows = self._library_parents(_ELIGIBLE + " AND p.id=?", [positive_id])
if not rows:
return None
conn = self.model.get_connection()
where = "pp.positive_prompt_id=? AND pp.archived=0"
params = [positive_id]
variant_total = conn.execute(
"SELECT COUNT(*) FROM prompt_pairs pp WHERE " + where, params
).fetchone()[0]
if variant_after is not None:
where += " AND pp.id<?"
params.append(variant_after)
variants = [
dict(row)
for row in conn.execute(
"SELECT pp.id,COALESCE(n.text,'') AS negative_text,"
"pp.category,pp.title,"
"pp.negative_prompt_id IS NULL AS explicit_empty,"
"(SELECT COUNT(DISTINCT image_id) FROM image_prompt_pairs ip "
"WHERE ip.pair_id=pp.id) AS image_count "
"FROM prompt_pairs pp "
"LEFT JOIN prompts n ON n.id=pp.negative_prompt_id "
"WHERE " + where + " ORDER BY pp.id DESC LIMIT ?",
[*params, variant_limit + 1],
)
]
variants_has_more = len(variants) > variant_limit
variants = variants[:variant_limit]
by_id = {row["id"]: row for row in variants}
for row in variants:
row["tags"] = []
row["explicit_empty"] = bool(row["explicit_empty"])
if by_id:
marks = ",".join("?" for _ in by_id)
for row in conn.execute(
"SELECT pt.pair_id,t.name FROM prompt_pair_tags pt "
"JOIN tags t ON t.id=pt.tag_id "
f"WHERE pt.pair_id IN ({marks}) ORDER BY t.name",
list(by_id),
):
by_id[row["pair_id"]]["tags"].append(row["name"])
unassigned = conn.execute(
"SELECT COUNT(*) FROM generated_images g WHERE g.prompt_id=? "
"AND NOT EXISTS(SELECT 1 FROM image_prompt_pairs ip "
"WHERE ip.image_id=g.id)",
(positive_id,),
).fetchone()[0]
return {
"positive": rows[0],
"variants": variants,
"unassigned_image_count": unassigned,
"variant_total": variant_total,
"variants_has_more": variants_has_more,
"variants_next_key": variants[-1]["id"] if variants_has_more else None,
}
def get_library_options(self) -> dict:
rows = (
self.model.get_connection()
.execute(
"SELECT category FROM (SELECT p.category AS category "
"FROM prompts p WHERE "
+ _ELIGIBLE
+ (" UNION SELECT category FROM prompt_pairs " "WHERE archived=0) ")
+ "WHERE category IS NOT NULL AND TRIM(category)!='' "
"ORDER BY category COLLATE NOCASE,category"
)
.fetchall()
)
return {"categories": [row["category"] for row in rows]}
def get_library_previews(self, positive_ids: list[int]) -> dict[int, list[dict]]:
"""Read at most two links per parent in one bounded page query."""
if (
not isinstance(positive_ids, list)
or len(positive_ids) > 200
or any(type(value) is not int or value <= 0 for value in positive_ids)
):
raise ValueError("Invalid preview parent IDs")
if not positive_ids:
return {}
result = {value: [] for value in positive_ids}
marks = ",".join("?" for _ in positive_ids)
rows = self.model.get_connection().execute(
"WITH ranked AS (SELECT id,prompt_id,image_path,ROW_NUMBER() OVER "
"(PARTITION BY prompt_id ORDER BY id DESC) AS position "
f"FROM generated_images WHERE prompt_id IN ({marks})) "
"SELECT id,prompt_id,image_path FROM ranked WHERE position<=2 "
"ORDER BY prompt_id,id DESC",
positive_ids,
)
for row in rows:
result[row["prompt_id"]].append(dict(row))
return result
def list_library_images(
self,
positive_id: int,
*,
pair_id: int | None = None,
limit: int = 40,
after: int | None = None,
) -> dict:
_limit(limit)
conn = self.model.get_connection()
if (
pair_id is not None
and not conn.execute(
"SELECT 1 FROM prompt_pairs WHERE id=? "
"AND positive_prompt_id=? AND archived=0",
(pair_id, positive_id),
).fetchone()
):
raise ValueError("Variant does not belong to this positive prompt")
where, params = "g.prompt_id=?", [positive_id]
if pair_id is not None:
where += (
" AND EXISTS(SELECT 1 FROM image_prompt_pairs ip "
"WHERE ip.image_id=g.id AND ip.pair_id=?)"
)
params.append(pair_id)
total = conn.execute(
"SELECT COUNT(*) FROM generated_images g WHERE " + where, params
).fetchone()[0]
if after is not None:
if type(after) is not int or after <= 0:
raise ValueError("Invalid image cursor")
where += " AND g.id<?"
params.append(after)
rows = conn.execute(
"SELECT g.id,g.image_path FROM generated_images g WHERE "
+ where
+ " ORDER BY g.id DESC LIMIT ?",
[*params, limit + 1],
).fetchall()
return {
"images": [dict(row) for row in rows[:limit]],
"total": total,
"has_more": len(rows) > limit,
"next_key": rows[limit - 1]["id"] if len(rows) > limit else None,
}
+41 -336
View File
@@ -4,22 +4,14 @@ Database schema and models for KikoTextEncode prompt storage.
import contextlib
import datetime
import json
import math
import shutil
import sqlite3
import os
import threading
import tempfile
import weakref
from contextlib import closing
from pathlib import Path
from typing import Optional, Set, Tuple
from .execution_history_schema import (
history_schema_error,
migrate_execution_history,
rotate_history_epoch,
)
from typing import Set, Tuple
# Import logging system
try:
@@ -37,41 +29,6 @@ except ImportError:
ISO_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%f+00:00"
MAX_PAIR_PROVENANCE_LENGTH = 4_000_000
MAX_PAIR_PROVENANCE_DEPTH = 64
def pair_provenance_error(encoded: object) -> Optional[str]:
"""Validate the same bounded JSON object for live writes and restores."""
if not isinstance(encoded, str):
return "Pair provenance must be JSON text"
if len(encoded) > MAX_PAIR_PROVENANCE_LENGTH:
return "Pair provenance exceeds the size limit"
try:
value = json.loads(encoded)
except (ValueError, TypeError, RecursionError):
return "Pair provenance is not readable JSON"
if not isinstance(value, dict):
return "Pair provenance must be a JSON object"
pending = [(iter((value,)), 0)]
while pending:
children, depth = pending[-1]
try:
item = next(children)
except StopIteration:
pending.pop()
continue
if depth > MAX_PAIR_PROVENANCE_DEPTH:
return "Pair provenance exceeds the nesting limit"
if isinstance(item, float) and not math.isfinite(item):
return "Pair provenance contains nonfinite numbers"
if isinstance(item, dict):
pending.append((iter(item.values()), depth + 1))
elif isinstance(item, list):
pending.append((iter(item), depth + 1))
return None
def utc_now_iso() -> str:
"""Current UTC time as ISO text with microseconds.
@@ -124,7 +81,7 @@ class PromptModel:
self.logger.debug(f"Initializing database model with path: {db_path}")
self.db_path = db_path
# One connection per thread (see get_connection); every open connection
# is also tracked here so close_all can clean up across threads.
# is also tracked here so restore_from_file can close them all.
self._local = threading.local()
self._connections: Set[sqlite3.Connection] = set()
self._conn_lock = threading.Lock()
@@ -278,225 +235,6 @@ class PromptModel:
# Image uniqueness by full path instead of basename (3.2.4)
self._migrate_image_uniqueness_by_path(conn)
self._migrate_prompt_pairs(conn)
migrate_execution_history(conn)
def _migrate_prompt_pairs(self, conn: sqlite3.Connection) -> None:
conn.execute("SAVEPOINT prompt_pair_schema")
try:
conn.execute("""
CREATE TABLE IF NOT EXISTS prompt_pairs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
positive_prompt_id INTEGER NOT NULL,
negative_prompt_id INTEGER,
title TEXT,
category TEXT,
rating INTEGER CHECK(rating BETWEEN 1 AND 5),
notes TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_used_at TEXT,
run_count INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0 CHECK(archived IN (0, 1)),
FOREIGN KEY(positive_prompt_id)
REFERENCES prompts(id) ON DELETE RESTRICT,
FOREIGN KEY(negative_prompt_id)
REFERENCES prompts(id) ON DELETE RESTRICT,
UNIQUE(positive_prompt_id, negative_prompt_id)
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS prompt_pair_tags (
pair_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
PRIMARY KEY(pair_id, tag_id),
FOREIGN KEY(pair_id) REFERENCES prompt_pairs(id) ON DELETE CASCADE,
FOREIGN KEY(tag_id) REFERENCES tags(id) ON DELETE CASCADE
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS image_prompt_pairs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
image_id INTEGER NOT NULL,
pair_id INTEGER NOT NULL,
provenance TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(image_id)
REFERENCES generated_images(id) ON DELETE CASCADE,
FOREIGN KEY(pair_id) REFERENCES prompt_pairs(id) ON DELETE RESTRICT,
UNIQUE(pair_id, image_id, provenance)
)
""")
self._create_pair_indexes(conn)
error = self._pair_schema_error(conn)
if error:
raise sqlite3.DatabaseError(error)
conn.execute("RELEASE prompt_pair_schema")
except BaseException:
conn.execute("ROLLBACK TO prompt_pair_schema")
conn.execute("RELEASE prompt_pair_schema")
raise
@staticmethod
def _create_pair_indexes(conn: sqlite3.Connection) -> None:
conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_prompt_pairs_positive_only "
"ON prompt_pairs(positive_prompt_id) WHERE negative_prompt_id IS NULL"
)
for name, target in (
("idx_prompt_pairs_negative", "prompt_pairs(negative_prompt_id)"),
("idx_prompt_pair_tags_tag", "prompt_pair_tags(tag_id)"),
("idx_image_prompt_pairs_image", "image_prompt_pairs(image_id)"),
):
conn.execute(f"CREATE INDEX IF NOT EXISTS {name} ON {target}")
@staticmethod
def _pair_schema_error(conn: sqlite3.Connection) -> Optional[str]:
required = {
"prompt_pairs": {
"id",
"positive_prompt_id",
"negative_prompt_id",
"title",
"category",
"rating",
"notes",
"created_at",
"updated_at",
"last_used_at",
"run_count",
"archived",
},
"prompt_pair_tags": {"pair_id", "tag_id"},
"image_prompt_pairs": {
"id",
"image_id",
"pair_id",
"provenance",
"created_at",
},
}
present = {
row[0]
for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
}
if not present.intersection(required):
return None
if not set(required).issubset(present):
return "Incomplete prompt pair schema"
foreign_keys = {
"prompt_pairs": {
("positive_prompt_id", "prompts", "id", "RESTRICT"),
("negative_prompt_id", "prompts", "id", "RESTRICT"),
},
"prompt_pair_tags": {
("pair_id", "prompt_pairs", "id", "CASCADE"),
("tag_id", "tags", "id", "CASCADE"),
},
"image_prompt_pairs": {
("pair_id", "prompt_pairs", "id", "RESTRICT"),
("image_id", "generated_images", "id", "CASCADE"),
},
}
unique = {
"prompt_pairs": ("positive_prompt_id", "negative_prompt_id"),
"prompt_pair_tags": ("pair_id", "tag_id"),
"image_prompt_pairs": ("pair_id", "image_id", "provenance"),
}
non_null = {
"prompt_pairs": {
"positive_prompt_id",
"created_at",
"updated_at",
"run_count",
"archived",
},
"prompt_pair_tags": {"pair_id", "tag_id"},
"image_prompt_pairs": {"pair_id", "image_id", "provenance", "created_at"},
}
integer_columns = {
"id",
"positive_prompt_id",
"negative_prompt_id",
"pair_id",
"tag_id",
"image_id",
"rating",
"run_count",
"archived",
}
for table, columns in required.items():
info = {row[1]: row for row in conn.execute(f"PRAGMA table_info({table})")}
if not columns.issubset(info):
return f"{table} is missing required pair columns"
if any(not info[name][3] for name in non_null[table]):
return f"{table} has nullable required pair columns"
if any(
info[name][2].upper()
!= ("INTEGER" if name in integer_columns else "TEXT")
for name in columns
):
return f"{table} has invalid pair column types"
primary = tuple(
name
for name in sorted(info, key=lambda name: info[name][5])
if info[name][5]
)
if primary != (
("pair_id", "tag_id") if table == "prompt_pair_tags" else ("id",)
):
return f"{table} has an invalid pair primary key"
actual_fks = {
(row[3], row[2], row[4], row[6])
for row in conn.execute(f"PRAGMA foreign_key_list({table})")
}
if actual_fks != foreign_keys[table]:
return f"{table} has invalid pair foreign key definitions"
if conn.execute(f"PRAGMA foreign_key_check({table})").fetchone():
return f"{table} contains foreign key violations"
indexes = list(conn.execute(f"PRAGMA index_list({table})"))
index_columns = {}
for row in indexes:
name = row[1].replace('"', '""')
index_columns[row[1]] = tuple(
item[2] for item in conn.execute(f'PRAGMA index_info("{name}")')
)
if not any(
row[2] and not row[4] and index_columns[row[1]] == unique[table]
for row in indexes
):
return f"{table} is missing pair uniqueness constraints"
if table == "prompt_pairs":
null_unique = False
for row in indexes:
if (
row[2]
and row[4]
and index_columns[row[1]] == ("positive_prompt_id",)
):
sql = conn.execute(
"SELECT sql FROM sqlite_master WHERE type='index' AND name"
"=?",
(row[1],),
).fetchone()[0]
if " ".join(sql.lower().split()).endswith(
"where negative_prompt_id is null"
):
null_unique = True
if not null_unique:
return "prompt_pairs is missing empty-negative pair uniqueness"
if conn.execute(
"SELECT 1 FROM image_prompt_pairs ip "
"JOIN prompt_pairs pp ON pp.id=ip.pair_id "
"JOIN generated_images gi ON gi.id=ip.image_id "
"WHERE pp.positive_prompt_id IS NOT gi.prompt_id LIMIT 1"
).fetchone():
return "Image pair association has a different positive prompt owner"
for row in conn.execute("SELECT provenance FROM image_prompt_pairs"):
error = pair_provenance_error(row[0])
if error:
return error
return None
def _create_indexes(self, conn: sqlite3.Connection) -> None:
"""
@@ -550,7 +288,7 @@ class PromptModel:
if conn is not None and self._local.generation == self._generation:
return conn
# check_same_thread=False only so close_all can clean up other
# check_same_thread=False only so restore_from_file can close other
# threads' connections; each connection is still used by one thread.
conn = sqlite3.connect(
self.db_path, check_same_thread=False, timeout=BUSY_TIMEOUT_SECONDS
@@ -1034,18 +772,6 @@ class PromptModel:
False,
"prompts table is missing required columns: " + ", ".join(missing),
)
forbidden = conn.execute(
"SELECT type FROM sqlite_master WHERE type IN ('trigger', 'view') "
"LIMIT 1"
).fetchone()
if forbidden:
return False, f"Database contains an unsupported {forbidden[0]}"
pair_error = PromptModel._pair_schema_error(conn)
if pair_error:
return False, pair_error
history_error = history_schema_error(conn)
if history_error:
return False, history_error
return True, "ok"
except sqlite3.Error as e:
return False, f"Invalid SQLite database: {e}"
@@ -1060,29 +786,41 @@ class PromptModel:
PromptModel instances that share the file (nodes, image monitor,
queue hook, API) simply see the new pages on their next statement.
Nothing is swapped underneath open handles and no -wal/-shm file has
to be deleted, which also keeps Windows happy. If SQLite refuses
replacement (for example, incompatible page sizes in WAL mode),
propagate the error rather than copy files under other open owners.
to be deleted, which also keeps Windows happy. When SQLite refuses
(page sizes differ while the target is in WAL mode) fall back to a
file copy with every connection of this instance closed first.
"""
source = sqlite3.connect(
Path(src_path).resolve().as_uri() + "?mode=ro", uri=True
)
try:
source.backup(self.get_connection())
try:
source.backup(self.get_connection())
return
except sqlite3.OperationalError as e:
self.logger.warning(
f"Online restore failed ({e}); falling back to a file copy"
)
finally:
source.close()
self.close_all()
shutil.copy2(src_path, self.db_path)
for suffix in ("-wal", "-shm"):
stale = self.db_path + suffix
if os.path.exists(stale):
os.remove(stale)
def restore_from_file(self, src_path: str) -> str:
"""
Replace the live database with the file at src_path.
A disposable SQLite backup of the source is migrated and verified first.
Only then is the current database backed up next to itself as
``<db_path>.backup_<YYYYmmdd_HHMMSS>`` (WAL content included), and the
staged pages replace the live pages through SQLite's backup API.
Migration failures never replace live content or modify the source.
An online replacement failure leaves the live database intact and
propagates; the completed safety backup remains available.
The current database is first backed up next to itself as
``<db_path>.backup_<YYYYmmdd_HHMMSS>`` (WAL content included), the
pages of ``src_path`` are copied into the live database through
SQLite's backup API (visible to every open connection) and the schema
migrations run on the restored content.
Callers should run verify_database_file(src_path) first.
Args:
src_path: SQLite file to restore
@@ -1093,52 +831,19 @@ class PromptModel:
Raises:
RuntimeError: If the previous database could not be backed up
sqlite3.OperationalError: If SQLite refuses online replacement
OSError: If the restored file could not be copied into place
"""
ok, reason = self.verify_database_file(src_path)
if not ok:
raise ValueError(reason)
with tempfile.TemporaryDirectory(prefix="prompt-manager-restore-") as directory:
staged_path = str(Path(directory) / "staged.db")
source = sqlite3.connect(
Path(src_path).resolve().as_uri() + "?mode=ro", uri=True
)
try:
with closing(sqlite3.connect(staged_path)) as destination:
source.backup(destination)
finally:
source.close()
staged = None
try:
staged = PromptModel(staged_path)
rotate_history_epoch(staged.get_connection())
ok, reason = self.verify_database_file(staged_path)
if not ok:
raise ValueError(reason)
staged.close_all()
backup_path = ""
if os.path.exists(self.db_path):
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
backup_path = f"{self.db_path}.backup_{timestamp}"
if not self.backup_database(backup_path):
raise RuntimeError("Could not back up the current database")
self.logger.info(f"Current database backed up to: {backup_path}")
try:
self._replace_contents_from(staged_path)
except sqlite3.OperationalError as exc:
exc._prompt_manager_restore_backup_created = bool(backup_path)
raise
with PromptModel._init_lock:
PromptModel._initialized_paths.add(self._schema_key())
self.logger.info(f"Database restored from {src_path}")
from .execution_history import notify_history_restore
backup_path = ""
if os.path.exists(self.db_path):
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
backup_path = f"{self.db_path}.backup_{timestamp}"
if not self.backup_database(backup_path):
raise RuntimeError("Could not back up the current database")
self.logger.info(f"Current database backed up to: {backup_path}")
notify_history_restore(self.db_path)
return backup_path
finally:
if staged is not None:
staged.close_all()
with PromptModel._init_lock:
PromptModel._initialized_paths.discard(
os.path.normcase(os.path.realpath(staged_path))
)
self._replace_contents_from(src_path)
with PromptModel._init_lock:
PromptModel._initialized_paths.discard(self._schema_key())
self._ensure_database_exists()
self.logger.info(f"Database restored from {src_path}")
return backup_path
+37 -155
View File
@@ -9,12 +9,6 @@ import os
from typing import Optional, List, Dict, Any
from .models import PromptModel, normalize_image_path, utc_now_iso
from .pairs import PairDependencyError, PairOperations
from .library import LibraryOperations
from .execution_history import ExecutionHistoryOperations
from .execution_contexts import ExecutionContextOperations
from .execution_outputs import ExecutionOutputOperations
from .history_reads import HistoryReadOperations
# Import logging system
try:
@@ -90,10 +84,7 @@ def _resolve_db_path(db_path: Optional[str] = None) -> str:
if db_path is None:
try:
try:
from ..py.config import PromptManagerConfig
except ImportError:
from py.config import PromptManagerConfig
from py.config import PromptManagerConfig
db_path = PromptManagerConfig.DEFAULT_DB_PATH
except Exception:
@@ -155,14 +146,7 @@ def _coerce_file_info(metadata: Any) -> tuple:
)
class PromptDatabase(
PairOperations,
LibraryOperations,
ExecutionHistoryOperations,
ExecutionContextOperations,
ExecutionOutputOperations,
HistoryReadOperations,
):
class PromptDatabase:
"""Database operations class for managing prompts."""
def __init__(self, db_path: Optional[str] = None):
@@ -597,8 +581,7 @@ class PromptDatabase(
Returns:
bool: True if deletion was successful, False otherwise
"""
with self._pair_transaction() as conn:
self._ensure_prompts_unpaired(conn, [prompt_id])
with self.model.get_connection() as conn:
# First delete related images to avoid foreign key constraint
conn.execute(
"DELETE FROM generated_images WHERE prompt_id = ?", (prompt_id,)
@@ -614,7 +597,7 @@ class PromptDatabase(
Returns:
Number of prompts deleted.
"""
with self._pair_transaction() as conn:
with self.model.get_connection() as conn:
# Get IDs first for cascade cleanup
ids = [
r[0]
@@ -624,7 +607,6 @@ class PromptDatabase(
]
if not ids:
return 0
self._ensure_prompts_unpaired(conn, ids)
placeholders = ",".join("?" * len(ids))
conn.execute(
f"DELETE FROM generated_images WHERE prompt_id IN ({placeholders})",
@@ -650,9 +632,7 @@ class PromptDatabase(
"""
with self.model.get_connection() as conn:
cursor = conn.execute(
"SELECT TRIM(category) AS category FROM prompts WHERE "
"category IS NOT NULL AND TRIM(category) != '' UNION "
"SELECT TRIM(category) AS category FROM prompt_pairs WHERE "
"SELECT DISTINCT TRIM(category) as category FROM prompts WHERE "
"category IS NOT NULL AND TRIM(category) != '' ORDER BY category"
)
return [row["category"] for row in cursor.fetchall()]
@@ -719,16 +699,16 @@ class PromptDatabase(
def get_all_tags(self) -> List[str]:
"""
Get all unique tags linked to a standalone prompt or reusable pair.
Get all unique tags that are in use (linked to at least one prompt).
Returns:
Sorted list of tag names
"""
with self.model.get_connection() as conn:
cursor = conn.execute(
"SELECT t.name AS tag FROM tags t WHERE "
"EXISTS(SELECT 1 FROM prompt_tags pt WHERE pt.tag_id=t.id) OR "
"EXISTS(SELECT 1 FROM prompt_pair_tags pp WHERE pp.tag_id=t.id) "
"SELECT DISTINCT t.name AS tag "
"FROM prompt_tags pt "
"JOIN tags t ON pt.tag_id = t.id "
"ORDER BY t.name"
)
return [row["tag"] for row in cursor.fetchall()]
@@ -804,8 +784,7 @@ class PromptDatabase(
Returns tags whose name starts with ``prefix`` (case-insensitive),
counted over the prompts that carry every tag in ``with_tags``; the
context tags themselves are left out. Counts include separate standalone
and pair references, with owner types kept distinct. Ordered by count/name.
context tags themselves are left out. Ordered by count, then name.
"""
seen = set()
context = []
@@ -820,11 +799,11 @@ class PromptDatabase(
if context:
placeholders = ",".join("?" * len(context))
where.append(
"(pt.kind,pt.owner_id) IN ("
" SELECT pt2.kind,pt2.owner_id FROM tag_refs pt2"
"pt.prompt_id IN ("
" SELECT pt2.prompt_id FROM prompt_tags pt2"
" JOIN tags t2 ON t2.id = pt2.tag_id"
f" WHERE t2.name COLLATE NOCASE IN ({placeholders})"
" GROUP BY pt2.kind,pt2.owner_id"
" GROUP BY pt2.prompt_id"
" HAVING COUNT(DISTINCT lower(t2.name)) = ?)"
)
params.extend(context)
@@ -834,11 +813,8 @@ class PromptDatabase(
params.append(max(1, int(limit)))
sql = (
"WITH tag_refs(kind,owner_id,tag_id) AS ("
"SELECT 'prompt',prompt_id,tag_id FROM prompt_tags UNION ALL "
"SELECT 'pair',pair_id,tag_id FROM prompt_pair_tags) "
"SELECT t.name AS name, COUNT(*) AS count"
" FROM tag_refs pt JOIN tags t ON t.id = pt.tag_id"
" FROM prompt_tags pt JOIN tags t ON t.id = pt.tag_id"
f" WHERE {' AND '.join(where)}"
" GROUP BY t.id"
" ORDER BY count DESC, t.name COLLATE NOCASE ASC"
@@ -945,8 +921,6 @@ class PromptDatabase(
old_name = old_name.strip()
new_name = new_name.strip()
if old_name == new_name:
return {"success": True, "affected_count": 0, "skipped_count": 0}
with self.model.get_connection() as conn:
cursor = conn.execute("SELECT id FROM tags WHERE name = ?", (old_name,))
@@ -968,14 +942,12 @@ class PromptDatabase(
if existing_new:
# Target tag exists — merge: move links, handle conflicts, delete old
new_tag_id = existing_new["id"]
for junction in ("prompt_tags", "prompt_pair_tags"):
conn.execute(
f"UPDATE OR IGNORE {junction} SET tag_id = ? WHERE tag_id = ?",
(new_tag_id, old_tag_id),
)
conn.execute(
f"DELETE FROM {junction} WHERE tag_id = ?", (old_tag_id,)
)
conn.execute(
"UPDATE OR IGNORE prompt_tags SET tag_id = ? WHERE tag_id = ?",
(new_tag_id, old_tag_id),
)
# Delete remaining links (conflicts = prompt already had the target tag)
conn.execute("DELETE FROM prompt_tags WHERE tag_id = ?", (old_tag_id,))
conn.execute("DELETE FROM tags WHERE id = ?", (old_tag_id,))
else:
# Simple rename
@@ -994,7 +966,7 @@ class PromptDatabase(
"""
Remove a tag from all prompts that use it.
Deleting from tags cascades to standalone and pair tag relationships.
With junction tables, deleting from the tags table cascades to prompt_tags.
Args:
tag_name: Tag name to remove
@@ -1061,27 +1033,20 @@ class PromptDatabase(
if not src_row:
continue
src_id = src_row["id"]
if src_id == target_id:
continue
cursor = conn.execute(
"SELECT COUNT(*) as c FROM prompt_tags WHERE tag_id = ?", (src_id,)
)
src_count = cursor.fetchone()["c"]
pair_count = conn.execute(
"SELECT COUNT(*) FROM prompt_pair_tags WHERE tag_id = ?", (src_id,)
).fetchone()[0]
if src_count or pair_count:
for junction in ("prompt_tags", "prompt_pair_tags"):
conn.execute(
f"UPDATE OR IGNORE {junction} "
"SET tag_id = ? WHERE tag_id = ?",
(target_id, src_id),
)
conn.execute(
f"DELETE FROM {junction} WHERE tag_id = ?", (src_id,)
)
if src_count > 0:
# Move links from source to target (ignore conflicts)
conn.execute(
"UPDATE OR IGNORE prompt_tags SET tag_id = ? WHERE tag_id = ?",
(target_id, src_id),
)
# Delete remaining (conflicts = prompt already had target tag)
conn.execute("DELETE FROM prompt_tags WHERE tag_id = ?", (src_id,))
tags_merged += 1
affected += src_count
@@ -1325,7 +1290,7 @@ class PromptDatabase(
"""
self.logger.info("Starting duplicate cleanup process with image preservation")
try:
with self._pair_transaction() as conn:
with self.model.get_connection() as conn:
# Find duplicates by text content (case-insensitive)
# Note: Removed ORDER BY from GROUP_CONCAT for SQLite compatibility
# We'll sort the IDs manually after fetching
@@ -1354,11 +1319,6 @@ class PromptDatabase(
id_date_pairs.sort(key=lambda x: x[1]) # Sort by date
sorted_ids = [int(pair[0]) for pair in id_date_pairs]
try:
self._ensure_prompts_unpaired(conn, sorted_ids)
except PairDependencyError:
continue
# Keep the oldest one (first), merge and delete the rest
primary_id = sorted_ids[0] # Keep the oldest
duplicate_ids = sorted_ids[1:]
@@ -1904,8 +1864,7 @@ class PromptDatabase(
Raises:
sqlite3.IntegrityError: Another prompt already has the new hash.
"""
with self._pair_transaction() as conn:
self._ensure_prompts_unpaired(conn, [prompt_id])
with self.model.get_connection() as conn:
cursor = conn.execute(
"UPDATE prompts SET text = ?, hash = ?, updated_at = ? WHERE id = ?",
(
@@ -1953,8 +1912,7 @@ class PromptDatabase(
Number of prompts actually deleted.
"""
count = 0
with self._pair_transaction() as conn:
self._ensure_prompts_unpaired(conn, prompt_ids)
with self.model.get_connection() as conn:
for pid in prompt_ids:
conn.execute("DELETE FROM generated_images WHERE prompt_id = ?", (pid,))
cursor = conn.execute("DELETE FROM prompts WHERE id = ?", (pid,))
@@ -2016,24 +1974,18 @@ class PromptDatabase(
return count
def get_image_prompt_info(
self,
image_path: str,
allow_filename_fallback: bool = True,
*,
report_ambiguity: bool = False,
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, parameters — or None.
Opting into report_ambiguity returns only an association_status marker
for multiple matches; default callers still receive None.
"""
with self.model.get_connection() as conn:
cursor = conn.execute(
"""SELECT gi.prompt_id, p.text, p.category, p.rating, p.notes,
gi.workflow_data, gi.prompt_metadata, gi.generation_time,
gi.parameters, gi.id, gi.image_path, gi.file_path
gi.parameters
FROM generated_images gi
JOIN prompts p ON gi.prompt_id = p.id
WHERE gi.image_path = ? OR gi.file_path = ?
@@ -2045,73 +1997,9 @@ class PromptDatabase(
f"%{escape_like(os.path.basename(image_path))}",
),
)
rows = cursor.fetchall()
exact = [
row
for row in rows
if row[10] == image_path or row[11] == normalize_image_path(image_path)
]
matches = exact or rows
if len(matches) != 1:
if report_ambiguity and matches:
return {"association_status": "ambiguous"}
row = cursor.fetchone()
if not row:
return None
row = matches[0]
associations = self.get_image_prompt_pairs(row[9])
pair_ids = {item["pair_id"] for item in associations}
pair = (
self.get_prompt_pair(next(iter(pair_ids)))
if len(pair_ids) == 1
else None
)
candidates = []
for item in associations:
evidence = item["provenance"]
positive_side = evidence.get("positive")
negative_side = evidence.get("negative")
if not isinstance(positive_side, dict) or not isinstance(
negative_side, dict
):
continue
positive, negative = positive_side.get("text"), negative_side.get(
"text"
)
if not isinstance(positive, str) or not isinstance(negative, str):
continue
sources = []
for side in (positive_side, negative_side):
provenance = side.get("provenance")
sources.append(
provenance.get("text_source")
if isinstance(provenance, dict)
else None
)
score = sources.count("execution")
source = (
"execution"
if score == 2
else (
"mixed"
if score
else (
"embedded-parameters"
if sources == ["embedded-parameters"] * 2
else "graph-inferred"
)
)
)
candidates.append((score, positive, negative, source))
context = None
if candidates and pair:
best_score = max(item[0] for item in candidates)
contexts = {item[1:] for item in candidates if item[0] == best_score}
if len(contexts) == 1:
positive, negative, source = next(iter(contexts))
context = {
"positive_text": positive,
"negative_text": negative,
"text_source": source,
}
tags = self._get_prompt_tags(conn, row[0])
try:
workflow = json.loads(row[5]) if row[5] else None
@@ -2122,8 +2010,6 @@ class PromptDatabase(
except (json.JSONDecodeError, TypeError):
prompt_meta = None
return {
"pair": pair,
"pair_context": context,
"prompt_id": row[0],
"text": row[1],
"category": row[2],
@@ -2164,15 +2050,11 @@ class PromptDatabase(
Returns number of prompts removed.
"""
with self._pair_transaction() as conn:
with self.model.get_connection() as conn:
cursor = conn.execute("""
SELECT p.id FROM prompts p
LEFT JOIN generated_images gi ON p.id = gi.prompt_id
WHERE gi.prompt_id IS NULL
AND NOT EXISTS (
SELECT 1 FROM prompt_pairs pp
WHERE pp.positive_prompt_id = p.id OR pp.negative_prompt_id = p.id
)
AND NOT EXISTS (
SELECT 1 FROM prompt_tags pt
JOIN tags t ON pt.tag_id = t.id
-136
View File
@@ -1,136 +0,0 @@
"""Atomic metadata enrichment for a single known generation context."""
import json
import os
from typing import Any, Dict
from .models import normalize_image_path
from .operations import _coerce_file_info
try:
from ..utils.hashing import generate_prompt_hash
except ImportError:
from utils.hashing import generate_prompt_hash
def ingest_resolved_pair(
db: Any, image_path: str, metadata: Dict[str, Any], resolution: Dict[str, Any]
) -> Dict[str, Any]:
state = resolution.get("state", "unresolved")
if state != "resolved":
return {"status": state, "pairs_added": 0, "links_added": 0}
positive, negative = resolution["positive"], resolution["negative"]
positive_text = positive.get("storage_text", positive["text"])
negative_text = negative.get("storage_text", negative["text"])
normalized = normalize_image_path(image_path)
provenance = {"resolver_version": 1, "positive": positive, "negative": negative}
encoded = json.dumps(
provenance, sort_keys=True, separators=(",", ":"), allow_nan=False
)
with db._pair_transaction() as conn:
images = conn.execute(
"SELECT gi.*,p.hash AS positive_hash FROM generated_images gi "
"JOIN prompts p ON p.id=gi.prompt_id "
"WHERE gi.file_path=? OR gi.image_path=?",
(normalized, image_path),
).fetchall()
if len(images) > 1 or (
images and images[0]["positive_hash"] != generate_prompt_hash(positive_text)
):
return {"status": "conflict", "pairs_added": 0, "links_added": 0}
existing = images[0] if images else None
if existing:
prior = conn.execute(
"SELECT n.hash AS negative_hash FROM image_prompt_pairs ip "
"JOIN prompt_pairs pp ON pp.id=ip.pair_id "
"LEFT JOIN prompts n ON n.id=pp.negative_prompt_id WHERE ip.image_id=?",
(existing["id"],),
).fetchall()
expected = (
generate_prompt_hash(negative_text) if negative_text.strip() else None
)
if any(row["negative_hash"] != expected for row in prior):
return {"status": "conflict", "pairs_added": 0, "links_added": 0}
prior_pair = conn.execute(
"SELECT pp.id FROM prompt_pairs pp "
"JOIN prompts p ON p.id=pp.positive_prompt_id "
"LEFT JOIN prompts n ON n.id=pp.negative_prompt_id "
"WHERE p.hash=? AND n.hash IS ?",
(
generate_prompt_hash(positive_text),
generate_prompt_hash(negative_text) if negative_text.strip() else None,
),
).fetchone()
pair_id = db._save_pair(conn, positive_text, negative_text)
pair = conn.execute(
"SELECT * FROM prompt_pairs WHERE id=?", (pair_id,)
).fetchone()
pairs_added = int(prior_pair is None)
file_size, width, height, image_format = _coerce_file_info(metadata)
snapshots = {
column: json.dumps(metadata[key], sort_keys=True, allow_nan=False)
for column, key in (
("workflow_data", "workflow"),
("prompt_metadata", "prompt"),
("parameters", "parameters"),
)
if key in metadata
}
info = {
"file_size": file_size,
"width": width,
"height": height,
"format": image_format,
}
snapshots.update(
{key: value for key, value in info.items() if value is not None}
)
changed = False
if existing:
image_id = existing["id"]
updates = {
key: value for key, value in snapshots.items() if existing[key] != value
}
if updates:
conn.execute(
"UPDATE generated_images SET "
+ ",".join(f"{key}=?" for key in updates)
+ " WHERE id=?",
[*updates.values(), image_id],
)
changed = True
else:
columns = ["prompt_id", "image_path", "filename", "file_path", *snapshots]
values = [
pair["positive_prompt_id"],
image_path,
os.path.basename(image_path),
normalized,
*snapshots.values(),
]
image_id = conn.execute(
"INSERT INTO generated_images("
+ ",".join(columns)
+ ") VALUES("
+ ",".join("?" for _ in columns)
+ ")",
values,
).lastrowid
linked = conn.execute(
"SELECT 1 FROM image_prompt_pairs WHERE pair_id=? "
"AND image_id=? AND provenance=?",
(pair_id, image_id, encoded),
).fetchone()
db._link_image_to_pair(conn, pair_id, image_id, provenance)
status = (
"added"
if existing is None
else "enriched" if changed or not linked else "unchanged"
)
return {
"status": status,
"pairs_added": pairs_added,
"links_added": int(not linked),
"pair_id": pair_id,
"image_id": image_id,
}
-387
View File
@@ -1,387 +0,0 @@
"""Ordered reusable prompt pairs and their generation provenance."""
import json
import sqlite3
from contextlib import contextmanager
from typing import Any, Dict, Iterator, List, Optional
from .models import pair_provenance_error, utc_now_iso
try:
from ..utils.hashing import generate_prompt_hash
except ImportError:
from utils.hashing import generate_prompt_hash
class PairDependencyError(ValueError):
"""An operation would change or remove content used by a saved pair."""
def _validate_metadata(
title: Optional[str],
category: Optional[str],
tags: Optional[List[str]],
rating: Optional[int],
notes: Optional[str],
) -> Optional[List[str]]:
for name, value in (("title", title), ("category", category), ("notes", notes)):
if value is not None and not isinstance(value, str):
raise ValueError(f"{name} must be a string")
if rating is not None and (type(rating) is not int or not 1 <= rating <= 5):
raise ValueError("Rating must be between 1 and 5")
if tags is None:
return None
if not isinstance(tags, list) or any(not isinstance(tag, str) for tag in tags):
raise ValueError("tags must be a list of strings")
return list(dict.fromkeys(tag.strip() for tag in tags if tag.strip()))
class PairOperations:
"""Connection-level pair operations mixed into PromptDatabase."""
@contextmanager
def _pair_transaction(self) -> Iterator[sqlite3.Connection]:
conn = self.model.get_connection()
if conn.in_transaction:
raise RuntimeError("Pair operations require their own transaction")
with conn:
conn.execute("BEGIN IMMEDIATE")
yield conn
def _ensure_prompts_unpaired(
self, conn: sqlite3.Connection, prompt_ids: List[int]
) -> None:
if not prompt_ids:
return
placeholders = ",".join("?" for _ in prompt_ids)
row = conn.execute(
f"SELECT id FROM prompt_pairs WHERE positive_prompt_id IN ({placeholders}) "
f"OR negative_prompt_id IN ({placeholders}) LIMIT 1",
[*prompt_ids, *prompt_ids],
).fetchone()
if row:
raise PairDependencyError(
f"Prompt belongs to saved pair {row['id']}; delete the pair first "
"or save changed text as a new prompt"
)
def _pair_component(self, conn: sqlite3.Connection, text: str) -> int:
prompt_hash = generate_prompt_hash(text)
conn.execute(
"INSERT INTO prompts(text, hash, created_at, updated_at) VALUES(?,?,?,?) "
"ON CONFLICT(hash) DO NOTHING",
(text.strip(), prompt_hash, utc_now_iso(), utc_now_iso()),
)
return conn.execute(
"SELECT id FROM prompts WHERE hash = ?", (prompt_hash,)
).fetchone()["id"]
def _sync_pair_tags(
self, conn: sqlite3.Connection, pair_id: int, tags: List[str]
) -> None:
conn.execute("DELETE FROM prompt_pair_tags WHERE pair_id = ?", (pair_id,))
for tag_id in self._ensure_tags(conn, tags).values():
conn.execute(
"INSERT INTO prompt_pair_tags(pair_id,tag_id) VALUES(?,?)",
(pair_id, tag_id),
)
def save_prompt_pair(
self,
positive_text: str,
negative_text: str = "",
*,
title: Optional[str] = None,
category: Optional[str] = None,
tags: Optional[List[str]] = None,
rating: Optional[int] = None,
notes: Optional[str] = None,
) -> int:
"""Save an ordered pair atomically; repeat saves preserve its metadata."""
with self._pair_transaction() as conn:
return self._save_pair(
conn,
positive_text,
negative_text,
title=title,
category=category,
tags=tags,
rating=rating,
notes=notes,
)
def _save_pair(
self,
conn: sqlite3.Connection,
positive_text: str,
negative_text: str = "",
*,
title: Optional[str] = None,
category: Optional[str] = None,
tags: Optional[List[str]] = None,
rating: Optional[int] = None,
notes: Optional[str] = None,
) -> int:
"""Save an ordered pair atomically; repeat saves preserve its metadata."""
if not isinstance(positive_text, str) or not positive_text.strip():
raise ValueError("Positive prompt text cannot be empty")
if not isinstance(negative_text, str):
raise ValueError("Negative prompt text must be a string; use '' for empty")
tags = _validate_metadata(title, category, tags, rating, notes)
positive = self._pair_component(conn, positive_text)
negative = (
self._pair_component(conn, negative_text) if negative_text.strip() else None
)
existing = conn.execute(
"SELECT id FROM prompt_pairs WHERE positive_prompt_id = ? "
"AND negative_prompt_id IS ?",
(positive, negative),
).fetchone()
if existing:
return existing["id"]
now = utc_now_iso()
pair_id = conn.execute(
"INSERT INTO prompt_pairs(positive_prompt_id,negative_prompt_id,title,"
"category,rating,notes,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)",
(positive, negative, title, category, rating, notes, now, now),
).lastrowid
if tags:
self._sync_pair_tags(conn, pair_id, tags)
return pair_id
def _read_pair_rows(
self, conn: sqlite3.Connection, where: str, params: List[Any], suffix: str = ""
) -> List[Dict[str, Any]]:
rows = conn.execute(
"SELECT pp.*, p.text AS positive_text, "
"COALESCE(n.text,'') AS negative_text,"
"(SELECT COUNT(DISTINCT image_id) FROM image_prompt_pairs ip "
"WHERE ip.pair_id=pp.id) AS image_count FROM prompt_pairs pp "
"JOIN prompts p ON p.id=pp.positive_prompt_id "
"LEFT JOIN prompts n ON n.id=pp.negative_prompt_id WHERE " + where + suffix,
params,
).fetchall()
pairs = [dict(row) for row in rows]
if not pairs:
return pairs
by_id = {pair["id"]: pair for pair in pairs}
for pair in pairs:
pair["archived"] = bool(pair["archived"])
pair["tags"] = []
placeholders = ",".join("?" for _ in by_id)
for row in conn.execute(
"SELECT pt.pair_id,t.name FROM prompt_pair_tags pt JOIN tags t ON "
f"t.id=pt.tag_id WHERE pt.pair_id IN ({placeholders}) ORDER BY t.name",
list(by_id),
):
by_id[row["pair_id"]]["tags"].append(row["name"])
return pairs
def get_prompt_pair(self, pair_id: int) -> Optional[Dict[str, Any]]:
"""Return components, pair metadata and image count, including archives."""
rows = self._read_pair_rows(self.model.get_connection(), "pp.id = ?", [pair_id])
return rows[0] if rows else None
def list_prompt_pairs(
self,
*,
text: Optional[str] = None,
category: Optional[str] = None,
tags: Optional[List[str]] = None,
include_archived: bool = False,
limit: int = 50,
offset: int = 0,
sort: str = "newest",
) -> Dict[str, Any]:
"""List pairs with all-tag filtering and stable paginated ordering."""
orders = {
"newest": "pp.id DESC",
"last_used_desc": "julianday(pp.last_used_at) DESC, pp.id DESC",
}
if sort not in orders:
raise ValueError("Unknown pair sort order")
if type(limit) is not int or not 1 <= limit <= 500:
raise ValueError("limit must be between 1 and 500")
if type(offset) is not int or offset < 0:
raise ValueError("offset must be a nonnegative integer")
tags = _validate_metadata(None, category, tags, None, None)
if text is not None and not isinstance(text, str):
raise ValueError("text must be a string")
clauses, params = ["1=1"], []
if not include_archived:
clauses.append("pp.archived=0")
if category is not None:
clauses.append("pp.category = ?")
params.append(category)
if text:
clauses.append("(p.text LIKE ? OR n.text LIKE ? OR pp.title LIKE ?)")
params.extend([f"%{text}%"] * 3)
for tag in tags or []:
clauses.append(
"EXISTS(SELECT 1 FROM prompt_pair_tags pt JOIN tags t "
"ON t.id=pt.tag_id WHERE pt.pair_id=pp.id AND t.name=?)"
)
params.append(tag)
where = " AND ".join(clauses)
conn = self.model.get_connection()
total = conn.execute(
"SELECT COUNT(*) FROM prompt_pairs pp "
"JOIN prompts p ON p.id=pp.positive_prompt_id "
"LEFT JOIN prompts n ON n.id=pp.negative_prompt_id WHERE " + where,
params,
).fetchone()[0]
pairs = self._read_pair_rows(
conn,
where,
[*params, limit, offset],
f" ORDER BY {orders[sort]} LIMIT ? OFFSET ?",
)
return {
"pairs": pairs,
"total": total,
"limit": limit,
"offset": offset,
"has_more": offset + len(pairs) < total,
}
def update_prompt_pair_metadata(
self,
pair_id: int,
*,
title: Optional[str] = None,
category: Optional[str] = None,
tags: Optional[List[str]] = None,
rating: Optional[int] = None,
notes: Optional[str] = None,
) -> bool:
"""Update pair metadata explicitly; None preserves and empty values clear."""
tags = _validate_metadata(title, category, tags, rating, notes)
changes = {
key: value
for key, value in (
("title", title),
("category", category),
("rating", rating),
("notes", notes),
)
if value is not None
}
if not changes and tags is None:
return False
with self._pair_transaction() as conn:
if not conn.execute(
"SELECT 1 FROM prompt_pairs WHERE id=?", (pair_id,)
).fetchone():
return False
changes["updated_at"] = utc_now_iso()
conn.execute(
"UPDATE prompt_pairs SET "
+ ",".join(f"{key}=?" for key in changes)
+ " WHERE id=?",
[*changes.values(), pair_id],
)
if tags is not None:
self._sync_pair_tags(conn, pair_id, tags)
return True
def archive_prompt_pair(self, pair_id: int, archived: bool = True) -> bool:
"""Hide a reusable pair without changing components or generation history."""
if type(archived) is not bool:
raise ValueError("archived must be a boolean")
with self._pair_transaction() as conn:
return (
conn.execute(
"UPDATE prompt_pairs SET archived=?,updated_at=? WHERE id=?",
(int(archived), utc_now_iso(), pair_id),
).rowcount
> 0
)
def delete_prompt_pair(self, pair_id: int) -> bool:
"""Delete unused pair metadata; generated pairs must be archived instead."""
with self._pair_transaction() as conn:
if conn.execute(
"SELECT 1 FROM image_prompt_pairs WHERE pair_id=? LIMIT 1", (pair_id,)
).fetchone():
raise PairDependencyError(
"Pair has generated images; archive it instead"
)
return (
conn.execute("DELETE FROM prompt_pairs WHERE id=?", (pair_id,)).rowcount
> 0
)
def record_prompt_pair_use(self, pair_id: int, times: int = 1) -> bool:
"""Count pair uses independently of standalone prompt usage."""
if type(times) is not int or times < 1:
raise ValueError("times must be a positive integer")
with self._pair_transaction() as conn:
return (
conn.execute(
"UPDATE prompt_pairs SET run_count=run_count+?,last_used_at=? "
"WHERE id=?",
(times, utc_now_iso(), pair_id),
).rowcount
> 0
)
def link_image_to_pair(
self, pair_id: int, image_id: int, provenance: Dict[str, Any]
) -> int:
"""Associate an image with immutable run provenance, idempotently."""
with self._pair_transaction() as conn:
return self._link_image_to_pair(conn, pair_id, image_id, provenance)
def _link_image_to_pair(
self,
conn: sqlite3.Connection,
pair_id: int,
image_id: int,
provenance: Dict[str, Any],
) -> int:
"""Associate an image with immutable run provenance, idempotently."""
if not isinstance(provenance, dict):
raise ValueError("provenance must be an object")
try:
encoded = json.dumps(
provenance, sort_keys=True, separators=(",", ":"), allow_nan=False
)
except (TypeError, ValueError, RecursionError) as error:
raise ValueError("provenance must contain JSON values") from error
error = pair_provenance_error(encoded)
if error:
raise ValueError(error)
pair = conn.execute(
"SELECT positive_prompt_id FROM prompt_pairs WHERE id=?", (pair_id,)
).fetchone()
image = conn.execute(
"SELECT prompt_id FROM generated_images WHERE id=?", (image_id,)
).fetchone()
if not pair or not image:
raise ValueError("Pair and image must exist")
if pair["positive_prompt_id"] != image["prompt_id"]:
raise ValueError("Image must belong to the pair's positive prompt")
conn.execute(
"INSERT INTO image_prompt_pairs(pair_id,image_id,provenance,created_at) "
"VALUES(?,?,?,?) ON CONFLICT(pair_id,image_id,provenance) DO NOTHING",
(pair_id, image_id, encoded, utc_now_iso()),
)
return conn.execute(
"SELECT id FROM image_prompt_pairs "
"WHERE pair_id=? AND image_id=? AND provenance=?",
(pair_id, image_id, encoded),
).fetchone()["id"]
def get_image_prompt_pairs(self, image_id: int) -> List[Dict[str, Any]]:
"""Return every recorded pair association with decoded provenance."""
rows = (
self.model.get_connection()
.execute(
"SELECT * FROM image_prompt_pairs WHERE image_id=? ORDER BY id",
(image_id,),
)
.fetchall()
)
return [
{**dict(row), "provenance": json.loads(row["provenance"])} for row in rows
]
-84
View File
@@ -1,84 +0,0 @@
# Application Redesign Development Plan
The maintainer approved the [interactive concept](concepts/workspace/README.md)
on 2026-10-17. It is a design reference with simulated operations, not production
code. This plan proposes implementation order and acceptance gates.
## Delivery status
- Foundation merged in [#198](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/198).
- [#200](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/200) delivers native Images browsing, physical-file details, infinite scroll, Grid/List/Masonry, Viewer.js and video playback. The workspace opens on Images; original application entry points remain available.
- Shared role-aware sending and the physical-file prompt inspector merged in [#203](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/203) and [#206](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/206).
- The native Prompt Library merged in [#210](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/210) for [#208](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/208): one positive parent, saved negative variants, full selected text and a shared filtered image gallery. It does not invent chronological runs from counters or scan dates.
- [Durable history storage](prompt-history-storage.md) records job attempts and outcomes from [#212](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/212), frozen prompt contexts from [#213](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/213), and observed run-owned output locators from [#214](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/214). Those locators do not verify file bytes or establish gallery-image associations. That association contract and the chronological execution timeline remain pending under [#209](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/209).
- [#215](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/215) brings the shell, Images and natural-height Prompt Library presentation into the approved geometry and themes while retaining their existing operations. [#216](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/216) adds the shared current-file inspector with exact generation facts, scoped provenance, raw/download evidence and loaded-collection viewer controls, including a slideshow that pauses at native video. This delivery does not establish whole-application parity, change the default entry point or constitute a release. Recorded chronological run/context browsing continues under [#209](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/209); image tagging/review, System consolidation and remaining organization capabilities continue under #196.
## Scope and compatibility
Rebuild the standalone application around one shared shell. Preserve existing
node schemas, saved workflows, backend capabilities, pinned Viewer.js and the
ComfyAssets toolkit. Keep existing application entry points usable during the
transition. Do not change the running maintainer instance during automated tests.
Use the concept's feature inventory as a parity checklist. Each row needs an
implementation issue, observable acceptance criteria and automated test coverage.
Record any intentional behavior change explicitly; no silent feature removal.
## Proposed delivery sequence
Use a tracking issue and small issue-linked branches and PRs. Dependencies may
require splitting these milestones further.
| Milestone | Deliverable and acceptance focus |
| --- | --- |
| 1. Foundation | Production shell, routing, shared theme and disposable page-test harness. Verify startup, navigation, dark/light changes, cleanup and existing entry points. Establish discovered-test and coverage baselines before changes. |
| 2. Image workspace | Real API-backed filters, Grid/List/Masonry, readable thumbnails, selection and shared Viewer.js inspector. Continuous infinite scroll only, with accessible Load more fallback, stable ordering, retry and restored browsing context. Bound retained DOM and test large datasets. |
| 3. Prompt library and history | Real positive/negative associations, selected full negative, run history and shared image filtering. Design any missing execution-history persistence and migration before wiring the timeline; never invent historical runs from incomplete metadata. |
| 4. Send to ComfyUI | Shared paper-plane actions wherever prompt text appears; role-specific and pair sends, destination preview, acknowledgement and failure recovery. Cover explicit empty versus unknown negatives and connected inputs. Never queue or replace a workflow. |
| 5. Editing and review | Prompt editing, vocabulary management, per-image manual/auto-tag approval and Needs review. Preserve cancellation semantics and distinguish tag approval from prompt-association decisions. |
| 6. System and maintenance | Settings, sources/scans, integrations, jobs, diagnostics, backup/restore and maintenance impact previews. Cover existing capabilities before retiring their old controls. |
| 7. Additional organization | Favorites independent of ratings, collections, saved views, Activity, scheduled backups and portable import previews. Specify persistence, validation and recovery separately for each capability. |
| 8. Cutover | Complete the parity checklist, migration and upgrade validation, accessibility/performance checks, documentation and targeted maintainer live testing. Switch the default only after these pass; preserve compatible URLs. |
## TDD and test layers
For each behavior, first write and run a meaningful failing test. Implement the
smallest working change, verify it passes, then refactor with the suite green.
Include failing-before/passing-after evidence in the PR.
- **Unit:** filtering and selection state, cursor handling, prompt roles,
formatting, validation, navigation and lifecycle cleanup.
- **Integration:** real HTTP handlers and disposable SQLite/filesystem fixtures;
persistence, migration from supported old schemas, idempotent scans, restore,
rollback and reference-safe deletion. Mock inference and external services.
- **Smoke:** shipped production pages start and navigate with real fixture APIs;
test populated, empty and error states, assets and unexpected console failures.
- **Browser workflows:** production HTML/JS across Chromium, Firefox, WebKit and
touch; editing/reload, infinite append, viewer cleanup, tagging approval,
keyboard access, delayed responses, cancellation and retries.
- **Host integration:** pinned disposable CPU ComfyUI, Classic and Nodes 2.0,
switching both ways and workflow reload for host-facing changes. Exercise
independent and combined pack installations for shared bridge/toolkit changes.
Retain existing test discovery and coverage gates. Carry forward database
injection and open-handle checks into new fixtures. Add new frontend modules to
coverage measurement and its baseline; do not lower floors or replace existing
regression coverage with mockup-only tests.
## GitHub and release gates
Extend existing required workflows: the Python 3.10–3.13 matrix on Linux,
Windows and macOS; 85% Python coverage; formatting; frontend units and per-file
coverage floors; vendor checks; browser workflows; real ComfyUI compatibility.
Publish traces, screenshots, logs and coverage artifacts on failures. See
[TESTING.md](../TESTING.md) for commands and fixture isolation requirements.
Every PR must map acceptance criteria to tests, report relevant checks and include
screenshots for visual changes. All required checks must pass before merging.
Use maintainer live review at meaningful visual and host-integration milestones;
capture discovered regressions in automated tests. Notify the maintainer when
Python changes require a ComfyUI restart.
Keep version/publication changes separate from intermediate UI work: the existing
registry workflow publishes on main/master changes to `pyproject.toml` or manual
dispatch. Do not trigger a release merely to preview a redesign milestone.
File diff suppressed because one or more lines are too long
-138
View File
@@ -1,138 +0,0 @@
# Prompt Manager application redesign concept
Status: visual concept approved by the maintainer, 2026-10-17. Production
implementation is progressing in tested slices; see the [development plan](../../application-redesign.md).
The production workspace now has native Images browsing; this concept remains the
approved target for the complete application.
## Review the mockup
Run `python tools/preview-prompt-timeline.py --bind 0.0.0.0 --port 8767` and
open `/workspace/`. The original approved timeline remains available at `/`.
The server exposes an explicit allowlist of concept files and pinned assets.
All examples are synthetic; there are no application API calls, database access,
real model downloads, or ComfyUI handoffs. Local file inspection previews an
image in the browser without uploading it. Exports contain sample data only.
The sidebar opens Images, Prompt library, Tags & categories, Inspect a file,
Activity, System and Design review. System includes Overview, Appearance,
Sources & scans, Integrations, Auto-tagging, Maintenance, Backup & export, and
Diagnostics & logs. The in-app Design review contains the feature map below.
## Proposed direction
One application shell replaces separate Admin, Gallery and Metadata shells.
Use the approved shared ComfyAssets theme: ComfyUI dark/light, blue primary
actions, green positive roles, red negative roles and errors. Preserve existing
capabilities and services while consolidating controls and clarifying their scope.
Browsing is image-led with readable thumbnails and one metadata inspector.
Prompt library groups by positive; negative variants and runs share its gallery.
The full selected negative appears directly beneath the positive in history.
Favorites express preference independently of the existing quality rating.
Collections organize projects without duplicating recipe text; saved views keep
search/filter context. New capabilities are proposals, not implemented features.
Maintenance moves out of the browsing toolbar. Checks, impact preview, backups,
and apply steps have separate responsibilities. Distinguish prompt text reuse
from image-file duplicates, and metadata enrichment from thumbnail generation.
No repeated global thumbnail-generation prompt on opening the gallery.
## Send to ComfyUI
Use the same original paper-plane outline, blue accent and accessible role label
on every positive/negative prompt block, including image details and the viewer.
A pair action sends both. Preview destination and exact fields before applying;
positive-only and negative-only actions leave the other role untouched. An
explicitly empty negative means clear the target field; unknown metadata must
never be interpreted as empty. Sending text neither queues execution nor
replaces a workflow.
Production needs a shared host bridge with versioned payloads, origin validation,
destination discovery, capability checks, disconnected-host handling, linked-input
checks and acknowledgements. Preview actual target values and report success only
after acknowledgement. Default to asking for a destination; never guess between
multiple nodes or positive and negative encoder inputs. Verify Classic and Nodes
2.0 with both packs installed independently and together.
## Existing feature inventory
| Current feature | Proposed home / behavior | Disposition |
| --- | --- | --- |
| Admin prompt cards / per-prompt gallery | Prompt library + one prompt history workspace | Retained, rebuilt |
| Standalone image gallery / Admin image modal | One Images workspace + shared Viewer.js viewer | Consolidated |
| Video playback, autoplay / mute / loop, viewer slideshow and transforms | Shared media viewer; playback controls in Appearance; same image navigation | Retained |
| Metadata page / viewer sidebar / full-text popups | One reusable metadata inspector; inline complete prompt text | Consolidated |
| Prompt add, edit, rating, notes, copy, delete | Recipe editor, shared details, contextual bulk actions; reference-safe archive | Retained, clarified |
| Categories, tag rename / merge, autocomplete | Tags & categories; same vocabulary in every editor | Retained |
| Grid/list, thumbnail size, columns, pages/infinite scroll | Infinite scroll only; one Appearance configuration and contextual layout / density overrides | Simplified |
| Scan, rescan, monitoring directories, worker threads | Sources & scans; explicit metadata enrichment vs thumbnail work | Consolidated |
| Ollama, HuggingFace, WD14 variants, thresholds, review | One Auto-tagging configuration and review flow | Retained |
| Thumbnail quality, size limit, metadata cache, paths | Appearance + cache controls in Maintenance | Retained |
| Prompt dedup, hash checks, consistency, unused prompts | Maintenance plan with dependency checks and impact preview | Retained, safer flow |
| Image dedup, orphan image links, browser cache | Separate reviewed operations in Maintenance | Retained |
| Database backup / restore, JSON export | Backup & export; recovery and portable recipes clearly distinguished | Retained |
| Diagnostics, image-link checks, logs, rotation, log levels | System → Diagnostics & logs | Retained |
| LoRA detection/import, trigger injection, CivitAI key | System → Integrations; positive-only triggers | Retained |
| Statistics / counts | System overview + usage breakdown; context counts in the workspace | Retained |
| Send prompts to ComfyUI from every prompt surface | Role-aware paper-plane action + destination / overwrite preview | New proposal |
| Reusable negatives / approved branch timeline | Prompt history with selected negative directly beneath positive | Approved concept |
| Favorites, saved searches, project collections | Sidebar views; collections do not duplicate recipe text | New proposal |
| Ambiguous metadata review, source provenance, run comparisons | Needs review queue; recorded vs inferred evidence; pair/run selection | New / expanded |
| Unified jobs, cancellation, resumable progress visibility | Activity center; cancellation distinguished from hiding progress | New proposal |
| Scheduled backups, pre-change safety snapshots | Explicit recovery policy after storage configuration | New proposal |
## Implementation boundaries and test requirements
Keep Viewer.js 1.15.2 and the shared toolkit as reproducible pinned artifacts;
this mock uses their actual checked-in files without modifying them. Preserve
legacy routes during the transition, current node layouts/schemas, existing
controllers where reusable, and all existing test/coverage gates.
The timeline needs persisted execution records to represent real runs. Saved
views, collections, metadata review resolution, activity history, scheduled
backups, portable JSON import, and the ComfyUI bridge need separate implementation
and persistence work. Preserve source evidence and distinguish recorded from
inferred metadata. Existing schema migration is automatic; old image enrichment
remains an optional explicit scan. The one-time upgrade notice belongs in the
new shell, with Scan and Later choices.
Implement in reviewed slices: shared application shell and inspector; reusable
prompt blocks/send bridge; image workspace; grouped library/history; settings and
maintenance; optional organization and workflow improvements. Write regressions
first for production behavior. Retain Python, frontend unit, Chromium/Firefox/
WebKit/touch and managed Classic/Nodes 2.0 tests. Add behavior tests for shared
filters, navigation state, viewer cleanup, interrupted jobs, dependency-safe
maintenance, backup/restore, paired metadata and correct send destinations.
Screenshots in `screenshots/` record the reviewed starting point. The approved
standalone timeline in `../prompt-variant-timeline.html` remains unchanged.
## Review polish
History actions use a shared branch icon. Images can switch among Grid, List and
Masonry; Masonry preserves each image's proportions and keeps DOM order while
packing cards with a responsive layout. It does not require experimental native
masonry support. Resize observers are disconnected when leaving the view.
Every image card includes a tagging action beside Info, including cards in Needs
review. Choose WD14 SwinV2, WD14 ViT, Ollama or HuggingFace; adjust thresholds or
instructions, generate sample suggestions, edit tags manually, and approve only
the desired suggestions. Changes are staged until approval, apply to that image
alone and do not resolve uncertain prompt associations. Changing models discards
previous suggestions. Cancel preserves existing tags. All inference is simulated.
## Continuous browsing decision
The redesign uses infinite scroll only. Remove page numbers, previous/next page
buttons, paged/infinite mode selection and results-per-page controls. Keep request
batching internal. The mock appends 12 images at a time in Grid, List and Masonry;
it preserves existing card nodes, focus and selection while appending. It also
retains the loaded range when switching layouts or revisiting a search. A
keyboard-accessible Load more action provides an alternate trigger for the same
continuous stream, not pagination. Exhausted and empty results are explicit.
Production will need bounded rendering, stable cursors, scroll restoration and
loading/error/retry behavior for large collections. Existing backend pagination
can continue providing internal request batches; no production tests or routes
are removed as part of this prototype.
File diff suppressed because it is too large Load Diff
-105
View File
@@ -1,105 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="referrer" content="no-referrer" />
<title>Images · Prompt Manager concept</title>
<link rel="stylesheet" href="/toolkit/controls.css" />
<link rel="stylesheet" href="/vendor/viewer.css" />
<link rel="stylesheet" href="/workspace/style.css" />
<script src="/vendor/viewer.js" defer></script>
<script src="/workspace/app.js" type="module"></script>
</head>
<body class="ca-tools">
<a class="skip" href="#main">Skip to workspace</a>
<div class="app-shell">
<aside class="sidebar">
<a class="brand" href="#images"
><span class="brand-mark">P<span>±</span></span
><span>Prompt Manager<small>COMFYASSETS</small></span></a
>
<div class="sidebar-label">WORKSPACE</div>
<nav id="primary-nav" aria-label="Workspace"></nav>
<div class="sidebar-label">
SAVED VIEWS <span class="new-label">PROPOSED</span>
</div>
<nav id="saved-nav" aria-label="Saved views"></nav>
<div class="sidebar-label">
COLLECTIONS <span class="new-label">PROPOSED</span>
</div>
<nav id="collections-nav" aria-label="Collections"></nav>
<div class="sidebar-bottom">
<nav id="system-nav" aria-label="Tools and system"></nav>
<div class="connection">
<i></i>
<div>
ComfyUI workspace<small
>Demo connection · Classic / Nodes 2.0</small
>
</div>
</div>
</div>
</aside>
<div class="workspace">
<header class="topbar">
<div class="breadcrumbs">
ComfyAssets <span>/</span> <span id="breadcrumb">Images</span>
</div>
<div class="top-actions">
<span class="concept-badge">Interactive concept</span
><button id="activity-open" type="button">
Activity <span class="count" id="activity-count">2</span></button
><label class="theme-label" for="theme"
>Theme
<select id="theme" aria-label="Theme">
<option value="dark">Dark</option>
<option value="light">Light</option>
<option value="system">System</option>
</select></label
><a class="canvas-link" href="#send"
>ComfyUI <span aria-hidden="true">↗</span></a
>
</div>
</header>
<main id="main" tabindex="-1"></main>
<footer class="app-footer">
<span
>Design prototype · sample images and data · actions stay in this
tab</span
><button type="button" id="review-link">What changes & why</button>
</footer>
</div>
</div>
<div id="toast" class="toast" role="status" aria-atomic="true"></div>
<dialog id="modal" aria-labelledby="modal-title">
<div class="dialog-header">
<h2 id="modal-title"></h2>
<button type="button" id="modal-close" aria-label="Close dialog">
×
</button>
</div>
<div id="modal-body"></div>
</dialog>
<dialog id="viewer-dialog" aria-labelledby="viewer-title">
<div class="dialog-header">
<div>
<h2 id="viewer-title">Image details</h2>
<p class="muted small">
One viewer · image, prompts, generation data, workflow
</p>
</div>
<button type="button" id="viewer-close" aria-label="Close image viewer">
×
</button>
</div>
<div class="viewer-layout">
<div class="viewer-left">
<div id="viewer-stage"></div>
<div class="viewer-toolbar" id="viewer-toolbar"></div>
</div>
<aside id="viewer-details" aria-label="Image metadata"></aside>
</div>
</dialog>
</body>
</html>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 365 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 441 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 440 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 443 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 478 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

File diff suppressed because it is too large Load Diff
-84
View File
@@ -1,84 +0,0 @@
# Durable Prompt History
Status: the durable job ledger, immutable prompt contexts and run-owned output
references are implemented for
[#209](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/209). Gallery
image associations and the chronological timeline remain pending; #209 stays open.
## Recorded jobs
New jobs observed at the ComfyUI queue's dequeue boundary receive an immutable
run identity and a host-session/queue-item identity. The public ComfyUI job UUID
is a correlation field: submitting it twice records two attempts. Rejected graphs
and jobs removed from the queue before dequeue create no execution rows. Cached
jobs are recorded even when no PromptManager node runs, and headless jobs do not
require a browser or WebSocket client.
The additive `execution_runs` table stores dequeue time, observed callback time,
optional executor event times and an explicit outcome: `observed`, `succeeded`,
`failed`, `interrupted` or `unknown`. Matching stored terminal events determine
outcomes. Missing or conflicting terminal evidence remains unknown; a process
crash can leave an observed row without an invented completion time. Bounded
safe diagnostics retain node ID/type and exception type, never graph text,
arbitrary extra data, credentials, exception messages, traceback, inputs or
arbitrary output payloads. A bounded background writer keeps storage failures from
changing host execution and reports capture gaps rather than inventing rows.
Existing databases begin with zero execution rows. Migration and backup restore
preserve prompts, pairs, image links and aggregate counters; storage epochs keep
stale queued writes from being replayed into restored data. There is no
historical backfill from counters, scan dates, image timestamps or saved recipes.
[Testing](../TESTING.md#durable-execution-ledger) describes the real CPU host
matrix, historical failure proof, privacy checks and cleanup. Python deployment
requires restarting ComfyUI.
## What the existing UI proves
`prompts.run_count` and `prompt_pairs.run_count` remain aggregate usage counters,
not completed-run counts. `last_used_at` is the latest aggregate update; legacy
migration may have estimated counts from linked images. `generated_images`
identifies linked files; image counts and insertion times are not run counts or
execution times. Image/pair association creation time can reflect an old-image
scan. A saved pair's creation time is its recipe date.
The native Prompt Library shows saved negative variants, linked images and
aggregate uses. It does not expose the ledger as chronological dots or repeat-run
branches. `PromptTracker` identities and PNG recipe evidence remain separate
from durable host job identity.
## Immutable contexts and output references
Prompt contexts retain bounded raw/effective positive, negative and composition
snapshots with nullable recipe navigation hints and explicit evidence provenance.
Unknown text remains SQL NULL; a known empty negative remains empty text. Cached
evidence belongs to its exact cache provider and attempt, rather than inheriting
unrelated recent prompts. Deletion and restore can remove navigation hints without
rewriting historical snapshots or rebinding them to current library rows.
Schema v3 adds `execution_output_capture` and `execution_run_outputs` without
backfilling older runs or modifying gallery image links. At final queue completion,
the writer attaches bounded output evidence from that attempt's final host history
to its immutable run ID. The internal `get_execution_outputs(run_id)` reader
returns state, reason, format version, source and ordered references. A complete
capture with `none_reported` means the final host history reported no images;
`not_captured`, partial, unavailable and conflicting evidence remain explicit.
Failed and interrupted jobs can still have complete evidence for images reported
before termination. Running jobs do not gain a final capture merely because an
image node finished.
Each reference records output node ID, image ordinal, folder type, subfolder,
filename, path style and an opaque configured-root key. A cached repeat can retain
the same locators under a new run. Locators do not verify file bytes, continued
existence, a newly generated file or membership in any prompt context: the reader
reports file identity as `unverified`, generation and context membership as
`unknown`, and temp outputs as transient. Capture never reads, resolves or serves
the files and adds no public route. PNG metadata is not required. Same-path file
replacement and existing gallery provenance retain their separate semantics.
## Remaining history work
Define a deduplicated run/gallery-image relationship that preserves existing image
provenance and same-path replacement protection. Add a chronological timeline only
after its navigation and evidence semantics can honestly represent prompt-level
execution chronology, batched outputs and repeat-run branches.
-34
View File
@@ -1,34 +0,0 @@
# Prompt inspection and sending
The opt-in workspace at `/prompt_manager/workspace#/images` shows full positive
and negative prompts in both Image details and the full-image preview. Existing
gallery and metadata tools remain available during the application rebuild.
Each prompt shows its evidence source: recorded execution, workflow inference,
or embedded generation parameters. Path-only saved-library associations are
marked unverified and cannot be sent: a filename alone cannot prove that an image
has not been replaced. Missing, ambiguous, and unreadable prompts remain visible
as distinct states. An explicitly
empty negative is a resolved value and can clear a destination's negative field.
Use the blue paper-plane action beside a prompt, or **Send prompt pair**. Keep a
ComfyUI tab open under the same origin and deployment prefix. Choose a window and
compatible Prompt Manager node, select **Preview changes**, review the complete
before/after text and preserved prepend/append composition, then **Apply to
ComfyUI**. The dialog reports success only after a matching acknowledgement.
It does not load workflows or queue generation. If confirmation is lost, inspect
ComfyUI and preview again before another attempt. Closing the dialog cannot undo
an update already sent.
## Read-only API
`GET /prompt_manager/images/inspect/{relative_path}?root={root_index}` requires
an explicit configured root, including when two roots contain the same filename.
The response contains `image` identity and `prompts.positive` / `prompts.negative`.
Each role has `status` (`resolved`, `absent`, `unresolved`, or `ambiguous`), `text`
(string only when resolved, otherwise null), and `source`. Inspection does not
index files, alter saved prompts, run scans, or decode image pixels. PNG text and
video probe output are bounded; unsupported or malformed evidence remains
unresolved rather than becoming sendable placeholder text.
Python route changes require restarting ComfyUI. Browser assets require a refresh.
-49
View File
@@ -1,49 +0,0 @@
# Prompt sending bridge
Issue #202 establishes the transport and host adapter before the redesigned
inspector displays prompt text. Gallery paper-plane controls and normalized
metadata follow in a separate slice; existing node layouts are unchanged.
## Shared transport
PromptManager consumes the exact Toolkit 0.2.0 artifact recorded in
`web/js/shared/toolkit/provenance.json`. The shared module has no ComfyUI imports.
Its client discovers explicit host sessions and compatible destinations, requests
a preview, and applies that preview using a one-use, client-bound, expiring ticket.
Success requires an acknowledgement with verified destination values.
BroadcastChannel connects independently opened tabs on the same origin and storage
partition. Deployment prefixes have separate channel namespaces. Exact origin,
protocol and peer checks reject unrelated messages. Same-origin scripts are
trusted; this transport is not authentication against other scripts on that origin.
Unsupported messaging is reported without breaking existing ComfyUI nodes.
An apply timeout or abort after posting means the outcome is unknown. It cannot
prove the host cancelled the write. The sender must inspect a fresh destination
preview before deciding to send again; automatic retry must not overwrite edits.
## PromptManager adapter
Classic, Text, Focused and Split accept positive text; +/- accepts either role or
both. Plain CLIP encoders are not assigned roles by title or position. An omitted
role remains untouched; an explicitly empty string clears only the selected
field. Unknown/ambiguous metadata must not become an empty string.
Previews show original and requested text plus positive prepend/append composition.
Connected composition is marked unknown rather than guessing upstream values.
Connected destination fields reject writes. Applying rechecks graph, node, widgets,
values and connections. Ordinary serialization cache synchronization is accepted
only when it preserves live values. Both requested roles are checked before writing.
Widget values, visible inputs, serialization and composed previews are synchronized.
Callback failures restore local fields; arbitrary external callback side effects
cannot be undone. Sending never queues execution or loads/replaces a workflow.
## Lifecycle and validation
Page hiding closes the host and invalidates tickets; restored pages mount one fresh
host. Client disposal clears pending timers/listeners and closes its channel.
Shared protocol contracts run against the actual bundled file, alongside adapter
units, cross-browser tab workflows and real Classic/Nodes 2.0 scenarios. Pinned
PromptManager-only, KikoTools-only and combined disposable hosts verify coexistence
and workflow reload without modifying either live installation.
-111
View File
@@ -1,111 +0,0 @@
# Reusable positive and negative prompts
Tracking: [#193](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/193).
This work builds on the merged Split node in PR #192. Keep the feature unmerged
until maintainer live testing is complete.
## Storage contract
Keep existing prompt IDs, text hashes, metadata, image links and node schemas.
A component can serve either role. Ordered combinations refer to shared prompt
rows; an explicitly empty negative uses a null reference. Missing or ambiguous
metadata does not mean an empty negative. Pair metadata belongs to the
combination; saving another combination does not overwrite component metadata.
Existing trim/lowercase hash normalization remains unchanged.
Saved components are immutable while referenced. To change text, create or reuse
a component and save a new combination. Archive combinations that have generation
history. Pruning and legacy editing/deletion must respect these dependencies.
Historical workflow snapshots remain intact, including their original text.
## Migration and recovery
Create pair tables after existing legacy rebuild migrations, in an atomic,
repeatable migration. Never scan media during schema initialization. Test fresh,
legacy and current databases, repeated startup, injected failures and retries.
Preserve old backup compatibility. Migrate and validate a disposable backup copy
before replacing live database pages. Reject broken pair schemas, foreign keys,
triggers and views. Use SQLite backup APIs so committed WAL data is included.
## Metadata and scanning
Resolve positive and negative from the same sampler/output context. Track both
node ID and output slot; bound graph traversal and text construction. Unknown
conditioning operations and conflicting sampler candidates remain unresolved.
Never guess that the first encoder or most recent negative belongs to an image.
Import high-confidence pairs atomically with their image association and source
provenance. Repeated scans must not duplicate components, pairs or image links,
regenerate thumbnails, or increment generation run counts. Preserve existing
conflicting/manual associations for review. Report new, enriched, unchanged,
ambiguous, unresolved and failed results separately.
## Live node
Use the shared toolkit and Split foundation. Positive and negative editors have
equal size. Only positive receives prepend, append and automatic LoRA triggers.
Keep upstream connections authoritative. Library selection previews both fields
before an explicit load. Saving text changes creates another reusable combination.
Verify Classic and Nodes 2.0 startup, switching both directions, reload, caching,
linked inputs, popup behavior and lifecycle cleanup before maintainer testing.
## Delivery gate
Keep all existing coverage floors and test suites. Record regression failures
before implementing each behavior. Tests use disposable state and the managed
CPU host; never mutate the maintainer's running ComfyUI or personal database.
Python changes require a ComfyUI restart. Live approval is required before merge.
## Live testing
1. Download a database backup from Admin before the first restart. The additive
schema migration runs on startup; existing workflows and prompt IDs stay valid.
2. Restart ComfyUI, reload the browser, and add **Prompt Manager (+/-)** from
**ComfyAssets / Prompts**. Connect CLIP and the two conditioning outputs to the
sampler's positive and negative inputs.
3. Enter both texts, save a pair, then keep positive unchanged and change negative.
Recent should show both combinations with distinct negative excerpts. Selecting
one previews both; **Use pair** loads both together.
4. Test category/tag autocomplete and metadata opt-in, resizing, light/dark,
switching Classic / Nodes 2.0 both ways, and workflow reload. Connected fields
stay authoritative; run the node to save connected metadata values.
5. Queue the same recipe twice and change only negative once. Usage should count
dequeued jobs even when ComfyUI caches the node. Prepend, append and LoRA triggers
affect positive only. Gallery text is labelled as recorded during execution or
recovered from workflow metadata.
6. Run an output metadata scan to enrich existing images. Repeat it: pairs and
links must remain deduplicated, with unchanged results rather than new links.
Conflicting or ambiguous files need review and are preserved.
Fresh executions embed bounded text evidence in PNG metadata. A cached node may
not emit fresh evidence, so its graph-derived text is explicitly marked inferred.
Multiple distinct batch records without an image anchor remain unresolved.
Rescanning after interruption is safe through idempotent per-file writes; this
version does not persist a filesystem scan cursor across server restarts.
The API can request cancellation with `POST /prompt_manager/scan/cancel`; the
existing dashboard Hide button continues to hide progress without cancelling.
Pair APIs are under `/prompt_manager/pairs`: POST saves a combination, GET lists
and filters, GET/PATCH/DELETE `/{id}` reads, edits metadata or removes an unused
pair. Changing text creates another combination. Null metadata updates preserve
existing values; empty strings/lists clear supported fields. Archive a pair with
`PATCH {"archived":true}`. GET `/export` provides versioned JSON containing both
texts and pair metadata; this is an export format, not a JSON import endpoint.
## Gallery redesign concept retained
The maintainer approved the interactive
[negative variant timeline concept](concepts/prompt-variant-timeline.html) on
2026-10-17. Preserve it for the later gallery redesign; it is not production UI
and does not expand the current node work.
Keep one positive parent and one shared gallery, with red branches for reusable
negative variants and smaller dots for repeat runs. Show the selected negative
in full directly beneath the unchanged positive; selecting another variant or
run updates that text and filters the shared gallery. Comparison shows both
negatives. Distinguish explicitly empty negatives from an all-variants view and
saved variants from executed runs. Production history needs persisted execution
records; the prototype's timeline uses illustrative sample data.
The Admin upgrade/scan notice remains a separate follow-up: explain automatic
schema migration and offer an optional metadata enrichment scan or Later.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

-13
View File
@@ -1,13 +0,0 @@
# Native Library visual evidence
Captured from the production workspace using disposable synthetic browser-test
fixtures. No personal images, library, or running ComfyUI state was accessed.
Solid-color media are intentional test images.
- `library-dark.png`: grouped positive shelf, dark appearance.
- `library-light.png`: the same shelf, light appearance.
- `history-selected-negative.png`: selected negative directly below its positive,
saved-variant branches and the filtered shared gallery.
- `history-mobile.png`: the same history at a 390-pixel viewport.
These screenshots document implemented behavior, rather than a design mockup.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

+1 -14
View File
@@ -6,11 +6,6 @@ with persistent prompt storage and search capabilities.
import time
from typing import Any, Dict, Optional, Tuple
if __package__:
from .utils.execution_context_capture import record_runtime_context
else:
from utils.execution_context_capture import record_runtime_context
try:
from comfy.comfy_types import IO, ComfyNodeABC, InputTypeDict
except ImportError:
@@ -261,11 +256,7 @@ class PromptManager(PromptManagerBase, ComfyNodeABC):
)
self.logger.info(f"CLIP encoding completed, text: {repr(encoding_text)[:80]}")
result = (conditioning, encoding_text)
record_runtime_context(
type(self).__name__, text, prepend_text, append_text, encoding_text
)
return result
return (conditioning, encoding_text)
@classmethod
def IS_CHANGED(
@@ -305,7 +296,3 @@ class PromptManagerFocused(PromptManager):
{**options, "default": "", "forceInput": True},
)
return inputs
class PromptManagerSplit(PromptManagerFocused):
"""The persistent library layout shares Focused encoding and storage."""
-243
View File
@@ -1,243 +0,0 @@
"""Paired positive and negative conditioning with reusable recipe storage."""
import hashlib
import json
import time
from typing import Any, Dict, Optional, Tuple
if __package__:
from .utils.execution_context_capture import record_runtime_context
else:
from utils.execution_context_capture import record_runtime_context
try:
from .prompt_manager import IO, InputTypeDict, PromptManagerFocused
from .utils.hashing import generate_prompt_hash
from .utils.pair_execution import record_pair_execution
from .utils.prompt_graph import intrinsic_pair_nodes, run_prompt_components
from .utils.prompt_pair_resolver import resolve_text_input
from .utils.usage_tracking import (
PENDING_FIRST_USE,
PENDING_PAIR_FIRST_USE,
pair_run_key,
)
except ImportError:
from prompt_manager import IO, InputTypeDict, PromptManagerFocused
from utils.hashing import generate_prompt_hash
from utils.pair_execution import record_pair_execution
from utils.prompt_graph import intrinsic_pair_nodes, run_prompt_components
from utils.prompt_pair_resolver import resolve_text_input
from utils.usage_tracking import (
PENDING_FIRST_USE,
PENDING_PAIR_FIRST_USE,
pair_run_key,
)
class PromptManagerPair(PromptManagerFocused):
"""Keep raw editors as one recipe while encoding their effective texts."""
@classmethod
def INPUT_TYPES(cls) -> InputTypeDict:
inputs = super().INPUT_TYPES()
inputs["required"] = {
"text": inputs["required"]["text"],
"negative_text": (
IO.STRING,
{"default": "", "multiline": True, "dynamicPrompts": True},
),
"clip": inputs["required"]["clip"],
}
inputs["hidden"]["extra_pnginfo"] = "EXTRA_PNGINFO"
return inputs
RETURN_TYPES = (IO.CONDITIONING, IO.CONDITIONING, IO.STRING, IO.STRING)
RETURN_NAMES = ("positive", "negative", "positive_text", "negative_text")
OUTPUT_TOOLTIPS = (
"Positive conditioning with composition and optional LoRA trigger words.",
"Negative conditioning from the negative editor, including empty text.",
"The effective positive text that was encoded.",
"The unchanged negative text that was encoded.",
)
DESCRIPTION = "Encode and save a reusable positive/negative prompt pair."
def encode_prompt(
self,
clip: Any,
text: str,
negative_text: str = "",
category: str = "",
tags: str = "",
search_text: str = "",
prepend_text: str = "",
append_text: str = "",
prompt: Optional[Dict[str, Any]] = None,
unique_id: Optional[str] = None,
extra_pnginfo: Optional[Dict[str, Any]] = None,
) -> Tuple[Any, Any, str, str]:
"""Encode both sides once; database and tracking failures never block CLIP."""
if clip is None:
raise RuntimeError("ERROR: clip input is invalid: None")
if not isinstance(text, str) or not isinstance(negative_text, str):
raise ValueError("Positive and negative prompt text must be strings")
positive = self._inject_lora_trigger_words(
" ".join(
part
for part in (prepend_text.strip(), text, append_text.strip())
if part
)
)
positive_conditioning = clip.encode_from_tokens_scheduled(
clip.tokenize(positive)
)
negative_conditioning = clip.encode_from_tokens_scheduled(
clip.tokenize(negative_text)
)
record_pair_execution(
extra_pnginfo,
unique_id,
text=text,
negative_text=negative_text,
positive_text=positive,
negative_effective_text=negative_text,
prepend_text=prepend_text.strip(),
append_text=append_text.strip(),
)
pair_id = prompt_id = None
known = isinstance(prompt, dict) and unique_id is not None
node_id = str(unique_id)
used_fields = (
{
field
for source, field in run_prompt_components(prompt)
if source == node_id
}
if known
else {"text"}
)
is_positive = "text" in used_fields
pair_used = node_id in intrinsic_pair_nodes(prompt) if known else True
try:
pair_id = self.db.save_prompt_pair(
text,
negative_text,
category=category.strip() or None,
tags=self._parse_tags(tags),
)
pair = self.db.get_prompt_pair(pair_id)
prompt_id = pair["positive_prompt_id"]
resolved_positive = (
resolve_text_input(prompt, node_id, "text") if known else None
)
resolved_negative = (
resolve_text_input(prompt, node_id, "negative_text") if known else None
)
positive_seen = resolved_positive is not None and generate_prompt_hash(
resolved_positive
) == generate_prompt_hash(text)
pair_seen = (
positive_seen
and resolved_negative is not None
and generate_prompt_hash(resolved_negative)
== generate_prompt_hash(negative_text)
)
positive_pending = getattr(self, "pending_first_use", PENDING_FIRST_USE)
pair_pending = getattr(
self, "pending_pair_first_use", PENDING_PAIR_FIRST_USE
)
counted = set()
for field, component_text, resolved_text, component_id in (
("text", text, resolved_positive, prompt_id),
(
"negative_text",
negative_text,
resolved_negative,
pair["negative_prompt_id"],
),
):
component_hash = generate_prompt_hash(component_text)
if (
field not in used_fields
or not component_id
or component_hash in counted
):
continue
counted.add(component_hash)
seen_at_queue = (
resolved_text is not None
and generate_prompt_hash(resolved_text) == component_hash
)
runs = positive_pending.consume(component_hash) + int(not seen_at_queue)
if runs:
self.db.record_prompt_use(component_id, times=runs)
if pair_used:
runs = pair_pending.consume(pair_run_key(text, negative_text)) + int(
not pair_seen
)
if runs:
self.db.record_prompt_pair_use(pair_id, times=runs)
if is_positive:
self.prompt_tracker.set_current_prompt(
prompt_text=positive,
additional_data={
"prompt_id": prompt_id,
"pair_id": pair_id if pair_used else None,
"positive_text": positive,
"negative_text": negative_text,
"positive_storage_text": text.strip(),
"negative_storage_text": negative_text.strip(),
"prepend_text": prepend_text.strip(),
"append_text": append_text.strip(),
"final_text": positive,
"category": category.strip() or None,
"tags": self._parse_tags(tags) or [],
},
push_to_queue=not positive_seen or (pair_used and not pair_seen),
)
except Exception as error:
self.logger.warning(f"Could not save or track paired prompt: {error}")
try:
self.comfyui_integration.register_prompt(
f"promptmanager_pair_{time.time_ns()}",
positive,
{
"prompt_id": prompt_id,
"pair_id": pair_id if pair_used else None,
"negative_text": negative_text,
"category": category.strip() or None,
},
)
except Exception as error:
self.logger.warning(f"Could not register paired metadata: {error}")
result = positive_conditioning, negative_conditioning, positive, negative_text
record_runtime_context(
type(self).__name__,
text,
prepend_text,
append_text,
positive,
role="positive",
)
record_runtime_context(
type(self).__name__, negative_text, "", "", negative_text, role="negative"
)
return result
@classmethod
def IS_CHANGED(
cls,
clip: Any,
text: str = "",
negative_text: str = "",
category: str = "",
tags: str = "",
search_text: str = "",
prepend_text: str = "",
append_text: str = "",
**kwargs: Any,
) -> str:
"""Either editor, composition or pair metadata invalidates the node cache."""
serialized = json.dumps(
[text, negative_text, prepend_text, append_text, category, tags]
)
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
+1 -10
View File
@@ -6,11 +6,6 @@ without CLIP encoding, while maintaining all database and search features.
import time
from typing import Any, Dict, Optional, Tuple
if __package__:
from .utils.execution_context_capture import record_runtime_context
else:
from utils.execution_context_capture import record_runtime_context
try:
from comfy.comfy_types import IO, ComfyNodeABC, InputTypeDict
except ImportError:
@@ -232,11 +227,7 @@ class PromptManagerText(PromptManagerBase, ComfyNodeABC):
)
self.logger.debug(f"Text processing completed: {final_text[:100]}...")
result = (final_text,)
record_runtime_context(
type(self).__name__, text, prepend_text, append_text, final_text
)
return result
return (final_text,)
@classmethod
def IS_CHANGED(
+3 -19
View File
@@ -22,9 +22,6 @@ from aiohttp import web
from PIL import Image
from .prompts import PromptRoutesMixin
from .pairs import PairRoutesMixin
from .library import LibraryRoutesMixin
from .history import HistoryRoutesMixin
from .images import ImageRoutesMixin
from .admin import AdminRoutesMixin
from .logging_routes import LoggingRoutesMixin
@@ -87,7 +84,6 @@ def _public_error(exc: BaseException) -> str:
# doubles as the allow-list.
STATIC_MIME_TYPES = {
".js": "application/javascript",
".mjs": "application/javascript",
".css": "text/css",
".map": "application/json",
".woff": "font/woff",
@@ -244,9 +240,6 @@ async def _gzip_middleware(request, handler):
class PromptManagerAPI(
PairRoutesMixin,
LibraryRoutesMixin,
HistoryRoutesMixin,
PromptRoutesMixin,
ImageRoutesMixin,
AdminRoutesMixin,
@@ -269,14 +262,12 @@ class PromptManagerAPI(
db (PromptDatabase): Database connection and operations handler
"""
def __init__(self, db: PromptDatabase | None = None) -> None:
"""Initialize the API, using an injected database before any cleanup."""
def __init__(self):
"""Initialize the PromptManager API with database connection and cleanup."""
self.logger = get_logger("prompt_manager.api")
self.logger.info("Initializing PromptManager API")
self.db = db if db is not None else PromptDatabase()
self._history_read_fence = None
self._history_reads_closed = False
self.db = PromptDatabase()
self._cached_output_dir = None # Lazy-cached by _find_comfyui_output_dir()
self._html_cache = {} # Cached HTML file contents keyed by path
self._gallery_cache = {} # dict: path_str -> (files, timestamp)
@@ -370,10 +361,6 @@ class PromptManagerAPI(
# ── Web UI serving routes ─────────────────────────────────────
@routes.get("/prompt_manager/workspace")
async def serve_workspace_ui(request: web.Request) -> web.Response:
return self._serve_html_page("workspace.html", "Workspace")
@routes.get("/prompt_manager/web")
async def serve_web_ui(request):
return self._serve_html_page("index.html", "Web UI", cache=False)
@@ -404,9 +391,6 @@ class PromptManagerAPI(
# ── Register domain-specific routes from mixins ───────────────
self._register_pair_routes(routes)
self._register_library_routes(routes)
self._register_history_routes(routes)
self._register_prompt_routes(routes)
self._register_image_routes(routes)
self._register_admin_routes(routes)
+15 -105
View File
@@ -26,12 +26,8 @@ except ImportError:
try:
from ...database.operations import PromptDatabase
from ...database.pair_ingestion import ingest_resolved_pair
from ...utils.prompt_pair_resolver import resolve_prompt_pair
except ImportError:
from database.operations import PromptDatabase
from database.pair_ingestion import ingest_resolved_pair
from utils.prompt_pair_resolver import resolve_prompt_pair
try:
@@ -44,20 +40,6 @@ VIDEO_EXTENSIONS = (".mp4", ".avi", ".mov", ".mkv", ".webm", ".m4v", ".wmv")
SCAN_BATCH_SIZE = 50 # Files per executor call during the output scan
MAX_SAFETY_BACKUPS = 10 # '<db>.backup_*' files kept next to the live database
MAX_SCAN_FILES = 50_000 # Upper bound on media files one scan will look at
SCAN_COUNT_KEYS = (
"processed",
"found",
"added",
"linked",
"enriched",
"unchanged",
"ambiguous",
"unresolved",
"conflict",
"failed",
"pairs_added",
"pair_links_added",
)
MAX_SCAN_DEPTH = 12 # Directory levels below an output root that are scanned
# Files the duplicate cleaner may delete: everything the scan reports, plus
# the common single-f TIFF spelling. Anything else inside the output
@@ -377,10 +359,6 @@ class AdminRoutesMixin:
async def scan_images_route(request):
return await self.scan_images(request)
@routes.post("/prompt_manager/scan/cancel")
async def cancel_scan_route(request):
return await self.cancel_scan(request)
@routes.get("/prompt_manager/scan/status")
async def scan_status_route(request):
return await self.scan_status(request)
@@ -1248,24 +1226,9 @@ class AdminRoutesMixin:
{"success": False, "error": reason}, status=400
)
try:
backup_path = await self._run_in_executor(
model.restore_from_file, temp_path
)
except sqlite3.OperationalError as exc:
if (
getattr(exc, "_prompt_manager_restore_backup_created", False)
is True
):
try:
await self._run_in_executor(
_prune_safety_backups, model.db_path
)
except Exception:
self.logger.warning(
"Could not prune safety backups after failed restore"
)
raise
backup_path = await self._run_in_executor(
model.restore_from_file, temp_path
)
removed = await self._run_in_executor(_prune_safety_backups, model.db_path)
if removed:
self.logger.info(f"Removed {len(removed)} old safety backups")
@@ -1317,14 +1280,6 @@ class AdminRoutesMixin:
job = self._start_scan_job()
return await self._stream_scan_job(request, job)
async def cancel_scan(self, request):
"""Stop between file transactions; an in-flight extraction can finish."""
job = self._scan_job
requested = bool(job and job.running)
if requested:
job.cancelled = True
return web.json_response({"success": True, "cancellation_requested": requested})
async def scan_status(self, request):
"""Whether an output scan is running, and its latest progress event."""
job = self._scan_job
@@ -1396,7 +1351,7 @@ class AdminRoutesMixin:
media_files = await self._run_in_executor(
self._collect_output_media_sync, output_dirs
)
counts = dict.fromkeys(SCAN_COUNT_KEYS, 0)
counts = {"processed": 0, "found": 0, "added": 0, "linked": 0}
if media_files:
yield self._scan_progress(
5, f"Found {len(media_files)} media files to process...", counts
@@ -1409,9 +1364,7 @@ class AdminRoutesMixin:
f"found={counts['found']}, new_prompts_added={counts['added']}, "
f"images_linked_to_existing={counts['linked']}"
)
job = self._scan_job
final_type = "cancelled" if job and job.cancelled else "complete"
yield {"type": final_type, **counts, "directories": dir_names}
yield {"type": "complete", **counts, "directories": dir_names}
except Exception:
self.logger.exception("Scan error")
@@ -1427,8 +1380,6 @@ class AdminRoutesMixin:
"""
total = len(media_files)
for batch_start in range(0, total, SCAN_BATCH_SIZE):
if self._scan_job and self._scan_job.cancelled:
return
batch = media_files[batch_start : batch_start + SCAN_BATCH_SIZE]
batch_results = await self._run_in_executor(
self._extract_batch_metadata_sync, batch
@@ -1454,7 +1405,7 @@ class AdminRoutesMixin:
"progress": progress,
"status": status,
"processed": counts.get("processed", 0),
**{key: counts.get(key, 0) for key in SCAN_COUNT_KEYS},
"found": counts.get("found", 0),
}
def _collect_output_media_sync(self, output_dirs):
@@ -1480,64 +1431,25 @@ class AdminRoutesMixin:
def _ingest_scan_batch_sync(self, batch_results):
"""Store the prompts found in a metadata batch (blocking DB work)."""
counts = dict.fromkeys(SCAN_COUNT_KEYS, 0)
counts = {"processed": 0, "found": 0, "added": 0, "linked": 0}
for media_file, metadata in batch_results:
if self._scan_job and self._scan_job.cancelled:
break
counts["processed"] += 1
try:
outcome = self._ingest_scanned_file(media_file, metadata, detailed=True)
outcome = self._ingest_scanned_file(media_file, metadata)
except Exception as e:
self.logger.error(f"Error processing {media_file.name}: {e}")
counts["failed"] += 1
continue
if outcome is None:
continue
counts["found"] += 1
status = outcome["status"] if isinstance(outcome, dict) else outcome
if status in (
"added",
"linked",
"enriched",
"unchanged",
"ambiguous",
"unresolved",
"conflict",
"failed",
):
counts[status] += 1
if isinstance(outcome, dict):
counts["pairs_added"] += outcome.get("pairs_added", 0)
counts["pair_links_added"] += outcome.get("links_added", 0)
if outcome in ("added", "linked"):
counts[outcome] += 1
return counts
def _ingest_scanned_file(self, media_file, metadata, detailed=False):
"""Enrich exact pairs; retain legacy positive-only import for absent pairs."""
def _ingest_scanned_file(self, media_file, metadata):
"""Save or link one scanned file; returns 'added', 'linked', 'found' or None."""
if not metadata:
return None
canonical = dict(metadata)
for key in ("prompt", "workflow"):
if isinstance(canonical.get(key), str):
try:
canonical[key] = json.loads(canonical[key])
except (ValueError, TypeError):
# Preserve malformed metadata for conservative resolver rejection.
pass
if not canonical.get("prompt") and not canonical.get("parameters"):
return None
resolution = resolve_prompt_pair(canonical)
if resolution["state"] == "resolved":
result = ingest_resolved_pair(
self.db, str(media_file), canonical, resolution
)
return result if detailed else result["status"]
if resolution["state"] == "ambiguous":
return "ambiguous"
if resolution["state"] == "unresolved":
if canonical.get("prompt") or not isinstance(
canonical.get("parameters"), str
):
return resolution["state"]
parsed = self._parse_comfyui_prompt(metadata)
if not (parsed.get("prompt") or parsed.get("parameters")):
return None
@@ -1553,15 +1465,13 @@ class AdminRoutesMixin:
existing = self.db.get_prompt_by_hash(prompt_hash)
if existing:
try:
image_id = self.db.link_image_to_prompt(
existing["id"], str(media_file), metadata=canonical
)
self.db.link_image_to_prompt(existing["id"], str(media_file))
except Exception as e:
self.logger.error(
f"Failed to link {media_file.name} to existing prompt: {e}"
)
return "found"
return "linked" if image_id else "unchanged"
return "linked"
prompt_id = self.db.save_prompt(
prompt_text,
@@ -1574,7 +1484,7 @@ class AdminRoutesMixin:
if not prompt_id:
return "found"
try:
self.db.link_image_to_prompt(prompt_id, str(media_file), metadata=canonical)
self.db.link_image_to_prompt(prompt_id, str(media_file))
except Exception as e:
self.logger.error(f"Failed to link {media_file.name} to new prompt: {e}")
return "added"
-748
View File
@@ -1,748 +0,0 @@
"""Authenticated chronological windows and bounded frozen run details."""
import asyncio
import base64
import binascii
from functools import wraps
import json
import re
import sqlite3
from typing import Callable
from urllib.parse import urlencode
from aiohttp import web
from .library import _decode, _encode, _scope
from .history_current import (
CurrentLocationUnavailable,
CurrentOutputScope,
VIDEO_EXTENSIONS,
_OpenedCurrentMedia,
_current_media_response,
_prepare_current_media,
_reauthorize_current_target,
candidate_locator,
resolve_current_candidate,
snapshot_current_output_scope,
)
from .inspection_service import CurrentFileChanged, inspect_current_file
try:
from ...database.execution_history import HistoryEpochChanged
from ...database.history_gallery import read_history_gallery
from ...database.history_reads import (
HistoryCaptureChanged,
OutputPosition,
RunKey,
WindowPosition,
)
from ...utils.history_read_fence import HistoryReadFence
from ...utils.inspection_response import encode_history_inspection_response
except ImportError:
from database.execution_history import HistoryEpochChanged
from database.history_gallery import read_history_gallery
from database.history_reads import (
HistoryCaptureChanged,
OutputPosition,
RunKey,
WindowPosition,
)
from utils.history_read_fence import HistoryReadFence
from utils.inspection_response import encode_history_inspection_response
_RUN_MAX_BYTES = 8 * 1024 * 1024
_OUTPUTS_MAX_BYTES = 1024 * 1024
_UUID = re.compile(r"[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}")
_DIGEST = re.compile(r"[0-9a-f]{64}")
_DECIMAL = re.compile(r"0|[1-9][0-9]{0,18}")
_RUN_DECIMALS = (
"queue_item_id",
"observed_dequeue_at",
"observed_callback_at",
"executor_started_at",
"terminal_event_at",
)
_MESSAGES = {
"invalid-request": "Invalid history request",
"invalid-cursor": "Invalid history cursor; refresh recorded history",
"not-found": "Recorded history target not found",
"history-changed": "Recorded history changed; reload recorded history",
"storage-unavailable": "Recorded history is temporarily unavailable",
"response-limit": "Recorded history response exceeds the response limit",
"capture-changed": "Reported output capture changed; reload reported outputs",
"candidate-stale": "Current output locations changed; reload reported outputs",
"not-authorized": "Current output media is unavailable or not authorized",
}
class HistoryReadError(Exception):
"""A bounded public failure without private storage or capture details."""
def __init__(self, status: int, code: str, recovery: dict | None = None) -> None:
self.status = status
self.code = code
self.recovery = recovery
super().__init__(_MESSAGES[code])
def _error_response(error: HistoryReadError) -> web.Response:
return web.json_response(
{
"success": False,
"history_version": 1,
"code": error.code,
"error": str(error),
"recovery": error.recovery,
},
status=error.status,
headers={"Cache-Control": "no-store"},
)
def _history_errors(handler: Callable) -> Callable:
@wraps(handler)
async def wrapped(self, request: web.Request) -> web.Response:
try:
return await handler(self, request)
except HistoryReadError as error:
return _error_response(error)
except HistoryEpochChanged:
return _error_response(
HistoryReadError(409, "history-changed", {"action": "refresh-history"})
)
except ValueError:
return _error_response(HistoryReadError(400, "invalid-request"))
except (sqlite3.Error, OSError) as error:
self.logger.warning("History read unavailable (%s)", type(error).__name__)
return _error_response(
HistoryReadError(503, "storage-unavailable", {"action": "retry-read"})
)
return wrapped
def _decimal(value: object, *, minimum: int = 0) -> int:
if type(value) is not str or _DECIMAL.fullmatch(value) is None:
raise ValueError("Invalid decimal history integer")
parsed = int(value)
if not minimum <= parsed <= 2**63 - 1:
raise ValueError("History integer outside supported range")
return parsed
def _uuid(value: object) -> str:
if type(value) is not str or _UUID.fullmatch(value) is None:
raise ValueError("Invalid history UUID")
return value
def _query(request: web.Request) -> None:
if any(len(request.query.getall(key)) != 1 for key in request.query):
raise ValueError("Duplicate history query parameter")
def _page_limit(request: web.Request) -> int:
limit = _decimal(request.query.get("limit", "40"), minimum=1)
if limit > 100:
raise ValueError("History limit outside supported range")
return limit
def _decode_run_key(value: object) -> RunKey:
if type(value) is not list or len(value) != 4:
raise ValueError("Invalid history run key")
return (_decimal(value[0]), _uuid(value[1]), _decimal(value[2]), _uuid(value[3]))
def _wire_run_key(key: RunKey) -> list[str]:
return [str(key[0]), key[1], str(key[2]), key[3]]
def _cursor_payload(token: str, kind: str) -> object:
"""Use Library authentication, allowing only inline, domain-bound keys."""
try:
if not 1 <= len(token.encode("ascii")) <= 4096:
raise ValueError
raw = base64.b64decode(
token + "=" * (-len(token) % 4), altchars=b"-_", validate=True
)
wrapper = json.loads(raw[32:])
if type(wrapper) is not dict or set(wrapper) != {"scope", "key"}:
raise ValueError
return _decode(token, _scope({"kind": kind}))
except (ValueError, TypeError, binascii.Error, UnicodeError, RecursionError):
raise HistoryReadError(400, "invalid-cursor") from None
def _decode_window_cursor(
token: str | None, positive_id: int, *, resume: bool = False
) -> tuple[str | None, WindowPosition | None]:
if token is None:
return None, None
try:
value = _cursor_payload(token, "history-resume" if resume else "history-window")
if type(value) is not dict or set(value) != {
"version",
"epoch",
"positive_id",
"raw_text_id",
"upper",
} | (set() if resume else {"direction", "before"}):
raise ValueError
if (
type(value["version"]) is not int
or value["version"] != 1
or _decimal(value["positive_id"], minimum=1) != positive_id
or (not resume and value["direction"] != "older")
or type(value["raw_text_id"]) is not str
or _DIGEST.fullmatch(value["raw_text_id"]) is None
):
raise ValueError
epoch = _uuid(value["epoch"])
upper = _decode_run_key(value["upper"])
before = upper if resume else _decode_run_key(value["before"])
if before > upper:
raise ValueError
return epoch, WindowPosition(value["raw_text_id"], upper, before)
except ValueError:
raise HistoryReadError(400, "invalid-cursor") from None
def _encode_window_cursor(
position: WindowPosition | None,
epoch: str,
positive_id: int,
*,
resume: bool = False,
) -> str | None:
if position is None:
return None
value = {
"version": 1,
"epoch": epoch,
"positive_id": str(positive_id),
"raw_text_id": position.raw_text_id,
"upper": _wire_run_key(position.upper),
}
if not resume:
value.update(direction="older", before=_wire_run_key(position.before))
return _encode(
value,
_scope({"kind": "history-resume" if resume else "history-window"}),
)
def _node(value: object) -> str:
if type(value) is not str or not 1 <= len(value) <= 128 or "\0" in value:
raise ValueError("Invalid output node")
if len(value.encode("utf-8")) > 512:
raise ValueError("Invalid output node size")
return value
def _decode_output_cursor(
token: str | None, run_id: str, epoch: str
) -> OutputPosition | None:
if token is None:
return None
try:
value = _cursor_payload(token, "history-outputs")
if type(value) is not dict or set(value) != {
"version",
"epoch",
"run_id",
"capture_fingerprint",
"after",
}:
raise ValueError
if (
type(value["version"]) is not int
or value["version"] != 1
or _uuid(value["run_id"]) != run_id
or type(value["capture_fingerprint"]) is not str
or _DIGEST.fullmatch(value["capture_fingerprint"]) is None
or type(value["after"]) is not list
or len(value["after"]) != 2
or type(value["after"][1]) is not int
or not 0 <= value["after"][1] <= 255
):
raise ValueError
node = _node(value["after"][0])
if _uuid(value["epoch"]) != epoch:
raise HistoryReadError(
409, "history-changed", {"action": "refresh-history"}
)
return OutputPosition(value["capture_fingerprint"], (node, value["after"][1]))
except (ValueError, UnicodeError):
raise HistoryReadError(400, "invalid-cursor") from None
def _encode_output_cursor(
position: OutputPosition | None, run_id: str, epoch: str
) -> str | None:
if position is None:
return None
return _encode(
{
"version": 1,
"epoch": epoch,
"run_id": run_id,
"capture_fingerprint": position.capture_fingerprint,
"after": list(position.after),
},
_scope({"kind": "history-outputs"}),
)
def _current_stale() -> HistoryReadError:
return HistoryReadError(409, "candidate-stale", {"action": "reload-outputs"})
def _current_unavailable(reason: str) -> HistoryReadError:
if reason == "temporary_unavailable":
return HistoryReadError(503, "storage-unavailable", {"action": "retry-read"})
if reason == "missing":
return HistoryReadError(404, "not-found", {"action": "retry-read"})
return HistoryReadError(403, "not-authorized", {"action": "retry-read"})
def _check_current(
scope: CurrentOutputScope,
api: object,
fence: HistoryReadFence,
epoch: str,
generation: int,
) -> None:
if snapshot_current_output_scope(api).current_scope != scope.current_scope:
raise _current_stale()
fence.validate(epoch, generation)
def _media_descriptor(receipt: dict, candidate: dict, epoch: str) -> dict:
occurrence = receipt["occurrence"]
query = urlencode(
{
"node": occurrence["output_node_id"],
"ordinal": occurrence["image_ordinal"],
"epoch": epoch,
"current_scope": candidate["current_scope"],
}
)
base = f"/prompt_manager/history/runs/{occurrence['run_id']}/output/current"
return {
"kind": "reported-location",
"occurrence": occurrence,
"history_epoch": epoch,
"current_scope": candidate["current_scope"],
"location_key": candidate["location_key"],
"reference": receipt["reference"],
"filename": receipt["reference"]["filename"],
"is_video": receipt["reference"]["filename"]
.lower()
.endswith(tuple(VIDEO_EXTENSIONS)),
"url": f"{base}/media?{query}",
"thumbnail_url": f"{base}/thumbnail?{query}",
"inspection_url": f"{base}/inspection?{query}",
}
def _close_prepared_result(task: asyncio.Task) -> None:
try:
result = task.result()
except (Exception, asyncio.CancelledError):
return
if not isinstance(result, bytes):
result.close()
def _wire_run(run: dict) -> None:
run["diagnostics_omitted"] = bool(run["diagnostics_omitted"])
for key in _RUN_DECIMALS:
if run[key] is not None:
run[key] = str(run[key])
def _serialize_history(payload: dict, *, max_bytes: int | None = None) -> bytes:
try:
encoded = json.dumps(
payload, ensure_ascii=False, allow_nan=False, separators=(",", ":")
).encode("utf-8")
except (ValueError, TypeError, UnicodeError):
raise sqlite3.DatabaseError("Invalid recorded history payload") from None
if max_bytes is not None and len(encoded) > max_bytes:
raise HistoryReadError(422, "response-limit")
return encoded
def _success_response(encoded: bytes) -> web.Response:
return web.Response(
body=encoded,
content_type="application/json",
headers={"Cache-Control": "no-store"},
)
class HistoryRoutesMixin:
def _register_history_routes(self, routes: web.RouteTableDef) -> None:
routes.get("/prompt_manager/library/{positive_id}/history")(
self.get_library_history
)
routes.get("/prompt_manager/history/runs/{run_id}")(self.get_history_run)
routes.get("/prompt_manager/history/runs/{run_id}/outputs")(
self.get_history_outputs
)
routes.get("/prompt_manager/history/runs/{run_id}/output/current/media")(
self.get_history_current_media
)
routes.get("/prompt_manager/history/runs/{run_id}/output/current/thumbnail")(
self.get_history_current_thumbnail
)
routes.get("/prompt_manager/history/runs/{run_id}/output/current/inspection")(
self.get_history_current_inspection
)
def _history_fence(self) -> HistoryReadFence:
# Called synchronously by the event-loop owner before scheduling work.
if self._history_reads_closed:
raise sqlite3.OperationalError("History reads are closed")
if self._history_read_fence is None:
self._history_read_fence = HistoryReadFence(self.db)
return self._history_read_fence
def close_history_reads(self) -> None:
self._history_reads_closed = True
if self._history_read_fence is not None:
self._history_read_fence.close()
async def cleanup_history_reads(self, app: web.Application) -> None:
self.close_history_reads()
@_history_errors
async def get_library_history(self, request: web.Request) -> web.Response:
_query(request)
if set(request.query) - {"limit", "cursor", "resume"} or (
"cursor" in request.query and "resume" in request.query
):
raise ValueError("Invalid history window query")
positive_id = _decimal(request.match_info["positive_id"], minimum=1)
limit = _page_limit(request)
resuming = "resume" in request.query
expected_epoch, position = _decode_window_cursor(
request.query.get("resume" if resuming else "cursor"),
positive_id,
resume=resuming,
)
fence = self._history_fence()
db = self.db
def project() -> bytes:
generation = fence.begin()
try:
with db.recorded_history_snapshot(expected_epoch) as epoch:
try:
result = db.list_history_window(
positive_id, limit=limit, after=position, restart=resuming
)
except ValueError:
raise HistoryReadError(400, "invalid-cursor") from None
finally:
# Restore may retire this owner before a queued read opens it.
db.model.close()
if result is None:
fence.validate(epoch, generation)
raise HistoryReadError(404, "not-found")
result["next_cursor"] = _encode_window_cursor(
result.pop("next_position"), epoch, positive_id
)
result["resume_cursor"] = _encode_window_cursor(
result.pop("resume_position"), epoch, positive_id, resume=True
)
for run in result["items"]:
_wire_run(run)
encoded = _serialize_history(
{
"success": True,
"history_version": 1,
"history_epoch": epoch,
**result,
}
)
fence.validate(epoch, generation)
return encoded
return _success_response(await self._run_in_executor(project))
@_history_errors
async def get_history_run(self, request: web.Request) -> web.Response:
_query(request)
run_id = _uuid(request.match_info["run_id"])
expected_epoch = request.query.get("epoch")
if expected_epoch is not None:
_uuid(expected_epoch)
fence = self._history_fence()
db = self.db
def project() -> bytes:
generation = fence.begin()
try:
with db.recorded_history_snapshot(expected_epoch) as epoch:
result = db.get_history_run(run_id)
finally:
db.model.close()
if result is None:
fence.validate(epoch, generation)
raise HistoryReadError(404, "not-found")
_wire_run(result["run"])
encoded = _serialize_history(
{
"success": True,
"history_version": 1,
"history_epoch": epoch,
**result,
},
max_bytes=_RUN_MAX_BYTES,
)
fence.validate(epoch, generation)
return encoded
return _success_response(await self._run_in_executor(project))
def _history_gallery(
self, conn: sqlite3.Connection, paths: tuple[str, ...]
) -> tuple[dict, bool]:
try:
return read_history_gallery(conn, paths), False
except Exception as error:
self.logger.warning(
"Saved history display unavailable (%s)", type(error).__name__
)
return {}, True
@_history_errors
async def get_history_outputs(self, request: web.Request) -> web.Response:
_query(request)
run_id = _uuid(request.match_info["run_id"])
expected_epoch = _uuid(request.query.get("epoch"))
limit = _page_limit(request)
position = _decode_output_cursor(
request.query.get("cursor"), run_id, expected_epoch
)
fence = self._history_fence()
db = self.db
def project() -> bytes:
generation = fence.begin()
scope = snapshot_current_output_scope(self)
try:
with db.recorded_history_snapshot(expected_epoch) as epoch:
try:
result = db.list_history_outputs(
run_id, limit=limit, after=position
)
except HistoryCaptureChanged:
raise HistoryReadError(
409, "capture-changed", {"action": "reload-outputs"}
) from None
if result is not None:
paths = tuple(
dict.fromkeys(
path
for item in result["items"]
if (path := candidate_locator(item["reference"], scope))
is not None
)
)
gallery, saved_unavailable = self._history_gallery(
db.model.get_connection(), paths
)
finally:
db.model.close()
if result is None:
_check_current(scope, self, fence, epoch, generation)
raise HistoryReadError(404, "not-found")
resolved = {}
for receipt in result["items"]:
reference = receipt["reference"]
locator = tuple(reference.items())
if locator not in resolved:
try:
if saved_unavailable:
raise sqlite3.OperationalError(
"Optional saved display unavailable"
)
resolved[locator] = resolve_current_candidate(
reference, scope, gallery
).public
except Exception:
resolved[locator] = {
"status": "error",
"reason": "temporary_unavailable",
"evidence_class": "locator_candidate",
}
candidate = dict(resolved[locator])
if candidate["status"] == "available":
candidate["media"] = _media_descriptor(receipt, candidate, epoch)
receipt["current_candidate"] = candidate
result["next_cursor"] = _encode_output_cursor(
result.pop("next_position"), run_id, epoch
)
encoded = _serialize_history(
{
"success": True,
"history_version": 1,
"history_epoch": epoch,
**result,
}
)
_check_current(scope, self, fence, epoch, generation)
if len(encoded) > _OUTPUTS_MAX_BYTES:
recovery = (
{"action": "smaller-limit", "limit": max(1, limit // 2)}
if limit > 1
else None
)
raise HistoryReadError(422, "response-limit", recovery)
return encoded
return _success_response(await self._run_in_executor(project))
@_history_errors
async def get_history_current_media(
self, request: web.Request
) -> web.StreamResponse:
return await self._history_current(request, "media")
@_history_errors
async def get_history_current_thumbnail(
self, request: web.Request
) -> web.StreamResponse:
return await self._history_current(request, "thumbnail")
@_history_errors
async def get_history_current_inspection(
self, request: web.Request
) -> web.Response:
return await self._history_current(request, "inspection")
async def _history_current(
self, request: web.Request, kind: str
) -> web.StreamResponse:
_query(request)
run_id = _uuid(request.match_info["run_id"])
node = _node(request.query.get("node"))
ordinal = _decimal(request.query.get("ordinal"))
if ordinal > 255:
raise ValueError("Invalid output ordinal")
expected_epoch = _uuid(request.query.get("epoch"))
current_scope = request.query.get("current_scope")
if current_scope is None or _DIGEST.fullmatch(current_scope) is None:
raise ValueError("Invalid current output scope")
fence = self._history_fence()
db = self.db
def project() -> bytes | _OpenedCurrentMedia:
generation = fence.begin()
scope = snapshot_current_output_scope(self)
try:
with db.recorded_history_snapshot(expected_epoch) as epoch:
if scope.current_scope != current_scope:
raise _current_stale()
result = db.get_history_occurrence(run_id, node, ordinal)
if result is not None:
receipt = result["receipt"]
path = candidate_locator(receipt["reference"], scope)
gallery, saved_unavailable = self._history_gallery(
db.model.get_connection(),
(path,) if path is not None else (),
)
finally:
db.model.close()
if result is None:
_check_current(scope, self, fence, epoch, generation)
raise HistoryReadError(404, "not-found")
resolved = resolve_current_candidate(receipt["reference"], scope, gallery)
if resolved.public["status"] != "available":
_check_current(scope, self, fence, epoch, generation)
raise _current_unavailable(resolved.public["reason"])
prepared = None
try:
if kind == "inspection":
saved = gallery.get(path, {})
_reauthorize_current_target(resolved, scope)
observed = inspect_current_file(
resolved.canonical_target,
source_label=resolved.public["source_label"],
saved=saved.get("saved"),
library=saved.get(
"library",
{"status": "unavailable" if saved_unavailable else "none"},
),
)
_reauthorize_current_target(resolved, scope)
identity = {
**receipt,
"history_epoch": epoch,
"current_scope": scope.current_scope,
"location_key": resolved.public["location_key"],
"evidence_class": "locator_candidate",
"observation": "current-file",
}
try:
prepared = encode_history_inspection_response(
current_output=identity, **observed
)
except ValueError:
_check_current(scope, self, fence, epoch, generation)
raise HistoryReadError(422, "response-limit") from None
else:
prepared = _prepare_current_media(
resolved, scope, thumbnail=kind == "thumbnail"
)
_check_current(scope, self, fence, epoch, generation)
return prepared
except CurrentFileChanged:
if prepared is not None and not isinstance(prepared, bytes):
prepared.close()
_check_current(scope, self, fence, epoch, generation)
raise _current_stale() from None
except CurrentLocationUnavailable as error:
if prepared is not None and not isinstance(prepared, bytes):
prepared.close()
_check_current(scope, self, fence, epoch, generation)
raise (
_current_stale()
if error.stale
else _current_unavailable(error.reason)
) from None
except BaseException as error:
if prepared is not None and not isinstance(prepared, bytes):
prepared.close()
if isinstance(error, Exception) and not isinstance(
error, (HistoryReadError, HistoryEpochChanged, sqlite3.Error)
):
_check_current(scope, self, fence, epoch, generation)
raise
pending = asyncio.create_task(self._run_in_executor(project))
try:
prepared = await asyncio.shield(pending)
except asyncio.CancelledError:
pending.add_done_callback(_close_prepared_result)
raise
except (
HistoryReadError,
HistoryEpochChanged,
sqlite3.Error,
OSError,
ValueError,
):
raise
except Exception as error:
self.logger.warning(
"Current history media unavailable (%s)", type(error).__name__
)
raise _current_unavailable("temporary_unavailable") from None
if isinstance(prepared, bytes):
return _success_response(prepared)
return await _current_media_response(request, prepared)
-399
View File
@@ -1,399 +0,0 @@
"""Native reported locations and already-opened current media, without ownership."""
import asyncio
from dataclasses import dataclass
import errno
import hashlib
import hmac
import json
import mimetypes
import os
from pathlib import Path
import stat
from typing import BinaryIO
from aiohttp import web
from .images import _canonical, _public_path, SERVABLE_EXTENSIONS, VIDEO_EXTENSIONS
try:
from ...utils.execution_outputs import (
OutputReference,
OutputRoots,
snapshot_output_roots,
validate_output_reference,
)
except ImportError:
from utils.execution_outputs import (
OutputReference,
OutputRoots,
snapshot_output_roots,
validate_output_reference,
)
_SECRET = os.urandom(32)
_READ_SIZE = 64 * 1024
def _opaque(value: object) -> str:
raw = json.dumps(value, ensure_ascii=True, separators=(",", ":")).encode("ascii")
return hmac.new(_SECRET, raw, hashlib.sha256).hexdigest()
@dataclass(frozen=True)
class CurrentOutputScope:
path_style: str
lexical_output: str | None
lexical_temp: str | None
roots: OutputRoots
canonical_output: Path | None
canonical_temp: Path | None
authorized_roots: tuple[Path, ...]
current_scope: str
@dataclass(frozen=True)
class ResolvedCurrentCandidate:
public: dict
canonical_target: Path | None = None
namespace: Path | None = None
gallery_root: Path | None = None
class CurrentLocationUnavailable(Exception):
"""A bounded current-file failure without the underlying private path."""
def __init__(self, reason: str, *, stale: bool = False) -> None:
self.reason = reason
self.stale = stale
super().__init__(reason)
def _host_directory(name: str) -> str | None:
try:
import folder_paths
value = getattr(folder_paths, name)()
except (ImportError, AttributeError, OSError):
return None
return value if type(value) is str else None
def snapshot_current_output_scope(api: object) -> CurrentOutputScope:
"""Bind host lexical namespaces independently of current gallery authority."""
from ..config import GalleryConfig
style = "windows" if os.name == "nt" else "posix"
output = _host_directory("get_output_directory")
temp = _host_directory("get_temp_directory")
roots = snapshot_output_roots(style, output, temp)
canonical_output = _canonical(output) if roots.output_key is not None else None
canonical_temp = _canonical(temp) if roots.temp_key is not None else None
authorized = tuple(
sorted({_canonical(path) for path in api._get_all_output_dirs()})
)
policy = []
for entry in GalleryConfig.MONITORING_DIRECTORIES:
valid, _ = GalleryConfig.validate_gallery_root(entry)
policy.append((entry if type(entry) is str else None, valid))
current_scope = _opaque(
[
"prompt-manager-current-output-v1",
style,
output,
temp,
roots.output_key,
roots.temp_key,
str(canonical_output) if canonical_output is not None else None,
str(canonical_temp) if canonical_temp is not None else None,
[str(path) for path in authorized],
sorted(
str(_canonical(path))
for path in GalleryConfig.allowed_gallery_parents()
),
sorted(policy, key=lambda item: (item[0] or "", item[1])),
sorted(str(_canonical(path)) for path in GalleryConfig.path_anchors()),
"gallery-validation-v1",
]
)
return CurrentOutputScope(
style,
output,
temp,
roots,
canonical_output,
canonical_temp,
authorized,
current_scope,
)
def _descriptor_reason(reference: dict, scope: CurrentOutputScope) -> str | None:
try:
validate_output_reference(OutputReference("current", 0, **reference))
except (TypeError, ValueError):
return "namespace_mismatch"
if reference["path_style"] != scope.path_style:
return "foreign_path_style"
folder = reference["folder_type"]
expected = scope.roots.output_key if folder == "output" else scope.roots.temp_key
if reference["root_key"] is None or expected is None:
return "root_unavailable"
if reference["root_key"] != expected:
return "namespace_mismatch"
return None
def candidate_locator(reference: dict, scope: CurrentOutputScope) -> str | None:
"""Validate the native namespace before constructing a lexical path; no IO."""
if _descriptor_reason(reference, scope) is not None:
return None
root = (
scope.lexical_output
if reference["folder_type"] == "output"
else scope.lexical_temp
)
return os.path.normcase(
os.path.normpath(
os.path.join(root, reference["subfolder"], reference["filename"])
)
)
def _authorized(target: Path, namespace: Path, scope: CurrentOutputScope) -> bool:
return target.is_relative_to(namespace) and any(
target.is_relative_to(root) for root in scope.authorized_roots
)
def _unavailable(reason: str, *, error: bool = False) -> ResolvedCurrentCandidate:
return ResolvedCurrentCandidate(
{
"status": "error" if error else "unavailable",
"reason": reason,
"evidence_class": "locator_candidate",
}
)
def resolve_current_candidate(
reference: dict, scope: CurrentOutputScope, gallery: dict
) -> ResolvedCurrentCandidate:
"""Observe only this exact current locator and its snapshot-owned association."""
reason = _descriptor_reason(reference, scope)
if reason is not None:
return _unavailable(reason)
lexical = candidate_locator(reference, scope)
namespace = (
scope.canonical_output
if reference["folder_type"] == "output"
else scope.canonical_temp
)
try:
if Path(reference["filename"]).suffix.lower() not in SERVABLE_EXTENSIONS:
return _unavailable("unsupported_media")
if namespace is None or not namespace.is_dir():
return _unavailable("root_unavailable")
target = _canonical(lexical)
if not _authorized(target, namespace, scope):
return _unavailable("not_authorized")
if target.suffix.lower() not in SERVABLE_EXTENSIONS:
return _unavailable("unsupported_media")
observed = target.stat()
if not stat.S_ISREG(observed.st_mode):
return _unavailable("not_regular_file")
if not os.access(target, os.R_OK) or not observed.st_mode & 0o444:
return _unavailable("unreadable")
matching = gallery.get(lexical, {})
root = max(
(root for root in scope.authorized_roots if target.is_relative_to(root)),
key=lambda root: len(root.parts),
)
return ResolvedCurrentCandidate(
{
"status": "available",
"reason": None,
"evidence_class": "locator_candidate",
"gallery_match": matching.get("gallery_match", "none"),
"gallery_image_id": matching.get("gallery_image_id"),
"source_label": _public_path(namespace),
"relative_path": Path(
reference["subfolder"], reference["filename"]
).as_posix(),
"current_scope": scope.current_scope,
"location_key": _opaque(
["current-location-v1", scope.current_scope, str(target)]
),
},
target,
namespace,
root,
)
except FileNotFoundError:
return _unavailable("missing")
except PermissionError:
return _unavailable("unreadable")
except (OSError, ValueError):
return _unavailable("temporary_unavailable", error=True)
def _open_current_target(
target: Path, namespace: Path, scope: CurrentOutputScope
) -> BinaryIO:
"""Open only the canonical target and verify authority against the actual handle."""
if _canonical(target) != target:
raise CurrentLocationUnavailable("not_authorized", stale=True)
if not _authorized(target, namespace, scope):
raise CurrentLocationUnavailable("not_authorized")
if target.suffix.lower() not in SERVABLE_EXTENSIONS:
raise CurrentLocationUnavailable("unsupported_media")
flags = (
os.O_RDONLY
| getattr(os, "O_BINARY", 0)
| getattr(os, "O_NOFOLLOW", 0)
| getattr(os, "O_NONBLOCK", 0)
)
try:
fd = os.open(target, flags)
except FileNotFoundError:
raise CurrentLocationUnavailable("missing") from None
except PermissionError:
raise CurrentLocationUnavailable("unreadable") from None
except OSError as error:
if error.errno in (errno.ELOOP, errno.ENOTDIR):
raise CurrentLocationUnavailable("not_authorized", stale=True) from None
raise CurrentLocationUnavailable("temporary_unavailable") from None
try:
observed = os.fstat(fd)
if not stat.S_ISREG(observed.st_mode):
raise CurrentLocationUnavailable("not_regular_file")
if not observed.st_mode & 0o444 or not os.access(target, os.R_OK):
raise CurrentLocationUnavailable("unreadable")
current = os.stat(target, follow_symlinks=False)
if (
_canonical(target) != target
or (observed.st_dev, observed.st_ino) != (current.st_dev, current.st_ino)
or not _authorized(target, namespace, scope)
):
raise CurrentLocationUnavailable("not_authorized", stale=True)
return os.fdopen(fd, "rb")
except FileNotFoundError:
os.close(fd)
raise CurrentLocationUnavailable("missing") from None
except PermissionError:
os.close(fd)
raise CurrentLocationUnavailable("unreadable") from None
except BaseException:
os.close(fd)
raise
def _reauthorize_current_target(
resolved: ResolvedCurrentCandidate, scope: CurrentOutputScope
) -> None:
if _canonical(
resolved.canonical_target
) != resolved.canonical_target or not _authorized(
resolved.canonical_target, resolved.namespace, scope
):
raise CurrentLocationUnavailable("not_authorized", stale=True)
@dataclass
class _OpenedCurrentMedia:
handle: BinaryIO
size: int
content_type: str
def close(self) -> None:
self.handle.close()
def _prepare_current_media(
resolved: ResolvedCurrentCandidate, scope: CurrentOutputScope, *, thumbnail: bool
) -> _OpenedCurrentMedia:
target = resolved.canonical_target
if thumbnail:
relative = target.relative_to(resolved.gallery_root)
extension = (
".jpg"
if target.suffix.lower() in VIDEO_EXTENSIONS
else target.suffix.lower()
)
preview = (
resolved.gallery_root
/ "thumbnails"
/ f"{relative.with_suffix('').as_posix()}_thumb{extension}"
)
try:
preview.stat()
except FileNotFoundError:
pass
else:
target = _canonical(preview)
handle = _open_current_target(target, resolved.namespace, scope)
try:
return _OpenedCurrentMedia(
handle,
os.fstat(handle.fileno()).st_size,
mimetypes.guess_type(target.name)[0] or "application/octet-stream",
)
except BaseException:
handle.close()
raise
async def _current_media_response(
request: web.Request, opened: _OpenedCurrentMedia
) -> web.StreamResponse:
"""Stream a bounded single range from a previously authorized handle."""
try:
try:
selected = request.http_range
start, stop = selected.start, selected.stop
partial = request.headers.get("Range") is not None
if (
partial
and request.headers["Range"].startswith("bytes=-")
and start == 0
):
raise ValueError("Empty suffix range")
if start is not None and start < 0:
start = max(0, opened.size + start)
start = start or 0
stop = min(opened.size, stop) if stop is not None else opened.size
if partial and (start >= stop or start >= opened.size):
raise ValueError("Unsatisfiable current media range")
except ValueError:
return web.Response(
status=416,
headers={
"Cache-Control": "no-store",
"Content-Range": f"bytes */{opened.size}",
},
)
headers = {
"Cache-Control": "no-store",
"Accept-Ranges": "bytes",
"Content-Type": opened.content_type,
"Content-Length": str(stop - start),
}
if partial:
headers["Content-Range"] = f"bytes {start}-{stop - 1}/{opened.size}"
response = web.StreamResponse(status=206 if partial else 200, headers=headers)
await response.prepare(request)
if request.method != "HEAD":
opened.handle.seek(start)
remaining = stop - start
while remaining:
chunk = await asyncio.get_running_loop().run_in_executor(
None, opened.handle.read, min(_READ_SIZE, remaining)
)
if not chunk:
break
remaining -= len(chunk)
await response.write(chunk)
await response.write_eof()
return response
finally:
opened.close()
+34 -430
View File
@@ -1,17 +1,13 @@
"""Image and gallery API routes for PromptManager."""
import asyncio
import base64
import binascii
import hashlib
import hmac
import json
import os
import re
import threading
import time as _time
import urllib.parse
from collections.abc import Mapping
from pathlib import Path
from aiohttp import web
@@ -20,23 +16,29 @@ from PIL import Image, UnidentifiedImageError
from .prompts import bad_request, parse_page_params, publish_image_paths
from .prompts import thumbnail_url_for
from .prompts import safe_error_message as _safe_error
from .inspection_service import (
CurrentFileChanged,
IMAGE_EXTENSIONS as _IMAGE_EXTENSIONS,
SERVABLE_EXTENSIONS,
VIDEO_EXTENSIONS,
inspect_current_file,
)
try:
from ...utils.parallel import map_parallel
from ...utils.inspection_response import encode_inspection_response
except ImportError:
from utils.parallel import map_parallel
from utils.inspection_response import encode_inspection_response
IMAGE_EXTENSIONS = _IMAGE_EXTENSIONS
# Only these file types are ever served by the image routes, regardless of
# what sits inside an allowed directory (defence in depth for the gallery).
IMAGE_EXTENSIONS = frozenset(
{
".png",
".jpg",
".jpeg",
".webp",
".gif",
".bmp",
".tiff",
".tif",
".mp4",
".webm",
".mov",
}
)
def _public_path(path):
@@ -88,7 +90,11 @@ def _media_access_error(path, allowed_dirs):
# Media types the gallery scans and thumbnails (a superset is never served).
GALLERY_IMAGE_EXTENSIONS = frozenset({".png", ".jpg", ".jpeg", ".webp", ".gif"})
VIDEO_EXTENSIONS = frozenset({".mp4", ".webm", ".avi", ".mov", ".mkv", ".m4v", ".wmv"})
MEDIA_EXTENSIONS = GALLERY_IMAGE_EXTENSIONS | VIDEO_EXTENSIONS
# What the serve routes hand out: every image type above plus every video type
# the gallery lists, so a card never points at a 403.
SERVABLE_EXTENSIONS = IMAGE_EXTENSIONS | VIDEO_EXTENSIONS
# Caps on request-triggered filesystem work.
MAX_SCAN_DEPTH = 12
@@ -222,70 +228,6 @@ MAX_JSON_BODY_BYTES = 1024 * 1024
_READ_CHUNK = 64 * 1024
_WORKSPACE_CURSOR_SECRET = os.urandom(32)
def _workspace_root(query: dict, roots: list[Path]) -> int | None:
value = query.get("root")
if value is None:
return None
try:
index = int(value)
except ValueError:
raise ValueError("Invalid gallery root") from None
if not 0 <= index < len(roots):
raise ValueError("Invalid gallery root")
return index
def _workspace_key(entry: tuple, order: str) -> tuple:
path, mtime, root, index = entry
relative = path.relative_to(root).as_posix()
tie = hashlib.sha256(relative.encode("utf-8")).hexdigest()
name = path.name.casefold()
if order in ("name", "name-desc"):
return (name, tie, index, mtime)
return ((-mtime if order == "newest" else mtime), name, tie, index)
def _workspace_cursor(key: tuple, scope: str) -> str:
payload = json.dumps(
{"v": 1, "scope": scope, "key": key}, separators=(",", ":"), ensure_ascii=False
).encode()
signature = hmac.new(_WORKSPACE_CURSOR_SECRET, payload, hashlib.sha256).digest()
return base64.urlsafe_b64encode(signature + payload).decode().rstrip("=")
def _workspace_cursor_key(token: str, scope: str, order: str) -> tuple:
try:
if len(token) > 4096:
raise ValueError()
raw = base64.b64decode(
token + "=" * (-len(token) % 4), altchars=b"-_", validate=True
)
signature, payload = raw[:32], raw[32:]
expected = hmac.new(_WORKSPACE_CURSOR_SECRET, payload, hashlib.sha256).digest()
if not hmac.compare_digest(signature, expected):
raise ValueError()
data = json.loads(payload)
key = data["key"]
if data["v"] != 1 or data["scope"] != scope or not isinstance(key, list):
raise ValueError()
types = (
(str, str, int, (float, int))
if order in ("name", "name-desc")
else ((float, int), str, str, int)
)
if len(key) != len(types) or not all(
type(value) in (kind if isinstance(kind, tuple) else (kind,))
for value, kind in zip(key, types)
):
raise ValueError()
return tuple(key)
except (ValueError, KeyError, TypeError, UnicodeError, binascii.Error):
raise ValueError("Invalid gallery cursor; refresh the gallery") from None
class _BodyTooLarge(Exception):
"""Raised when a request body exceeds MAX_JSON_BODY_BYTES."""
@@ -356,9 +298,7 @@ def _iter_media_files(output_path, max_files):
yield root_path / name
def _output_image_entry(
media_path, output_path, root_index, public_root=None, *, root_scope=None
):
def _output_image_entry(media_path, output_path, root_index, public_root=None):
"""Build one gallery entry for *media_path* (blocking stat).
``path`` is relative to its root (what delete/autotag resolve against) and
@@ -379,22 +319,15 @@ def _output_image_entry(
if thumbnail_url is not None:
thumbnail_url += f"&root={root_index}"
encoded_path = urllib.parse.quote(rel_path.as_posix(), safe="/")
url = f"/prompt_manager/images/serve/{encoded_path}?root={root_index}"
if root_scope is not None:
url += f"&root_scope={root_scope}"
if thumbnail_url is not None:
thumbnail_url += f"&root_scope={root_scope}"
entry = {
"id": hashlib.sha1(
str(media_path).encode("utf-8"), usedforsecurity=False
).hexdigest()[:16],
return {
"id": hashlib.sha1(str(media_path).encode("utf-8")).hexdigest()[:16],
"filename": media_path.name,
"path": rel_path.as_posix(),
"relative_path": str(rel_path),
"root_dir": public_root,
"root_index": root_index,
"url": url,
"url": f"/prompt_manager/images/serve/{encoded_path}?root={root_index}",
"thumbnail_url": thumbnail_url,
"size": stat.st_size,
"modified_time": stat.st_mtime,
@@ -402,9 +335,6 @@ def _output_image_entry(
"media_type": "video" if is_video else "image",
"is_video": is_video,
}
if root_scope is not None:
entry["root_scope"] = root_scope
return entry
def _in_subfolder(rel_dir, subfolder):
@@ -414,48 +344,6 @@ def _in_subfolder(rel_dir, subfolder):
class ImageRoutesMixin:
"""Mixin providing image and gallery-related API endpoints."""
def _gallery_root_scope(self, root: Path, index: int) -> str:
payload = json.dumps(
["prompt-manager-gallery-root-v1", index, str(_canonical(root))],
separators=(",", ":"),
ensure_ascii=False,
).encode("utf-8")
return hmac.new(_WORKSPACE_CURSOR_SECRET, payload, hashlib.sha256).hexdigest()
def _gallery_scope_error(
self, query: Mapping, roots: list[Path]
) -> web.Response | None:
if "root_scope" not in query:
return None
scope, root = query.get("root_scope"), query.get("root")
if (
not isinstance(scope, str)
or re.fullmatch(r"[0-9a-f]{64}", scope) is None
or not isinstance(root, str)
or re.fullmatch(r"[0-9]+", root) is None
):
return bad_request("Invalid gallery root scope")
try:
index = int(root)
except ValueError:
return bad_request("Invalid gallery root scope")
if index >= len(roots) or not hmac.compare_digest(
scope, self._gallery_root_scope(roots[index], index)
):
return web.json_response(
{
"success": False,
"code": "source-changed",
"error": (
"The source changed. Refresh the image collection before "
"inspecting this file."
),
},
status=409,
headers={"Cache-Control": "no-store"},
)
return None
def _register_image_routes(self, routes):
@routes.get("/prompt_manager/prompts/{prompt_id}/images")
async def get_prompt_images_route(request):
@@ -493,10 +381,6 @@ class ImageRoutesMixin:
async def get_image_prompt_route(request):
return await self.get_image_prompt(request)
@routes.get("/prompt_manager/images/inspect/{image_path:.*}")
async def inspect_image_route(request):
return await self.inspect_image(request)
@routes.delete("/prompt_manager/images/{image_id}")
async def delete_image_route(request):
return await self.delete_image(request)
@@ -710,8 +594,6 @@ class ImageRoutesMixin:
"""Get all images from ComfyUI output folder(s)."""
try:
output_dirs = self._get_all_output_dirs()
if request.query.get("mode") == "workspace":
return await self._get_workspace_images(request, list(output_dirs))
if not output_dirs:
return web.json_response(
{
@@ -747,119 +629,6 @@ class ImageRoutesMixin:
{"success": False, "error": _safe_error(e)}, status=500
)
async def _get_workspace_images(
self, request, output_dirs: list[Path]
) -> web.Response:
try:
limit, _offset = parse_page_params(request.query, default_limit=100)
except ValueError:
return bad_request("limit and offset must be integers")
try:
root_index = _workspace_root(request.query, output_dirs)
query = request.query.get("q", "").strip().casefold()
media = request.query.get("media", "all")
order = request.query.get("sort", "newest")
folder = request.query.get("subfolder", "").strip().replace("\\", "/")
if (
len(query) > 500
or media not in ("all", "image", "video")
or order not in ("newest", "oldest", "name", "name-desc")
):
raise ValueError("Invalid gallery filters")
if folder and (_has_traversal(folder) or len(folder) > 1024):
raise ValueError("Invalid gallery folder")
scope = hashlib.sha256(
json.dumps(
[
query,
media,
order,
folder,
root_index,
[str(root) for root in output_dirs],
]
).encode()
).hexdigest()
token = request.query.get("cursor")
after = _workspace_cursor_key(token, scope, order) if token else None
except ValueError as exc:
return bad_request(str(exc))
listings = [
(index, root, await self._get_gallery_files(root))
for index, root in enumerate(output_dirs)
if root_index is None or index == root_index
]
page, total, has_more, next_cursor = await self._run_in_executor(
self._select_workspace_page,
listings,
folder,
query,
media,
order,
after,
limit,
scope,
)
images = await self._run_in_executor(self._format_workspace_page, page)
return web.json_response(
{
"success": True,
"images": images,
"total": total,
"limit": limit,
"has_more": has_more,
"next_cursor": next_cursor,
}
)
@staticmethod
def _select_workspace_page(
listings: list[tuple],
folder: str,
query: str,
media: str,
order: str,
after: tuple | None,
limit: int,
scope: str,
) -> tuple:
entries = []
for index, root, files in listings:
for path, mtime in files:
relative = path.relative_to(root)
if folder and not _in_subfolder(
str(relative.parent), folder.replace("/", os.sep)
):
continue
if query and query not in relative.as_posix().casefold():
continue
kind = "video" if path.suffix.lower() in VIDEO_EXTENSIONS else "image"
if media != "all" and media != kind:
continue
entries.append((path, mtime, root, index))
total = len(entries)
reverse = order == "name-desc"
entries.sort(key=lambda entry: _workspace_key(entry, order), reverse=reverse)
if after is not None:
entries = [
entry
for entry in entries
if (
_workspace_key(entry, order) < after
if reverse
else _workspace_key(entry, order) > after
)
]
page = entries[:limit]
has_more = len(entries) > limit
next_cursor = (
_workspace_cursor(_workspace_key(page[-1], order), scope)
if has_more
else None
)
return page, total, has_more, next_cursor
async def serve_image(self, request):
"""Serve the actual image file using streamed FileResponse."""
try:
@@ -945,17 +714,15 @@ class ImageRoutesMixin:
async def serve_output_image(self, request):
"""Serve a media file from the ComfyUI output folder(s)."""
try:
roots = list(self._get_all_output_dirs())
scope_error = self._gallery_scope_error(request.query, roots)
if scope_error is not None:
return scope_error
filepath = request.match_info["filepath"]
if _has_traversal(filepath):
return _forbidden()
if Path(filepath).suffix.lower() not in SERVABLE_EXTENSIONS:
return _forbidden("Only media files can be served")
output_dirs = self._select_root(roots, request.query.get("root"))
output_dirs = self._select_root(
list(self._get_all_output_dirs()), request.query.get("root")
)
if not output_dirs:
return _forbidden("No allowed image directories configured")
@@ -1008,20 +775,7 @@ class ImageRoutesMixin:
async def get_gallery_subfolders(self, request):
"""Get distinct subfolders from gallery output directories."""
try:
output_dirs = list(self._get_all_output_dirs())
workspace = request.query.get("mode") == "workspace"
roots = None
if workspace:
try:
selected = _workspace_root(request.query, output_dirs)
except ValueError as exc:
return bad_request(str(exc))
roots = [
{"index": index, "label": _public_path(root)}
for index, root in enumerate(output_dirs)
]
if selected is not None:
output_dirs = [output_dirs[selected]]
output_dirs = self._get_all_output_dirs()
subfolders = set()
for output_path in output_dirs:
files = await self._get_gallery_files(output_path)
@@ -1041,10 +795,9 @@ class ImageRoutesMixin:
ancestors.add("/".join(parts[:i]))
subfolders.update(ancestors)
payload = {"success": True, "subfolders": sorted(subfolders)}
if workspace:
payload["roots"] = roots
return web.json_response(payload)
return web.json_response(
{"success": True, "subfolders": sorted(subfolders)}
)
except Exception as e:
self.logger.error(f"Subfolders error: {e}")
return web.json_response(
@@ -1679,127 +1432,6 @@ class ImageRoutesMixin:
{"success": False, "error": _safe_error(e)}, status=500
)
async def inspect_image(self, request):
"""Read prompt evidence for one explicitly rooted physical media file."""
roots = list(self._get_all_output_dirs())
scope_error = self._gallery_scope_error(request.query, roots)
if scope_error is not None:
return scope_error
try:
index = _workspace_root(request.query, roots)
if index is None:
raise ValueError("Gallery root is required")
except ValueError as error:
return bad_request(str(error))
root_scope = request.query.get("root_scope") or self._gallery_root_scope(
roots[index], index
)
relative = request.match_info.get("image_path", "")
if (
_has_traversal(relative)
or "\0" in relative
or re.match(r"^[a-zA-Z]:", relative)
):
return _forbidden()
path = Path(roots[index]) / relative
denied = _media_access_error(path, [roots[index]])
if denied is not None:
return denied
if not path.is_file():
return web.json_response(
{"success": False, "error": "Image file not found"}, status=404
)
try:
def inspect() -> bytes | None:
saved, unavailable = None, False
try:
saved = self.db.get_image_prompt_info(
str(path), False, report_ambiguity=True
)
except Exception:
unavailable = True
self.logger.warning("Saved inspection display could not be read")
try:
observed = inspect_current_file(
path,
source_label=_public_path(roots[index]),
saved=saved,
library=self._inspection_library(
saved, unavailable=unavailable
),
)
except CurrentFileChanged:
return None
return encode_inspection_response(
image={
"root_index": index,
"path": Path(relative).as_posix(),
"filename": path.name,
},
root_scope=root_scope,
**observed,
)
body = await self._run_in_executor(inspect)
if body is None:
return web.json_response(
{
"success": False,
"error": "The file changed while being inspected. Try again.",
},
status=409,
headers={"Cache-Control": "no-store"},
)
return web.Response(
body=body,
content_type="application/json",
charset="utf-8",
headers={"Cache-Control": "no-store"},
)
except Exception:
self.logger.exception("Prompt inspection failed")
return web.json_response(
{"success": False, "error": "Prompt inspection failed"}, status=500
)
def _inspection_library(self, saved: dict | None, *, unavailable: bool) -> dict:
result = {
"status": "unavailable" if unavailable else "none",
"scope": "saved-library",
"positive_id": None,
"pair_id": None,
"title": None,
"category": None,
"tags": [],
"metadata_scope": None,
}
if not isinstance(saved, dict) or not saved:
return result
if saved.get("association_status") == "ambiguous":
result["status"] = "ambiguous"
return result
pair = saved.get("pair")
if isinstance(pair, dict):
result.update(
status="linked",
positive_id=pair.get("positive_prompt_id"),
pair_id=pair.get("id"),
title=pair.get("title"),
category=pair.get("category"),
tags=pair.get("tags"),
metadata_scope="pair",
)
else:
result.update(
status="linked",
positive_id=saved.get("prompt_id"),
category=saved.get("category"),
tags=saved.get("tags"),
metadata_scope="positive",
)
return result
async def get_image_prompt(self, request):
"""Get prompt information for a specific image path."""
try:
@@ -1897,27 +1529,7 @@ class ImageRoutesMixin:
if _in_subfolder(str(entry[0].relative_to(entry[2]).parent), subfolder)
]
def _format_workspace_page(self, page: list[tuple]) -> list[dict]:
images = self._format_output_page(page, scoped=True)
paths = {
(entry[3], entry[0].relative_to(entry[2]).as_posix()): entry[0]
for entry in page
}
for image in images:
image["width"] = None
image["height"] = None
if image["is_video"]:
continue
try:
with paths[(image["root_index"], image["path"])].open("rb") as stream:
with Image.open(stream) as source:
image["width"], image["height"] = source.size
except (OSError, ValueError, Image.DecompressionBombError):
# A corrupt or disappearing preview must not hide the listing.
continue
return images
def _format_output_page(self, page, *, scoped=False):
def _format_output_page(self, page):
entries = []
public_roots = {}
for media_path, _mtime, output_path, root_index in page:
@@ -1926,15 +1538,7 @@ class ImageRoutesMixin:
try:
entries.append(
_output_image_entry(
media_path,
output_path,
root_index,
public_roots[output_path],
root_scope=(
self._gallery_root_scope(output_path, root_index)
if scoped
else None
),
media_path, output_path, root_index, public_roots[output_path]
)
)
except Exception as e:
-107
View File
@@ -1,107 +0,0 @@
"""One bounded current-file inspection worker shared by physical identities."""
import os
import stat
from pathlib import Path
try:
from ...utils.prompt_inspector import (
MetadataReadError,
inspect_prompt_evidence,
read_inspection_file,
)
from ...utils.video_metadata import is_video_path
except ImportError:
from utils.prompt_inspector import (
MetadataReadError,
inspect_prompt_evidence,
read_inspection_file,
)
from utils.video_metadata import is_video_path
# Only these file types are served or inspected within an authorized directory.
IMAGE_EXTENSIONS = frozenset(
{
".png",
".jpg",
".jpeg",
".webp",
".gif",
".bmp",
".tiff",
".tif",
".mp4",
".webm",
".mov",
}
)
VIDEO_EXTENSIONS = frozenset({".mp4", ".webm", ".avi", ".mov", ".mkv", ".m4v", ".wmv"})
SERVABLE_EXTENSIONS = IMAGE_EXTENSIONS | VIDEO_EXTENSIONS
class CurrentFileChanged(Exception):
"""File facts are invalid or changed across the bounded metadata observation."""
def _current_file_facts(path: Path) -> os.stat_result:
if path.suffix.lower() not in SERVABLE_EXTENSIONS:
raise CurrentFileChanged
try:
observed = path.stat()
except (FileNotFoundError, NotADirectoryError, PermissionError):
raise CurrentFileChanged from None
if (
not stat.S_ISREG(observed.st_mode)
or not observed.st_mode & 0o444
or not os.access(path, os.R_OK)
):
raise CurrentFileChanged
return observed
def inspect_current_file(
path: Path, *, source_label: str, saved: dict | None, library: dict
) -> dict:
before = _current_file_facts(path)
try:
inspection = read_inspection_file(path)
except MetadataReadError:
inspection = None
after = _current_file_facts(path)
fields = ("st_dev", "st_ino", "st_mode", "st_size", "st_mtime_ns", "st_ctime_ns")
if any(getattr(before, key) != getattr(after, key) for key in fields):
raise CurrentFileChanged
if inspection is None:
evidence = {
"prompts": {
role: {"status": "unresolved", "text": None, "source": None}
for role in ("positive", "negative")
},
"resolution": {
"state": "unresolved",
"sampler_id": None,
"reason": "Media metadata could not be read",
},
}
else:
role_saved = (
None if saved and saved.get("association_status") == "ambiguous" else saved
)
evidence = inspect_prompt_evidence(inspection.metadata, role_saved)
return {
"file": {
"observation": "current-file",
"source_label": source_label,
"media_type": "video" if is_video_path(path) else "image",
"extension": path.suffix.lower(),
"size": before.st_size,
"modified_time": before.st_mtime,
"format": None,
"width": None,
"height": None,
},
**evidence,
"inspection": inspection,
"library": library,
}
-255
View File
@@ -1,255 +0,0 @@
"""Read-only native library with bounded, filter-scoped continuation cursors."""
import base64
import binascii
import hashlib
import hmac
import json
import os
import time
from collections import OrderedDict
from collections.abc import Callable
from pathlib import Path
from aiohttp import web
from .images import _canonical, _media_access_error
from .pairs import pair_response
_SECRET = os.urandom(32)
class _NameCursorStore:
"""Full lexical keys stay off URLs, bounded by age, count and UTF-8 bytes."""
def __init__(
self,
ttl: int = 3600,
max_entries: int = 128,
max_bytes: int = 8 * 1024 * 1024,
clock: Callable[[], float] = time.monotonic,
) -> None:
self.clock = clock
self.ttl = ttl
self.max_entries = max_entries
self.max_bytes = max_bytes
self.entries = OrderedDict()
self.size = 0
def _remove(self, reference: str) -> None:
_expires, _scope, raw = self.entries.pop(reference)
self.size -= len(raw)
def _prune(self) -> None:
now = self.clock()
for reference, (expires, _scope, _raw) in list(self.entries.items()):
if expires <= now:
self._remove(reference)
def put(self, key: object, scope: str) -> str:
raw = json.dumps(key, ensure_ascii=False, allow_nan=False).encode()
if len(raw) > min(512 * 1024, self.max_bytes):
raise ValueError("Prompt name exceeds the cursor limit")
self._prune()
while self.entries and (
len(self.entries) >= self.max_entries
or self.size + len(raw) > self.max_bytes
):
self._remove(next(iter(self.entries)))
reference = base64.urlsafe_b64encode(os.urandom(24)).decode()
self.entries[reference] = (self.clock() + self.ttl, scope, raw)
self.size += len(raw)
return reference
def get(self, reference: str, scope: str) -> object:
self._prune()
entry = self.entries.get(reference)
if entry is None or entry[1] != scope:
raise ValueError("Name cursor expired; refresh the library")
self.entries.move_to_end(reference)
return json.loads(entry[2])
_NAME_CURSORS = _NameCursorStore()
def _scope(value: dict) -> str:
return hashlib.sha256(json.dumps(value, sort_keys=True).encode()).hexdigest()
def _encode(key: object, scope: str, *, name: bool = False) -> str | None:
if key is None:
return None
value = (
{"scope": scope, "reference": _NAME_CURSORS.put(key, scope)}
if name
else {"scope": scope, "key": key}
)
payload = json.dumps(value, separators=(",", ":"), allow_nan=False).encode()
signature = hmac.new(_SECRET, payload, hashlib.sha256).digest()
return base64.urlsafe_b64encode(signature + payload).decode().rstrip("=")
def _decode(token: str | None, scope: str) -> object:
if token is None:
return None
try:
if not 1 <= len(token) <= 4096:
raise ValueError
decoded = base64.b64decode(
token + "=" * (-len(token) % 4), altchars=b"-_", validate=True
)
signature, payload = decoded[:32], decoded[32:]
if not hmac.compare_digest(
signature, hmac.new(_SECRET, payload, hashlib.sha256).digest()
):
raise ValueError
value = json.loads(payload)
if (
set(value) not in ({"scope", "key"}, {"scope", "reference"})
or value["scope"] != scope
):
raise ValueError
if "reference" in value:
return _NAME_CURSORS.get(value["reference"], scope)
return value["key"]
except (ValueError, TypeError, binascii.Error, UnicodeError):
raise ValueError("Invalid library cursor; refresh the library") from None
def _positive_id(request: web.Request) -> int:
value = int(request.match_info["positive_id"])
if not 1 <= value <= 2**63 - 1:
raise ValueError("Invalid positive prompt ID")
return value
def _page_limit(request: web.Request, key: str = "limit") -> int:
value = int(request.query.get(key, "40"))
if not 1 <= value <= 200:
raise ValueError("limit must be between 1 and 200")
return value
def _missing() -> web.Response:
return web.json_response(
{"success": False, "error": "Positive prompt not found"}, status=404
)
class LibraryRoutesMixin:
def _register_library_routes(self, routes: web.RouteTableDef) -> None:
routes.get("/prompt_manager/library")(self.list_library)
routes.get("/prompt_manager/library/options")(self.get_library_options)
routes.get("/prompt_manager/library/{positive_id}")(self.get_library_positive)
routes.get("/prompt_manager/library/{positive_id}/images")(
self.get_library_images
)
def _library_media(self, records: list[dict], roots: list[Path]) -> list[dict]:
page = []
for record in records:
try:
raw_path = Path(record["image_path"])
if not raw_path.is_absolute():
continue
path = _canonical(raw_path)
denied = _media_access_error(path, roots)
if denied is not None or not path.is_file():
continue
for index, root in enumerate(roots):
canonical_root = _canonical(root)
if path.is_relative_to(canonical_root):
page.append((path, 0, canonical_root, index))
break
except (OSError, ValueError, RuntimeError):
continue
return self._format_workspace_page(page)
@pair_response
async def get_library_options(self, request: web.Request) -> web.Response:
result = await self._run_in_executor(self.db.get_library_options)
return web.json_response({"success": True, **result})
@pair_response
async def list_library(self, request: web.Request) -> web.Response:
options = {
"text": request.query.get("text", ""),
"category": request.query.get("category"),
"tags": sorted(set(request.query.getall("tag", []))),
"sort": request.query.get("sort", "newest"),
}
scope = _scope({"kind": "parents", **options})
result = await self._run_in_executor(
self.db.list_library,
**options,
limit=_page_limit(request),
after=_decode(request.query.get("cursor"), scope),
)
roots = list(self._get_all_output_dirs())
def previews() -> None:
records = self.db.get_library_previews(
[parent["id"] for parent in result["items"]]
)
for parent in result["items"]:
parent["previews"] = self._library_media(records[parent["id"]], roots)
await self._run_in_executor(previews)
result["next_cursor"] = _encode(
result.pop("next_key"), scope, name=options["sort"] == "name"
)
return web.json_response({"success": True, **result})
@pair_response
async def get_library_positive(self, request: web.Request) -> web.Response:
positive = _positive_id(request)
scope = _scope({"kind": "variants", "positive": positive})
result = await self._run_in_executor(
self.db.get_library_positive,
positive,
variant_limit=_page_limit(request, "variant_limit"),
variant_after=_decode(request.query.get("variant_cursor"), scope),
)
if result is None:
return _missing()
roots = list(self._get_all_output_dirs())
records = await self._run_in_executor(
self.db.list_library_images, result["positive"]["id"], limit=2
)
result["positive"]["previews"] = await self._run_in_executor(
self._library_media, records["images"], roots
)
result["variants_next_cursor"] = _encode(result.pop("variants_next_key"), scope)
return web.json_response({"success": True, **result})
@pair_response
async def get_library_images(self, request: web.Request) -> web.Response:
positive = _positive_id(request)
pair = request.query.get("pair_id")
pair = int(pair) if pair is not None else None
if pair is not None and not 1 <= pair <= 2**63 - 1:
raise ValueError("Invalid variant ID")
roots = list(self._get_all_output_dirs())
scope = _scope(
{
"kind": "images",
"positive": positive,
"pair": pair,
"roots": [str(_canonical(root)) for root in roots],
}
)
if await self._run_in_executor(self.db.get_library_positive, positive) is None:
return _missing()
result = await self._run_in_executor(
self.db.list_library_images,
positive,
pair_id=pair,
limit=_page_limit(request),
after=_decode(request.query.get("cursor"), scope),
)
result["images"] = await self._run_in_executor(
self._library_media, result["images"], roots
)
result["next_cursor"] = _encode(result.pop("next_key"), scope)
return web.json_response({"success": True, **result})
-204
View File
@@ -1,204 +0,0 @@
"""Reusable ordered prompt combinations, separate from legacy prompt CRUD."""
from functools import wraps
from typing import Callable
from aiohttp import web
from .prompts import parse_page_params
try:
from ...database.pairs import PairDependencyError
from ...utils.validators import (
validate_category,
validate_prompt_text,
validate_rating,
validate_tags,
)
except ImportError:
from database.pairs import PairDependencyError
from utils.validators import (
validate_category,
validate_prompt_text,
validate_rating,
validate_tags,
)
def pair_response(handler: Callable) -> Callable:
@wraps(handler)
async def wrapped(self, request: web.Request) -> web.Response:
try:
return await handler(self, request)
except PairDependencyError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=409)
except (ValueError, TypeError) as exc:
return web.json_response({"success": False, "error": str(exc)}, status=400)
except Exception:
self.logger.exception("Prompt pair operation failed")
return web.json_response(
{"success": False, "error": "Prompt pair operation failed"}, status=500
)
return wrapped
def metadata_fields(data: dict) -> dict:
fields = {}
for name in ("title", "category", "tags", "rating", "notes"):
if name not in data:
continue
value = data[name]
if name == "tags":
if not isinstance(value, list):
raise ValueError("Tags must be a list of strings")
validate_tags(value)
elif name == "rating":
if isinstance(value, bool):
raise ValueError("Rating must be an integer")
validate_rating(value)
else:
if value is not None and not isinstance(value, str):
raise ValueError(f"{name} must be a string")
if name == "category":
validate_category(value)
elif value is not None and len(value) > 10000:
raise ValueError(f"{name} is too long")
fields[name] = value
return fields
async def object_body(request: web.Request) -> dict:
data = await request.json()
if not isinstance(data, dict):
raise ValueError("Expected a JSON object")
return data
def missing_pair() -> web.Response:
return web.json_response({"success": False, "error": "Pair not found"}, status=404)
class PairRoutesMixin:
def _register_pair_routes(self, routes: web.RouteTableDef) -> None:
routes.get("/prompt_manager/pairs/export")(self.export_prompt_pairs)
routes.get("/prompt_manager/pairs")(self.list_prompt_pairs)
routes.post("/prompt_manager/pairs")(self.save_prompt_pair)
routes.get("/prompt_manager/pairs/{pair_id}")(self.get_prompt_pair)
routes.patch("/prompt_manager/pairs/{pair_id}")(self.update_prompt_pair)
routes.delete("/prompt_manager/pairs/{pair_id}")(self.delete_prompt_pair)
@pair_response
async def save_prompt_pair(self, request: web.Request) -> web.Response:
data = await object_body(request)
positive = data.get("positive_text")
negative = data.get("negative_text")
validate_prompt_text(positive)
if not isinstance(negative, str):
raise ValueError(
"negative_text must be explicit; use an empty string for none"
)
if negative.strip():
validate_prompt_text(negative)
fields = metadata_fields(data)
pair_id = await self._run_in_executor(
self.db.save_prompt_pair, positive, negative, **fields
)
pair = await self._run_in_executor(self.db.get_prompt_pair, pair_id)
return web.json_response({"success": True, "pair": pair})
@pair_response
async def get_prompt_pair(self, request: web.Request) -> web.Response:
pair = await self._run_in_executor(
self.db.get_prompt_pair, int(request.match_info["pair_id"])
)
return (
web.json_response({"success": True, "pair": pair})
if pair
else missing_pair()
)
@pair_response
async def list_prompt_pairs(self, request: web.Request) -> web.Response:
limit, offset = parse_page_params(request.query)
archived = request.query.get("include_archived", "false")
if archived not in ("true", "false"):
raise ValueError("include_archived must be true or false")
result = await self._run_in_executor(
self.db.list_prompt_pairs,
text=request.query.get("text"),
category=request.query.get("category"),
tags=request.query.getall("tag", []),
include_archived=archived == "true",
sort=request.query.get("sort", "newest"),
limit=limit,
offset=offset,
)
return web.json_response({"success": True, **result})
@pair_response
async def update_prompt_pair(self, request: web.Request) -> web.Response:
pair_id = int(request.match_info["pair_id"])
data = await object_body(request)
allowed = {"title", "category", "tags", "rating", "notes", "archived"}
if not data or set(data) - allowed:
raise ValueError(
"Update metadata or archive; save a new pair to change text"
)
fields = metadata_fields(data)
if "archived" in data:
if not isinstance(data["archived"], bool) or fields:
raise ValueError("Archive must be a separate boolean update")
updated = await self._run_in_executor(
self.db.archive_prompt_pair, pair_id, data["archived"]
)
else:
updated = await self._run_in_executor(
self.db.update_prompt_pair_metadata, pair_id, **fields
)
pair = await self._run_in_executor(self.db.get_prompt_pair, pair_id)
if not updated and not pair:
return missing_pair()
return web.json_response({"success": True, "pair": pair})
@pair_response
async def delete_prompt_pair(self, request: web.Request) -> web.Response:
deleted = await self._run_in_executor(
self.db.delete_prompt_pair, int(request.match_info["pair_id"])
)
return web.json_response({"success": True}) if deleted else missing_pair()
@pair_response
async def export_prompt_pairs(self, request: web.Request) -> web.Response:
pairs = []
offset = 0
while True:
result = await self._run_in_executor(
self.db.list_prompt_pairs,
include_archived=True,
limit=500,
offset=offset,
)
for pair in result["pairs"]:
pairs.append(
{
key: pair[key]
for key in (
"positive_text",
"negative_text",
"title",
"category",
"tags",
"rating",
"notes",
"archived",
)
}
)
offset += len(result["pairs"])
if not result["has_more"] or not result["pairs"]:
break
return web.json_response(
{"schema_version": 1, "kind": "prompt-manager-pairs", "pairs": pairs},
headers={"Content-Disposition": 'attachment; filename="prompt-pairs.json"'},
)
-11
View File
@@ -6,11 +6,6 @@ import os
import sqlite3
from aiohttp import web
try:
from ...database.pairs import PairDependencyError
except ImportError:
from database.pairs import PairDependencyError
from urllib.parse import quote
# Upper bounds on page sizes and offsets for every list endpoint. The offset
@@ -798,8 +793,6 @@ class PromptRoutesMixin:
status=404,
)
except PairDependencyError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=409)
except ValueError:
return web.json_response(
{"success": False, "error": "Invalid prompt ID"}, status=400
@@ -847,8 +840,6 @@ class PromptRoutesMixin:
{"success": False, "error": "Prompt not found"}, status=404
)
except PairDependencyError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=409)
except sqlite3.IntegrityError:
return web.json_response(
{"success": False, "error": "Another prompt already has this text"},
@@ -1090,8 +1081,6 @@ class PromptRoutesMixin:
}
)
except PairDependencyError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=409)
except Exception as e:
self.logger.error(f"Bulk delete error: {e}")
return web.json_response(
-1
View File
@@ -16,7 +16,6 @@ class ScanJob:
def __init__(self):
self.last_event = None
self.finished = False
self.cancelled = False
self.task = None
self._subscribers = set()
+1 -1
View File
@@ -541,7 +541,7 @@ def download_civitai_images(
if not isinstance(url, str) or not is_civitai_url(url):
continue
url_hash = hashlib.md5(url.encode(), usedforsecurity=False).hexdigest()[:12]
url_hash = hashlib.md5(url.encode()).hexdigest()[:12]
local_path = lora_cache / f"{url_hash}.jpg"
if local_path.exists():
+36 -114
View File
@@ -2,17 +2,6 @@ import {readFileSync} from 'node:fs';
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
for (const variant of ['Focused', 'Split']) {
test.describe(variant, () => {
const isSplit = variant === 'Split';
const uiProperty = isSplit ? 'splitUI' : 'focusedUI';
async function openLibrary(page) {
if (isSplit) {
await expect(page.locator('.pm-editor-library')).toBeVisible();
await expect(page.getByRole('button',{name:'Browse library',exact:true})).toHaveCount(0);
} else await page.getByRole('button',{name:'Browse library',exact:true}).click();
}
test.beforeEach(async({page,browserName})=>{
await page.addInitScript(()=>Object.defineProperty(Crypto.prototype,'randomUUID',{value:undefined,configurable:true}));
await startCoverage(page,browserName);
@@ -21,26 +10,26 @@ test.beforeEach(async({page,browserName})=>{
await page.route('**/scripts/api.js',route=>route.fulfill({contentType:'text/javascript',body:'export const api={fileURL:path=>path,queuePrompt:async(...args)=>args};'}));
await page.route('**/extensions/promptmanager/prompt_manager.js',route=>route.fulfill({contentType:'text/javascript',body:readFileSync(new URL('../../web/comfy/prompt_manager.js',import.meta.url),'utf8')}));
await page.goto('/prompt_manager/test');
await page.evaluate(async variant=>{
await page.evaluate(async()=>{
document.body.replaceChildren();document.body.style.setProperty('--bg-color','#222');
window.fixtureApp={registerExtension(extension){this.extension=extension;},ui:{settings:new EventTarget()},graph:{setDirtyCanvas(){}},canvas:{}};
await import('/extensions/promptmanager/prompt_manager.js');
class Node {
constructor(){this.widgets=['text','category','tags','search_text'].map(name=>({name,type:'text',value:'',options:{},inputEl:document.createElement('textarea')}));this.inputs=['clip','prepend_text','append_text'].map(name=>({name,link:null}));this.properties={};this.size=[550,700];}
addWidget(type,name,value,callback,options){const widget={type,name,value,callback,options};this.widgets.push(widget);return widget;}
addDOMWidget(name,type,element){const host=document.createElement('div');host.style.height='650px';host.className='fixture-dom-widget';host.append(element);document.body.append(host);return {element};}
addDOMWidget(name,type,element){document.body.append(element);return {element};}
setSize(size){this.size=size;}setDirtyCanvas(){}
serialize(){return {widgets_values:this.widgets.map(widget=>widget.value),properties:{...this.properties}};}
configure(data){this.widgets.forEach((widget,index)=>{widget.value=data.widgets_values[index];});this.properties={...data.properties};}
}
await fixtureApp.extension.beforeRegisterNodeDef(Node,{name:`PromptManager${variant}`},fixtureApp);
await fixtureApp.extension.beforeRegisterNodeDef(Node,{name:'PromptManagerFocused'},fixtureApp);
window.node=new Node();node.onNodeCreated();
},variant);
});
await expect(page.locator('.pm-focused')).toBeVisible();
});
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.focusedErrors).toEqual([]);});
test(`${variant} library autocompletes filters and searches without changing editor metadata`, async ({page}) => {
test('Focused library autocompletes filters and searches without changing editor metadata', async ({page}) => {
const requests=[];
await page.route('**/prompt_manager/categories',route=>route.fulfill({json:{success:true,categories:['Portrait']}}));
await page.route('**/prompt_manager/tags/suggest?**',route=>route.fulfill({json:{success:true,suggestions:[{name:'studio'}]}}));
@@ -49,7 +38,7 @@ test(`${variant} library autocompletes filters and searches without changing edi
return route.fulfill({json:{success:true,results:[{text:'Filtered portrait',category:'Portrait',tags:['studio']}]}});
});
await page.getByLabel('Main prompt',{exact:true}).fill('My draft');
await openLibrary(page);
await page.getByRole('button',{name:'Browse library',exact:true}).click();
const category=page.getByLabel('Filter category',{exact:true});
await category.fill('por');
await expect(page.getByRole('option',{name:'Portrait',exact:true})).toBeVisible();
@@ -71,7 +60,7 @@ test(`${variant} library autocompletes filters and searches without changing edi
await category.fill('Landscape');
await expect(page.locator('.pm-editor-selection button')).toBeDisabled();
const saved=await page.evaluate(()=>{const saved=node.serialize();node.configure(saved);return saved;});
expect(saved.properties[uiProperty].libraryFilters).toEqual({category:'Landscape',tags:'studio, '});
expect(saved.properties.focusedUI.libraryFilters).toEqual({category:'Landscape',tags:'studio, '});
await expect(category).toHaveValue('Landscape');
await expect(tags).toHaveValue('studio, ');
let recentRequest;
@@ -80,15 +69,14 @@ test(`${variant} library autocompletes filters and searches without changing edi
await expect.poll(()=>Boolean(recentRequest)).toBe(true);
expect(recentRequest.searchParams.has('category')).toBe(false);
expect(recentRequest.searchParams.has('tags')).toBe(false);
if (isSplit) await page.getByRole('searchbox',{name:'Search saved prompts'}).focus();
else await page.getByRole('button',{name:'Hide library',exact:true}).click();
await page.getByRole('button',{name:'Hide library',exact:true}).click();
await expect(page.getByRole('listbox')).toBeHidden();
});
test(`${variant} library can search by filters alone and Enter searches after autocomplete closes`, async ({page}) => {
test('Focused library can search by filters alone and Enter searches after autocomplete closes', async ({page}) => {
const requests=[];
await page.route('**/prompt_manager/search?**',route=>{requests.push(new URL(route.request().url()));return route.fulfill({json:{success:true,results:[]}});});
await openLibrary(page);
await page.getByRole('button',{name:'Browse library',exact:true}).click();
await page.getByLabel('Filter category',{exact:true}).fill('New category');
await page.getByLabel('Filter category',{exact:true}).press('Enter');
await expect.poll(()=>requests.length).toBe(1);
@@ -100,7 +88,7 @@ test(`${variant} library can search by filters alone and Enter searches after au
expect(requests[1].searchParams.get('tags')).toBe('custom');
});
test(`${variant} completes saved tags with keyboard and pointer and appends commas`, async ({page}) => {
test('Focused completes saved tags with keyboard and pointer and appends commas', async ({page}) => {
await page.route('**/prompt_manager/tags/suggest?**', route => {
const q = new URL(route.request().url()).searchParams.get('q');
return route.fulfill({json:{success:true,suggestions:(q === 'bl' ? ['blue eyes','blue sky'] : ['portrait','blue eyes']).map(name => ({name}))}});
@@ -130,7 +118,7 @@ test(`${variant} completes saved tags with keyboard and pointer and appends comm
await expect(tags).toHaveValue('A brand new tag');
});
test(`${variant} metadata autocomplete ignores stale responses, recovers from errors and cleans up`, async ({page}) => {
test('Focused metadata autocomplete ignores stale responses, recovers from errors and cleans up', async ({page}) => {
let release;
let mode='delayed';
await page.route('**/prompt_manager/tags/suggest?**', async route => {
@@ -158,7 +146,7 @@ test(`${variant} metadata autocomplete ignores stale responses, recovers from er
await expect(page.getByRole('listbox')).toHaveCount(0);
});
test(`${variant} completes one category, allows new values and dismisses suggestions on blur`, async ({page}) => {
test('Focused completes one category, allows new values and dismisses suggestions on blur', async ({page}) => {
await page.route('**/prompt_manager/categories',route=>route.fulfill({json:{success:true,categories:['Portrait','Landscape','Portrait studio']}}));
await page.getByText('Metadata · category / tags',{exact:true}).click();
const category=page.getByLabel('Category',{exact:true});
@@ -180,7 +168,7 @@ test(`${variant} completes one category, allows new values and dismisses suggest
await expect(category).toHaveValue('New category');
});
test(`${variant} shares suggestion controls safely with another bundled toolkit copy`, async ({page}) => {
test('Focused shares suggestion controls safely with another bundled toolkit copy', async ({page}) => {
await page.evaluate(async()=>{
const {createScope}=await import('/prompt_manager/js/shared/toolkit/core.js');
const first=await import('/prompt_manager/js/shared/toolkit/suggestions.js?pack=first');
@@ -208,10 +196,10 @@ test(`${variant} shares suggestion controls safely with another bundled toolkit
await expect(lists).toHaveCount(0);
});
test(`${variant} previews before loading, keeps metadata optional and composes text`,async({page})=>{
test('Focused previews before loading, keeps metadata optional and composes text',async({page})=>{
await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:[{id:1,text:'Library portrait',category:'Portrait',tags:['studio']}]}}));
await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill('My draft');
await openLibrary(page);
await page.getByRole('button',{name:'Browse library',exact:true}).click();
await page.getByRole('button',{name:'Recent',exact:true}).click();
await page.getByRole('button',{name:/Library portrait/}).click();
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('My draft');
@@ -226,7 +214,7 @@ test(`${variant} previews before loading, keeps metadata optional and composes t
expect(await page.evaluate(()=>node.serialize().widgets_values.slice(0,6))).toEqual(['Library portrait','','','','trigger','detailed']);
});
test(`${variant} restores workflow values and disclosures and protects connected text`,async({page})=>{
test('Focused restores workflow values and disclosures and protects connected text',async({page})=>{
await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill('Saved draft');
await page.getByText('Metadata · category / tags',{exact:true}).click();
await page.getByLabel('Category',{exact:true}).fill('Saved category');
@@ -240,14 +228,14 @@ test(`${variant} restores workflow values and disclosures and protects connected
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('Saved draft');
});
test(`${variant} searches quoted tags with Enter and explicitly loads metadata`, async ({page}) => {
test('Focused searches quoted tags with Enter and explicitly loads metadata', async ({page}) => {
let requested;
await page.route('**/prompt_manager/search?**', async route => {
requested = new URL(route.request().url());
await route.fulfill({json:{success:true,results:[{id:7,text:'Selected fox',category:'Animals',tags:['film grain','fox']}]}});
});
await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill('Preserved draft');
await openLibrary(page);
await page.getByRole('button',{name:'Browse library',exact:true}).click();
const query=page.getByRole('searchbox',{name:'Search saved prompts'});
await query.fill('fox tag:"film grain"');
await query.press('Enter');
@@ -262,7 +250,7 @@ test(`${variant} searches quoted tags with Enter and explicitly loads metadata`,
expect(await page.evaluate(()=>node.serialize().widgets_values.slice(0,4))).toEqual(['Selected fox','Animals','film grain, fox','fox tag:"film grain"']);
});
test(`${variant} reports empty queries, no results, and safely renders backend and HTTP errors`, async ({page}) => {
test('Focused reports empty queries, no results, and safely renders backend and HTTP errors', async ({page}) => {
let requests=0;
let mode='empty';
const malicious='<img src=x onerror="window.focusedInjected=true">';
@@ -271,7 +259,7 @@ test(`${variant} reports empty queries, no results, and safely renders backend a
if(mode==='http') return route.fulfill({status:503,body:'Unavailable'});
await route.fulfill({json:mode==='error'?{success:false,error:malicious}:{success:true,results:[]}});
});
await openLibrary(page);
await page.getByRole('button',{name:'Browse library',exact:true}).click();
const search=page.getByRole('button',{name:'Search',exact:true});
await search.click();
await expect(page.locator('.pm-editor-library [role="status"]')).toHaveText('Enter search text, a category or tags.');
@@ -299,10 +287,10 @@ test(`${variant} reports empty queries, no results, and safely renders backend a
await expect(page.locator('.pm-editor-library [role="status"]')).toHaveText('No matching prompts.');
});
test(`${variant} treats saved prompt content and metadata as text`, async ({page}) => {
test('Focused treats saved prompt content and metadata as text', async ({page}) => {
const malicious='<img src=x onerror="window.focusedInjected=true">';
await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:[{id:8,text:malicious,category:'<script>bad()</script>',tags:['<svg onload=bad()>']}]}}));
await openLibrary(page);
await page.getByRole('button',{name:'Browse library',exact:true}).click();
await page.getByRole('button',{name:'Recent',exact:true}).click();
await page.locator('.pm-editor-result').click();
await expect(page.locator('.pm-editor-selection .pm-editor-preview')).toHaveText(malicious);
@@ -324,9 +312,9 @@ async function deferLibraryRequests(page) {
});
}
test(`${variant} keeps the latest result when searches and recent requests overlap`, async ({page}) => {
test('Focused keeps the latest result when searches and recent requests overlap', async ({page}) => {
await deferLibraryRequests(page);
await openLibrary(page);
await page.getByRole('button',{name:'Browse library',exact:true}).click();
await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('older search');
await page.getByRole('button',{name:'Search',exact:true}).click();
await page.getByRole('button',{name:'Recent',exact:true}).click();
@@ -345,9 +333,9 @@ test(`${variant} keeps the latest result when searches and recent requests overl
await expect(page.locator('.pm-editor-result')).toHaveText('Latest search');
});
test(`${variant} removal aborts work and stops theme and control listeners`, async ({page}) => {
test('Focused removal aborts work and stops theme and control listeners', async ({page}) => {
await deferLibraryRequests(page);
await openLibrary(page);
await page.getByRole('button',{name:'Browse library',exact:true}).click();
await page.getByRole('button',{name:'Recent',exact:true}).click();
await expect(page.locator('.pm-focused')).toHaveAttribute('data-ca-theme','dark');
const removed=await page.evaluate(()=>{
@@ -360,18 +348,11 @@ test(`${variant} removal aborts work and stops theme and control listeners`, asy
await page.evaluate(()=>completeLibraryRequest(0,'Late result after removal'));
await expect(page.locator('.pm-editor-result')).toHaveCount(0);
await expect(page.locator('.pm-focused')).toHaveAttribute('data-ca-theme','dark');
if (isSplit) {
const count=await page.evaluate(()=>libraryRequests.length);
await page.getByRole('button',{name:'Recent',exact:true}).click();
expect(await page.evaluate(()=>libraryRequests.length)).toBe(count);
await expect(page.locator('.pm-editor-library')).toBeVisible();
} else {
await page.getByRole('button',{name:'Hide library',exact:true}).click();
await expect(page.getByRole('button',{name:'Hide library',exact:true})).toBeVisible();
}
await page.getByRole('button',{name:'Hide library',exact:true}).click();
await expect(page.getByRole('button',{name:'Hide library',exact:true})).toBeVisible();
});
test(`${variant} fields reflow with node width and follow live host themes`, async ({page}) => {
test('Focused fields reflow with node width and follow live host themes', async ({page}) => {
await page.setViewportSize({width:1000,height:1000});
await page.getByText('Composition · prepend / append',{exact:true}).click();
await page.evaluate(()=>{node._focusedView.element.style.width='700px';node._focusedView.element.style.height='850px';});
@@ -392,7 +373,7 @@ test(`${variant} fields reflow with node width and follow live host themes`, asy
await expect.poll(()=>prepend.evaluate(element=>getComputedStyle(element).backgroundColor)).toBe(dark);
});
test(`${variant} composed preview preserves main prompt whitespace like queued execution`, async ({page}) => {
test('Focused composed preview preserves main prompt whitespace like queued execution', async ({page}) => {
await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill(' draft ');
await page.getByText('Composition · prepend / append',{exact:true}).click();
await page.getByLabel('Prepend',{exact:true}).fill(' trigger ');
@@ -401,17 +382,17 @@ test(`${variant} composed preview preserves main prompt whitespace like queued e
expect(await page.locator('[data-composed-preview]').textContent()).toBe('trigger draft detail');
});
test(`${variant} exposes the visible text element for autocomplete and host extension listeners`, async ({page}) => {
test('Focused exposes the visible text element for autocomplete and host extension listeners', async ({page}) => {
expect(await page.evaluate(()=>node.widgets.find(widget=>widget.name==='text').inputEl===document.querySelector('.pm-focused textarea'))).toBe(true);
});
test(`${variant} expands its main editor when the node gets taller`,async({page})=>{
await page.locator('.pm-focused').evaluate((element,isSplit)=>{element.style.width=isSplit?'900px':'550px';element.style.height='800px';},isSplit);
test('Focused expands its main editor when the node gets taller',async({page})=>{
await page.locator('.pm-focused').evaluate(element=>{element.style.width='550px';element.style.height='800px';});
const editor=await page.getByRole('textbox',{name:'Main prompt',exact:true}).boundingBox();
expect(editor.height).toBeGreaterThan(400);
});
test(`${variant} respects the existing Web UI display preference`,async({page})=>{
test('Focused respects the existing Web UI display preference',async({page})=>{
await page.route('**/prompt_manager/settings',route=>route.fulfill({json:{success:true,settings:{webui_display_mode:'newtab'}}}));
await page.evaluate(()=>{
const Node=node.constructor;node.onRemoved();node._focusedView.element.remove();node=new Node();node.onNodeCreated();
@@ -422,68 +403,9 @@ test(`${variant} respects the existing Web UI display preference`,async({page})=
expect(await page.evaluate(()=>window.openArgs)).toEqual(['/prompt_manager/web','_blank']);
});
test(`${variant} reports blocked Web UI popups without leaving the workflow`,async({page})=>{
test('Focused reports blocked Web UI popups without leaving the workflow',async({page})=>{
await page.evaluate(()=>{window.open=()=>null;});
await page.getByRole('button',{name:'Web UI ↗',exact:true}).click();
await expect(page.locator('.pm-focused [role="alert"]')).toContainText('Allow popups');
await expect(page.locator('.pm-focused')).toBeVisible();
});
if (isSplit) test('Split keeps the library beside the editor when wide and stacks it when narrow', async ({page}) => {
await page.setViewportSize({width:1200,height:1000});
const root=page.locator('.pm-split');
await expect(root).toHaveAttribute('aria-label','Split prompt editor');
await expect(page.getByRole('button',{name:'Hide library',exact:true})).toHaveCount(0);
await root.evaluate(element=>{element.style.width='900px';element.style.height='800px';});
const compose=root.locator('.pm-editor-workspace > .pm-editor-compose');
const library=root.locator('.pm-editor-workspace > .pm-editor-library');
await expect(compose.locator('.pm-editor-main')).toBeVisible();
let a=await compose.boundingBox(),b=await library.boundingBox();
expect(Math.abs(a.y-b.y)).toBeLessThan(2);
expect(b.x).toBeGreaterThanOrEqual(a.x+a.width);
await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:[{text:'Library example'}]}}));
await page.getByRole('button',{name:'Recent',exact:true}).click();
await expect(page.locator('.pm-editor-result')).toContainText('Library example');
await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill('Editing beside the library');
await expect(library).toBeVisible();
await expect(page.locator('.pm-editor-result')).toContainText('Library example');
expect(await page.evaluate(()=>node.serialize().widgets_values[0])).toBe('Editing beside the library');
await root.evaluate(element=>{element.style.width='340px';});
await expect.poll(async()=>{const a=await compose.boundingBox(),b=await library.boundingBox();return b.y>=a.y+a.height;}).toBe(true);
expect(await root.evaluate(element=>element.scrollWidth<=element.clientWidth+1)).toBe(true);
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('Editing beside the library');
await expect(library).toBeVisible();
});
if (isSplit) test('Split bounds long recent results and selected previews at the default node size', async ({page}) => {
await page.setViewportSize({width:1200,height:1000});
const root=page.locator('.pm-split');
await root.evaluate(element=>{element.style.width='900px';element.style.height='650px';});
const longPrompt='Long saved prompt '+('detailed portrait in a studio with natural light '.repeat(110));
const prompts=Array.from({length:20},(_,index)=>({id:index+1,text:index===0?longPrompt:`Saved prompt ${index+1}`,category:'Portrait',tags:['studio']}));
await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:prompts}}));
await page.getByRole('button',{name:'Recent',exact:true}).click();
const results=root.locator('.pm-editor-results');
await expect(results.locator('.pm-editor-result')).toHaveCount(20);
const rowHeights=await results.locator('.pm-editor-result > span:first-child').evaluateAll(elements=>elements.map(element=>element.getBoundingClientRect().height));
expect(rowHeights.every(height=>height>0)).toBe(true);
const resultSize=await results.evaluate(element=>({height:element.getBoundingClientRect().height,scroll:element.scrollHeight,client:element.clientHeight}));
expect.soft(resultSize.height).toBeLessThanOrEqual(300);
expect(resultSize.scroll).toBeGreaterThan(resultSize.client);
await results.locator('.pm-editor-result').first().click();
const preview=root.locator('.pm-editor-selection > .pm-editor-preview');
await expect(preview).toHaveText(longPrompt);
const previewSize=await preview.evaluate(element=>({height:element.getBoundingClientRect().height,scroll:element.scrollHeight,client:element.clientHeight}));
expect.soft(previewSize.height).toBeLessThanOrEqual(180);
expect.soft(previewSize.scroll).toBeGreaterThan(previewSize.client);
await page.getByRole('button',{name:'Use prompt',exact:true}).click();
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue(longPrompt);
const bounds=await root.boundingBox();
for (const selector of ['.pm-editor-compose','.pm-editor-library']) {
const pane=await root.locator(selector).boundingBox();
expect(pane.y).toBeGreaterThanOrEqual(bounds.y);
expect(pane.y+pane.height).toBeLessThanOrEqual(bounds.y+bounds.height);
}
expect(await root.evaluate(element=>element.scrollWidth<=element.clientWidth+1)).toBe(true);
});
});
}
-681
View File
@@ -1,681 +0,0 @@
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
test.use({locale:'en-US',timezoneId:'UTC',reducedMotion:'reduce'});
test.beforeEach(async({page,browserName})=>{await startCoverage(page,browserName);page.inspectorErrors=[];page.on('pageerror',error=>page.inspectorErrors.push(error.message));});
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.inspectorErrors).toEqual([]);});
test('inspection facts survive actual API to browser transport', async ({ page, request }) => {
await expect(await request.post('/__test__/reset', { data: { full_inspector: true } })).toBeOK();
await page.goto('/prompt_manager/workspace');
const result = await page.evaluate(async () => {
if (typeof window.ComfyMetadata?.extractGenerationFacts !== 'function') return { missingFactsExport: true };
const { inspectPath, toInspectionRecord } = await import('/prompt_manager/js/workspace/inspection-state.mjs');
const { createApiClient } = window.ApiClient;
const api = createApiClient();
const collection = await api.get('/prompt_manager/images/output?mode=workspace&subfolder=inspector&limit=40');
const records = {};
for (const name of ['complete', 'ambiguous', 'workflow-only', 'control-heavy']) {
const image = collection.images.find(entry => entry.root_index === 0 && entry.path === `inspector/${name}.png`);
const envelope = await api.get(inspectPath(image));
records[name] = toInspectionRecord(envelope, image, window.ComfyMetadata.extractGenerationFacts);
}
return records;
});
expect(result.missingFactsExport).toBeUndefined();
const complete = result.complete;
expect(Object.values(complete.generation).map(fact => fact.value)).toEqual(['9007199254740993', '28', '6.5', 'dpmpp_2m', 'karras', 'fixture-model.safetensors']);
expect(Object.values(complete.generation).every(fact => fact.source === 'api-graph' && fact.evidence === 'configuration')).toBe(true);
expect(Object.values(result.ambiguous.generation).every(fact => fact.status === 'ambiguous' && fact.value === null)).toBe(true);
expect(result['workflow-only'].generation.seed).toMatchObject({ status: 'known', value: '9007199254740993', source: 'workflow', evidence: 'configuration' });
expect(result['workflow-only'].prompts.positive.source).toBe('saved-association');
expect(Object.values(result['control-heavy'].generation).every(fact => fact.status === 'unresolved' && fact.value === null && fact.reason === 'projection-limited')).toBe(true);
await expect(await request.post('/__test__/reset')).toBeOK();
});
test('inspection wire preserves exact rooted envelope', async ({ request }) => {
for (const data of [
{ full_inspector: 'true' }, { full_inspector: 1 },
{ full_inspector: true, workspace_visual: true },
{ full_inspector: true, prompt_inspector: true },
{ full_inspector: true, native_library: true },
{ full_inspector: true, extra_prompts: 1 },
{ full_inspector: true, extra_variants: 1 },
{ full_inspector: true, extra_images: true },
{ full_inspector: true, include_video: 'true' },
]) expect((await request.post('/__test__/reset', { data })).status()).toBe(400);
await expect(await request.post('/__test__/reset', { data: { full_inspector: true, include_video: true, extra_images: 2 } })).toBeOK();
const collection = await (await request.get('/prompt_manager/images/output?mode=workspace&subfolder=inspector&limit=40')).json();
expect(collection.total).toBe(11);
const complete = collection.images.find(image => image.root_index === 0 && image.path === 'inspector/complete.png');
const collision = collection.images.find(image => image.root_index === 1 && image.path === complete.path);
expect(complete.root_scope).toMatch(/^[0-9a-f]{64}$/);
expect(collision.root_scope).not.toBe(complete.root_scope);
const inspect = async entry => {
const response = await request.get(`/prompt_manager/images/inspect/${entry.path.split('/').map(encodeURIComponent).join('/')}?root=${entry.root_index}&root_scope=${entry.root_scope}`);
await expect(response).toBeOK();
expect(response.headers()['cache-control']).toBe('no-store');
expect((await response.body()).length).toBeLessThanOrEqual(8 * 1024 * 1024);
return response.json();
};
const result = await inspect(complete);
expect(result.inspection_version).toBe(1);
expect(result.image).toEqual({ root_index: 0, path: complete.path, filename: 'complete.png' });
expect(result.root_scope).toBe(complete.root_scope);
expect(result.prompts.positive.text).toContain('Inspector portrait 猫 😀');
expect(result.prompts.negative.text).toBe('blurred edges, watermark');
expect(result.metadata.prompt_graph.value['3'].inputs.seed).toBe('9007199254740993');
expect(result.metadata.prompt_graph.value['3'].inputs.cfg).toBe('6.5');
expect(result.metadata.record_formats).toEqual({ prompt: 'original-text', workflow: 'original-text' });
expect(result.file).toMatchObject({ observation: 'current-file', format: 'PNG', width: 720, height: 480 });
expect(result.library).toMatchObject({ status: 'linked', metadata_scope: 'pair', title: 'Inspector recipe', category: 'Inspection', tags: ['fixture', 'geometric'] });
expect((await inspect(collision)).prompts.positive.text).toBe('Input collision');
const gallery = await (await request.get(`/prompt_manager/library/${result.library.positive_id}/images?pair_id=${result.library.pair_id}`)).json();
expect(gallery.images[0].root_scope).toBe(complete.root_scope);
const library = await (await request.get('/prompt_manager/library')).json();
expect(library.items.find(item => item.id === result.library.positive_id).previews.some(entry => entry.root_scope === complete.root_scope)).toBe(true);
const workflow = await inspect(collection.images.find(image => image.path === 'inspector/workflow-only.png'));
expect(workflow.prompts.positive.source).toBe('saved-association');
const limited = await inspect(collection.images.find(image => image.path === 'inspector/control-heavy.png'));
expect(limited.metadata).toMatchObject({ status: 'limited', omitted_reason: 'response-size-limit', raw: {}, parameters: null });
expect(collection.images.some(image => image.path.endsWith('clip.webm'))).toBe(true);
await expect(await request.post('/__test__/reset')).toBeOK();
});
const inspector=page=>page.locator('.image-details');
const selectFile=async(page,name,root=0)=>page.locator(`article[data-image-key="${root}:inspector/${name}"] .image-open`).click();
async function fullFixture(page,request) {
await expect(await request.post('/__test__/reset',{data:{full_inspector:true}})).toBeOK();
await page.goto('/prompt_manager/workspace#/images');await selectFile(page,'complete.png');
}
async function downloadText(page,action) {
const [download]=await Promise.all([page.waitForEvent('download'),action.click()]);
const fs=await import('node:fs/promises');return {name:download.suggestedFilename(),text:await fs.readFile(await download.path(),'utf8')};
}
test('full inspector shows six facts and scoped provenance',async({page,request},testInfo)=>{
await page.setViewportSize({width:1600,height:1100});await fullFixture(page,request);const panel=inspector(page);
await expect(panel.getByRole('heading',{name:'Inspector recipe',exact:true})).toBeVisible();await expect(panel.locator('.file-format')).toHaveText('PNG');await expect(panel.locator('.file-name')).toHaveText('complete.png');
await expect(panel.locator('.file-evidence')).toHaveText('Workflow prompt evidence');
expect(await panel.evaluate(node=>node.getBoundingClientRect().width)).toBe(310);
expect(await panel.locator('.file-inspector-inner').evaluate(node=>getComputedStyle(node).padding)).toBe('17px');
if (!await page.evaluate(()=>matchMedia('(pointer: coarse)').matches)) {
const copy=await panel.getByRole('button',{name:'Copy positive prompt',exact:true}).boundingBox();expect(copy.width).toBe(30);expect(copy.height).toBe(26);
expect((await panel.getByRole('button',{name:'Send prompt pair',exact:true}).boundingBox()).height).toBe(30);
}
await expect(panel.locator('.generation-facts dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);
await expect(panel.locator('.generation-facts dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']);
await expect(panel).toContainText('Recipe tags');await expect(panel).toContainText('Recipe category');
await expect(panel.locator('.file-vocabulary button')).toHaveCount(0);
await panel.getByText('File & provenance',{exact:true}).click();
await expect(panel).toContainText('Current file at this source; historical bytes are not verified.');await expect(panel).toContainText('Saved association; not verified against current file');
await expect(panel).toContainText('Configuration evidence');await expect(panel.getByRole('link',{name:'Prompt history',exact:true})).toHaveAttribute('href',/^#\/library\/\d+$/);
await expect(panel.getByText('Seed: Configuration evidence · Embedded API configuration',{exact:true})).toBeVisible();
if (testInfo.project.name === 'chromium') {
await panel.getByText('Seed: Configuration evidence · Embedded API configuration',{exact:true}).scrollIntoViewIfNeeded();
const path=testInfo.outputPath('configuration-api-source.png');await page.screenshot({path});await testInfo.attach('configuration-api-source',{path,contentType:'image/png'});
}
await expect(panel).not.toContainText(/Run:|Original file unchanged|Execution recorded/);
await selectFile(page,'empty.png');await expect(panel.locator('.file-format')).toHaveText('PNG');await expect(panel.getByRole('heading',{name:'empty.png',exact:true})).toBeVisible();
await expect(panel.getByRole('button',{name:'Send negative prompt',exact:true})).toHaveCount(0);await expect(panel).toContainText('Not recorded in this file.');await expect(panel.getByRole('link',{name:'Prompt history',exact:true})).toHaveCount(0);
await selectFile(page,'explicit-empty.png');await expect(panel).toContainText('Empty negative prompt');await expect(panel.getByRole('button',{name:'Send prompt pair',exact:true})).toHaveCount(1);
await selectFile(page,'workflow-only.png');await panel.getByText('File & provenance',{exact:true}).click();
await expect(panel.getByText('Seed: Configuration evidence · Saved workflow configuration',{exact:true})).toBeVisible();
if (testInfo.project.name === 'chromium') {
await panel.getByText('Seed: Configuration evidence · Saved workflow configuration',{exact:true}).scrollIntoViewIfNeeded();
const path=testInfo.outputPath('configuration-workflow-source.png');await page.screenshot({path});await testInfo.attach('configuration-workflow-source',{path,contentType:'image/png'});
}
});
test('full inspector preserves long role text and exact sends',async({page,request,context})=>{
const host=await context.newPage();await host.goto('/prompt_manager/workspace#/overview');
await host.evaluate(async()=>{const {createBridgeHost}=await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');const target={id:'pair',label:'Exact pair',type:'PromptManagerPair',roles:['positive','negative']};window.sent=[];window.bridge=createBridgeHost({origin:location.origin,namespace:'/',label:'Exact host',adapter:{listTargets:()=>[target],preview:(_id,roles)=>({preview:{target,fields:Object.entries(roles).map(([role,after])=>({role,before:'',after}))},snapshot:roles}),apply:roles=>{window.sent.push(roles);return{targetId:target.id,values:roles};}}});});
try {
await fullFixture(page,request);const panel=inspector(page);const positive=panel.locator('.prompt-positive .prompt-text');await expect(positive).toContainText('猫 😀');const text=await positive.textContent();expect(text.length).toBeGreaterThan(1000);
await panel.getByRole('button',{name:'Send prompt pair',exact:true}).click();const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
await expect(sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).locator('option')).toContainText(['Exact host']);
await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'Exact host'});await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'Exact pair'});
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
expect(await host.evaluate(()=>window.sent)).toEqual([{positive:text,negative:'blurred edges, watermark'}]);
} finally {await host.evaluate(()=>window.bridge.dispose());await host.close();}
});
test('full inspector downloads actual bounded evidence',async({page,request})=>{
await fullFixture(page,request);const panel=inspector(page);await panel.getByText('Workflow & raw metadata',{exact:true}).click();
const list=await(await request.get('/prompt_manager/images/output?mode=workspace&subfolder=inspector')).json();const image=list.images.find(image=>image.root_index===0&&image.path==='inspector/complete.png');
const result=await(await request.get(`/prompt_manager/images/inspect/inspector/complete.png?root=0&root_scope=${image.root_scope}`)).json();
await expect(panel).toContainText('Original embedded text');const metadata=await downloadText(page,panel.getByRole('button',{name:'Download metadata JSON',exact:true}));expect(metadata.name).toBe('complete.metadata.json');expect(JSON.parse(metadata.text).metadata.raw).toEqual(result.metadata.raw);expect(JSON.parse(metadata.text).identity.root_scope).toBe(image.root_scope);
const workflow=await downloadText(page,panel.getByRole('button',{name:'Download workflow JSON',exact:true}));expect(workflow.name).toBe('complete.workflow.json');expect(workflow.text).toBe(result.metadata.raw.workflow);
await panel.getByText('File & provenance',{exact:true}).click();await expect(panel.getByRole('link',{name:'Download original',exact:true})).toHaveAttribute('href',image.url);
await selectFile(page,'malformed.png');await panel.getByText('Workflow & raw metadata',{exact:true}).click();await expect(panel.locator('pre code').filter({hasText:'{broken'})).toHaveText('{broken');await expect(panel.getByRole('button',{name:'Download workflow JSON',exact:true})).toHaveAttribute('aria-disabled','true');
await selectFile(page,'control-heavy.png');await panel.getByText('Workflow & raw metadata',{exact:true}).click();await expect(panel).toContainText('Raw metadata exceeds the response limit. Download the original file to inspect it externally.');
let downloads=0;page.on('download',()=>downloads++);for(const name of ['Download metadata JSON','Download workflow JSON']) {const button=panel.getByRole('button',{name,exact:true});await expect(button).toHaveAttribute('aria-disabled','true');await button.focus();await page.keyboard.press('Enter');}expect(downloads).toBe(0);
await panel.getByText('File & provenance',{exact:true}).click();await expect(panel.getByRole('link',{name:'Download original',exact:true})).toHaveAttribute('href',/root=0&root_scope=[a-f0-9]{64}/);
});
test('full inspector rejects stale shared action state',async({page,request})=>{
let reads=0,stale=false;await page.route('**/images/inspect/**',route=>{reads++;return stale?route.fulfill({status:409,json:{success:false,code:'source-changed',error:'Source changed'}}):route.continue();});
await fullFixture(page,request);const panel=inspector(page);await expect(panel.getByRole('button',{name:'Send prompt pair',exact:true})).toBeVisible();const before=reads;
await panel.getByRole('button',{name:'Open full preview',exact:true}).click();const preview=page.getByRole('dialog',{name:'Image preview',exact:true});await expect(preview.locator('.generation-facts dd').first()).toHaveText('9007199254740993');expect(reads).toBe(before);await preview.getByRole('button',{name:'Close preview',exact:true}).click();
stale=true;await selectFile(page,'complete.png',1);await expect(panel).toContainText('The source changed. Refresh the image collection before inspecting this file.');
await expect(panel.getByRole('button',{name:/Retry/})).toHaveCount(0);await expect(panel.locator('[data-copy-role],[data-send-role],a[download]')).toHaveCount(0);await expect(panel.getByRole('button',{name:/Download/})).toHaveCount(0);
stale=false;const refreshed=page.waitForResponse(response=>response.url().includes('/images/output?'));await panel.getByRole('button',{name:'Refresh image collection',exact:true}).click();await refreshed;await selectFile(page,'complete.png',1);await expect(panel).toContainText('Input collision');
const urls=await page.evaluate(()=>performance.getEntriesByType('resource').map(item=>item.name).filter(name=>name.includes('/images/inspect/')));expect(urls.every(value=>new URL(value).searchParams.has('root_scope'))).toBe(true);
});
test('full inspector keeps long literal content reachable on mobile',async({page,request})=>{
await page.addInitScript(()=>Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async()=>{throw Error('Denied');}}}));
const literal='<svg onload="window.executed=true"><image href="https://invalid.test/never"/></svg></pre>'+'verylongmodelpath/'.repeat(30);
let external=0;await page.route('https://invalid.test/**',route=>{external++;return route.abort();});
await page.route('**/images/inspect/**',async route=>{const response=await route.fetch();const data=await response.json();data.metadata.raw.parameters=literal;data.metadata.record_formats.parameters='original-text';data.metadata.parameters=literal;data.metadata.prompt_graph.value['4'].inputs.ckpt_name=literal;await route.fulfill({response,json:data});});
await page.setViewportSize({width:390,height:844});await fullFixture(page,request);const panel=inspector(page);await expect(panel.locator('.prompt-positive pre')).toContainText('fine geometry and light.');await panel.getByRole('button',{name:'Copy positive prompt',exact:true}).click();await expect(panel).toContainText('Copy unavailable. Select the prompt text to copy it.');
await panel.getByText('Workflow & raw metadata',{exact:true}).click();await expect(panel.locator('pre code').filter({hasText:literal}).last()).toHaveText(literal);await expect(panel.locator('.generation-facts dd').last()).toHaveText(literal);
for(const zoom of [1,2]) {await page.evaluate(zoom=>{document.documentElement.style.zoom=String(zoom);},zoom);await expect.poll(()=>page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);const footer=panel.getByRole('button',{name:'Send prompt pair',exact:true});await footer.focus();await expect(footer).toBeFocused();const summary=panel.getByText('File & provenance',{exact:true});await summary.focus();await page.keyboard.press('Enter');await expect(panel).toContainText('historical bytes are not verified');}
expect(await page.evaluate(()=>window.executed)).toBeUndefined();expect(external).toBe(0);
});
test('embedded gallery uses the same full inspector',async({page,request})=>{
await expect(await request.post('/__test__/reset',{data:{full_inspector:true}})).toBeOK();const list=await(await request.get('/prompt_manager/library')).json();const id=list.items.find(item=>item.text.startsWith('Inspector portrait')).id;
await page.goto(`/prompt_manager/workspace#/library/${id}`);await selectFile(page,'complete.png');const panel=inspector(page);await expect(panel.getByRole('heading',{name:'Inspector recipe',exact:true})).toBeVisible();await expect(panel.locator('.generation-facts dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);await expect(panel).toContainText('Recipe tags');await panel.getByRole('button',{name:'Open full preview',exact:true}).click();await expect(page.locator('.image-preview .file-inspector')).toHaveCount(1);
});
test('full inspector labels container representations and releases action URLs',async({page,request})=>{
await fullFixture(page,request);
await page.evaluate(async()=>{
const {mountFileInspector}=await import('/prompt_manager/js/workspace/file-inspector.mjs');
const {toInspectionRecord,inspectPath}=await import('/prompt_manager/js/workspace/inspection-state.mjs');
const {createScope}=await import('/prompt_manager/js/shared/toolkit/core.js');
const api=window.ApiClient.createApiClient();const list=await api.get('/prompt_manager/images/output?mode=workspace&subfolder=inspector');const image=list.images.find(item=>item.root_index===0&&item.path==='inspector/complete.png');
const record=toInspectionRecord(await api.get(inspectPath(image)),image);record.file.media_type='video';record.metadata.record_formats.workflow='reserialized-json';record.metadata.record_formats.comfyassets_prompt_pairs='invalid';
window.renderFixture={record,scope:createScope(),created:[],revoked:[],timers:0};
const create=URL.createObjectURL.bind(URL),revoke=URL.revokeObjectURL.bind(URL),timeout=window.setTimeout.bind(window);
URL.createObjectURL=blob=>{const url=create(blob);window.renderFixture.created.push(url);return url;};URL.revokeObjectURL=url=>{window.renderFixture.revoked.push(url);revoke(url);};
window.setTimeout=(callback,delay,...args)=>{if(delay===1000)window.renderFixture.timers++;return timeout(callback,delay,...args);};
const container=document.createElement('section');container.id='render-fixture';document.body.append(container);
window.renderFixture.view=mountFileInspector(container,{parentScope:window.renderFixture.scope});window.renderFixture.view.setState({record});
});
const panel=page.locator('#render-fixture');await panel.getByText('Workflow & raw metadata',{exact:true}).click();
await expect(panel).toContainText('Reserialized container JSON; numeric spelling/precision may differ');await expect(panel).toContainText('Invalid container record; text is unavailable');
await expect(panel.getByRole('button',{name:'Download workflow JSON',exact:true})).toHaveAttribute('aria-disabled','true');await expect(panel).toContainText('Original workflow JSON text is unavailable for this container record. Download metadata or the original file.');
const metadata=await downloadText(page,panel.getByRole('button',{name:'Download metadata JSON',exact:true}));const value=JSON.parse(metadata.text);expect(value.metadata.record_formats.workflow).toBe('reserialized-json');expect(value.metadata.raw.workflow).toContain('9007199254740993');expect(value.metadata.raw.comfyassets_prompt_pairs).toBeUndefined();
await page.evaluate(()=>window.renderFixture.view.setState({error:true,refreshRequired:true,message:'The source changed. Refresh the image collection before inspecting this file.'}));
await expect(panel).toContainText('Reopen this file from a refreshed image collection.');await expect(panel.locator('button,a[download]')).toHaveCount(0);
expect(await page.evaluate(()=>({created:window.renderFixture.created,revoked:window.renderFixture.revoked,timers:window.renderFixture.timers}))).toMatchObject({timers:1});
await expect.poll(()=>page.evaluate(()=>window.renderFixture.created.length===window.renderFixture.revoked.length)).toBe(true);
await page.evaluate(()=>{window.renderFixture.view.dispose();window.renderFixture.view.dispose();window.renderFixture.scope.dispose();});await expect(panel).toBeEmpty();
expect(await page.evaluate(()=>window.renderFixture.revoked)).toEqual(await page.evaluate(()=>window.renderFixture.created));
});
test('full inspector distinguishes missing, limited and legacy unknown generation facts',async({page,request})=>{
await fullFixture(page,request);const panel=inspector(page);
await selectFile(page,'empty.png');await expect(panel.locator('.generation-facts dd')).toHaveText(Array(6).fill('Not recorded'));
await page.locator('article[data-image-key="0:architecture/study-01.png"] .image-open').click();await expect(panel.locator('.generation-facts dd')).toHaveText(Array(6).fill('Not recorded'));
await selectFile(page,'control-heavy.png');
const explanation='Configuration exceeds inspection limits; download the original file to inspect it locally.';
await expect(panel.getByText(explanation,{exact:true})).toHaveCount(1);await expect(panel.getByText(explanation,{exact:true})).toBeVisible();
for(const value of await panel.locator('.generation-facts dd').all())await expect(value).toHaveAccessibleDescription(explanation);
await page.evaluate(async()=>{
const {mountFileInspector}=await import('/prompt_manager/js/workspace/file-inspector.mjs');
const {toInspectionRecord}=await import('/prompt_manager/js/workspace/inspection-state.mjs');
const {createScope}=await import('/prompt_manager/js/shared/toolkit/core.js');
const container=document.createElement('section');container.id='legacy-facts';document.body.append(container);
window.legacyFactsScope=createScope();window.legacyFactsView=mountFileInspector(container,{parentScope:window.legacyFactsScope});
window.legacyFactsView.setState({record:toInspectionRecord({prompts:{}},{root_index:0,path:'legacy.png'})});
});
const legacy=page.locator('#legacy-facts');await expect(legacy.locator('.generation-facts dd')).toHaveText(Array(6).fill('Unresolved'));await expect(legacy).not.toContainText(explanation);await expect(legacy.locator('.generation-facts dd[aria-describedby]')).toHaveCount(0);
await page.evaluate(()=>{window.legacyFactsView.dispose();window.legacyFactsScope.dispose();});
});
test.describe('full inspector coarse pointer',()=>{
test.use({hasTouch:true,viewport:{width:390,height:844}});
test('full inspector meets coarse target sizes and keeps long content reachable at zoom',async({page,request})=>{
await fullFixture(page,request);expect(await page.evaluate(()=>matchMedia('(pointer: coarse)').matches)).toBe(true);const panel=inspector(page);
await expect(panel.locator('.prompt-positive pre')).toContainText('Full prompt context, fine geometry and light.');
const fullText=await panel.locator('.prompt-positive pre').textContent();expect(fullText.length).toBeGreaterThan(1000);
await panel.getByText('File & provenance',{exact:true}).click();await panel.getByText('Workflow & raw metadata',{exact:true}).click();
const bounds=await panel.locator('button,a,summary').evaluateAll(nodes=>nodes.map(node=>({name:node.getAttribute('aria-label')||node.textContent,role:node.matches('.prompt-actions button'),width:node.getBoundingClientRect().width,height:node.getBoundingClientRect().height})));
for(const target of bounds){expect(target.width,target.name).toBeGreaterThanOrEqual(target.role?36:40);expect(target.height,target.name).toBeGreaterThanOrEqual(target.role?36:40);}
for(const zoom of [1,2]) {
await page.evaluate(zoom=>{document.documentElement.style.zoom=String(zoom);},zoom);
await expect.poll(()=>page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);
await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
for(const target of [panel.locator('summary').first(),panel.getByRole('button',{name:'Download metadata JSON',exact:true}),panel.getByRole('link',{name:'Prompt history',exact:true})]) {
await target.scrollIntoViewIfNeeded();await target.focus();await expect(target).toBeFocused();await expect(target).toBeInViewport();
}
await expect(panel.locator('.prompt-positive pre')).toHaveText(fullText);
}
await panel.getByRole('button',{name:'Open full preview',exact:true}).click();const viewer=page.getByRole('dialog',{name:'Image preview',exact:true});
await expect(viewer.locator('.viewer-canvas img')).toBeVisible();
for(const target of await viewer.locator('.image-preview-controls > :is(button,a),.image-preview-header > button').all()) {
const box=await target.evaluate(node=>{const {width,height}=node.getBoundingClientRect();return {width,height,name:node.getAttribute('aria-label')};});
expect(box.width,box.name).toBeGreaterThanOrEqual(80);expect(box.height,box.name).toBeGreaterThanOrEqual(80);
}
const raw=viewer.getByText('Workflow & raw metadata',{exact:true});await raw.scrollIntoViewIfNeeded();await raw.focus();await expect(raw).toBeInViewport();
});
});
const mediaDialog=page=>page.getByRole('dialog',{name:'Image preview',exact:true});
const mediaStage=page=>mediaDialog(page).getByRole('region',{name:'Media preview',exact:true});
const position=page=>mediaDialog(page).locator('.image-preview-position');
async function openCollection(page) {
await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();
await expect(mediaDialog(page).locator('.viewer-canvas img')).toBeVisible();
await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
}
test('collection viewer follows filtered loaded order beyond mounted cards',async({page,request})=>{
await page.addInitScript(()=>{window.IntersectionObserver=undefined;});
await expect(await request.post('/__test__/reset',{data:{full_inspector:true,extra_images:200}})).toBeOK();
await page.goto('/prompt_manager/workspace#/images');
await page.getByRole('combobox',{name:'Source',exact:true}).selectOption('0');
await page.getByRole('combobox',{name:'Folder',exact:true}).selectOption('inspector');
await page.getByRole('combobox',{name:'Sort',exact:true}).selectOption('name');
const more=page.getByRole('button',{name:'Load more images',exact:true});
await expect(page.locator('.image-toolbar')).toContainText('40 of');
for(const n of [80,120,160]) {await more.click();await expect(page.locator('.image-toolbar')).toContainText(`${n} of`);}
const listed=await(await request.get('/prompt_manager/images/output?mode=workspace&root=0&subfolder=inspector&sort=name&limit=200')).json();
const loaded=listed.images.slice(0,160);expect(loaded).toHaveLength(160);
await page.evaluate(()=>scrollTo(0,0));await expect.poll(()=>page.locator('article[data-image-key]').count()).toBeLessThan(120);
let release,started;const held=new Promise(resolve=>{release=resolve;});const hit=new Promise(resolve=>{started=resolve;});
await page.route('**/images/output?*',async route=>{started();await held;await route.continue();},{times:1});
await more.click();await hit;await page.evaluate(()=>scrollTo(0,0));
const opener=page.locator(`article[data-image-key="0:${loaded[0].path}"] .image-card-footer button`);
await opener.click();release();await expect(page.locator('.image-toolbar')).toContainText('200 of');
const modal=mediaDialog(page);await expect(position(page)).toHaveText(`1 of 160 loaded: ${loaded[0].filename}`);
let pages=0;page.on('request',req=>{if(req.url().includes('/images/output?'))pages++;});
await modal.getByRole('button',{name:'Previous media',exact:true}).click();
await expect(position(page)).toHaveText(`160 of 160 loaded: ${loaded[159].filename}`);
await expect(modal.locator('.file-name')).toHaveText(loaded[159].filename);
await expect(modal.getByRole('group',{name:'Media controls',exact:true}).getByRole('link',{name:'Download original',exact:true})).toHaveAttribute('href',loaded[159].url);
await expect(page.locator(`article[data-image-key="0:${loaded[159].path}"]`)).toHaveCount(0);
await modal.getByRole('button',{name:'Next media',exact:true}).click();await expect(position(page)).toHaveText(`1 of 160 loaded: ${loaded[0].filename}`);
await mediaStage(page).focus();await page.keyboard.press('ArrowRight');await expect(position(page)).toHaveText(`2 of 160 loaded: ${loaded[1].filename}`);
expect(pages).toBe(0);await modal.getByRole('button',{name:'Close preview',exact:true}).click();
await expect(opener).toBeFocused();
await page.getByRole('combobox',{name:'Source',exact:true}).selectOption('1');
await page.getByRole('combobox',{name:'Folder',exact:true}).selectOption('inspector');
await selectFile(page,'complete.png',1);await openCollection(page);await expect(position(page)).toHaveText('1 of 1 loaded: complete.png');
for(const name of ['Previous media','Next media'])await expect(mediaDialog(page).getByRole('button',{name,exact:true})).toHaveAttribute('aria-disabled','true');
const onePlay=mediaDialog(page).getByRole('button',{name:'Play slideshow',exact:true});
await expect(onePlay).toHaveAccessibleDescription('Slideshow requires at least two loaded media entries.');await onePlay.dispatchEvent('click');await expect(onePlay).toHaveText('Play');
await mediaDialog(page).getByRole('button',{name:'Close preview',exact:true}).click();
const library=await(await request.get('/prompt_manager/library')).json();const id=library.items.find(item=>item.text.startsWith('Inspector portrait')).id;
await page.goto(`/prompt_manager/workspace#/library/${id}`);await page.getByRole('button',{name:'Select negative variant 1',exact:true}).click();await selectFile(page,'complete.png');await openCollection(page);
await expect(position(page)).toContainText('of 1 loaded: complete.png');
await mediaDialog(page).getByRole('button',{name:'Close preview',exact:true}).click();
await page.getByRole('button',{name:'All images for this positive',exact:true}).click();
await selectFile(page,'complete.png');await openCollection(page);await expect(position(page)).toContainText('of 2 loaded:');
});
test('collection viewer uses real public transforms and resets on navigation',async({page,request})=>{
await fullFixture(page,request);
await page.evaluate(()=>{
window.viewerCalls=[];window.viewerInstances=[];
const Native=window.Viewer;
for(const method of ['zoom','reset','rotate','scaleX']) {const original=Native.prototype[method];Native.prototype[method]=function(...args){window.viewerCalls.push([method,...args]);return original.apply(this,args);};}
window.Viewer=class extends Native {constructor(...args){super(...args);window.viewerInstances.push(this);}};
});
await openCollection(page);const modal=mediaDialog(page),controls=modal.getByRole('group',{name:'Media controls',exact:true});
await expect(modal.locator('.viewer-container')).toHaveCount(1);await expect(modal.locator(':is(.viewer-toolbar,.viewer-navbar,.viewer-title,.viewer-button):visible')).toHaveCount(0);
const fitted=await page.evaluate(()=>({...window.viewerInstances.at(-1).imageData}));
await controls.getByRole('button',{name:'Zoom in',exact:true}).click();expect(await page.evaluate(()=>window.viewerInstances.at(-1).imageData.width)).toBeGreaterThan(fitted.width);
await controls.getByRole('button',{name:'Zoom out',exact:true}).click();
await controls.getByRole('button',{name:'Rotate image 90 degrees',exact:true}).click();await controls.getByRole('button',{name:'Flip image horizontally',exact:true}).click();
expect(await page.evaluate(()=>({rotate:window.viewerInstances.at(-1).imageData.rotate,scaleX:window.viewerInstances.at(-1).imageData.scaleX}))).toEqual({rotate:90,scaleX:-1});
const fit=controls.getByRole('button',{name:'Fit image',exact:true});await fit.click();await expect(fit).toBeFocused();
expect(await page.evaluate(()=>({rotate:window.viewerInstances.at(-1).imageData.rotate,scaleX:window.viewerInstances.at(-1).imageData.scaleX}))).toEqual({rotate:0,scaleX:1});
expect(await page.evaluate(()=>window.viewerCalls)).toEqual(expect.arrayContaining([['zoom',0.2],['zoom',-0.2],['reset'],['rotate',90],['scaleX',-1]]));
await page.evaluate(()=>{window.currentMedia=document.querySelector('.viewer-canvas img');window.currentFit=document.querySelector('[aria-label="Fit image"]');});
await modal.getByText('File & provenance',{exact:true}).click();await modal.getByText('Workflow & raw metadata',{exact:true}).click();
expect(await page.evaluate(()=>window.currentMedia===document.querySelector('.viewer-canvas img')&&window.currentFit===document.querySelector('[aria-label="Fit image"]'))).toBe(true);
await controls.getByRole('button',{name:'Rotate image 90 degrees',exact:true}).click();await controls.getByRole('button',{name:'Next media',exact:true}).click();
await expect(controls.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');await expect(modal.locator('.viewer-container')).toHaveCount(1);
expect(await page.evaluate(()=>window.currentMedia!==document.querySelector('.viewer-canvas img')&&window.currentFit===document.querySelector('[aria-label="Fit image"]'))).toBe(true);
expect(await page.evaluate(()=>({rotate:window.viewerInstances.at(-1).imageData.rotate,scaleX:window.viewerInstances.at(-1).imageData.scaleX}))).toEqual({rotate:0,scaleX:1});
await expect(controls.getByRole('button',{name:'Play slideshow',exact:true})).toBeVisible();
});
async function slideshowFixture(page,request,preferences={videoAutoplay:false,videoMute:true,videoLoop:false}) {
await expect(await request.post('/__test__/reset',{data:{full_inspector:true,include_video:true}})).toBeOK();
await page.goto('/prompt_manager/workspace#/images');
await page.evaluate(async preferences=>{
if(preferences) localStorage.setItem('gallerySettings',JSON.stringify(preferences));
const [{createMediaViewer},{createInspectionController},{createScope},{createPromptSender}]=await Promise.all([
import('/prompt_manager/js/workspace/media-viewer.mjs'),import('/prompt_manager/js/workspace/inspection-state.mjs'),
import('/prompt_manager/js/shared/toolkit/core.js'),import('/prompt_manager/js/workspace/prompt-sender.mjs')]);
const api=window.ApiClient.createApiClient(),collection=await api.get('/prompt_manager/images/output?mode=workspace&subfolder=inspector&limit=40');
const items=['complete.png','clip.webm','explicit-empty.png'].map(filename=>collection.images.find(image=>image.root_index===0&&image.filename===filename));
window.slideshowTimers=new Set();window.slideshowCallbacks=[];window.slideshowReady=[];
const schedule=window.setTimeout.bind(window),cancel=window.clearTimeout.bind(window);
window.setTimeout=(callback,delay,...args)=>{let id;id=schedule(()=>{window.slideshowTimers.delete(id);callback(...args);},delay);if(delay===3500){window.slideshowTimers.add(id);window.slideshowCallbacks.push(callback);}return id;};
window.clearTimeout=id=>{window.slideshowTimers.delete(id);cancel(id);};
const Native=window.Viewer;
window.Viewer=class extends Native {constructor(node,options){window.slideshowReady.push(options.viewed);super(node,options);}};
const scope=createScope(),controller=createInspectionController({api}),sender=createPromptSender({scope});scope.cleanup(()=>controller.dispose());
const view=createMediaViewer({container:document.getElementById('workspace-main'),parentScope:scope,inspectionController:controller,sender,onSelect:entry=>controller.select(entry)});
window.slideshowFixture={scope,controller,view,items,open:()=>view.open({items,selectedKey:'0:inspector/complete.png'})};window.slideshowFixture.open();
},preferences);
await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
await expect(mediaDialog(page).locator('.file-name')).toHaveText('complete.png');
await page.clock.install();await page.clock.pauseAt(new Date(Date.now()+1000));
}
const slideshowButton=page=>mediaDialog(page).getByRole('group',{name:'Media controls',exact:true}).getByRole('button',{name:/^(Play|Pause|Stop) slideshow$/});
const timerCount=page=>page.evaluate(()=>window.slideshowTimers.size);
const mediaStep=async(page,direction)=>mediaDialog(page).getByRole('button',{name:`${direction} media`,exact:true}).click();
async function readyClockImage(page) {
await expect.poll(async()=>{await page.clock.runFor(50);return mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true}).getAttribute('aria-disabled');}).toBe('false');
}
test('slideshow exposes exact state labels and respects video playback',async({page,request})=>{
const originals=[];page.on('request',request=>{if(request.url().includes('_pm_viewer='))originals.push(request.url());});
await slideshowFixture(page,request);const button=slideshowButton(page),modal=mediaDialog(page);
await expect(button).toHaveText('Play');await expect(button).not.toHaveAttribute('aria-pressed');
expect(originals).toHaveLength(1);
await button.click();await expect(button).toHaveText('Pause');await expect(button).toBeFocused();await expect(position(page)).toContainText('Slideshow playing.');expect(await timerCount(page)).toBe(1);
await page.clock.runFor(3499);await expect(position(page)).toContainText('1 of 3 loaded: complete.png');
await page.clock.runFor(1);await expect(position(page)).toContainText('2 of 3 loaded: clip.webm');
await expect(button).toHaveAccessibleName('Stop slideshow');await expect(button).toHaveText('Stop');await expect(button).toBeFocused();
await expect(position(page)).toContainText('Slideshow paused for video. Next or Previous continues it on an image; Stop ends slideshow.');expect(await timerCount(page)).toBe(0);
const video=modal.locator('video');await expect.poll(()=>video.evaluate(node=>node.readyState)).toBeGreaterThanOrEqual(1);
expect(await video.evaluate(node=>({controls:node.controls,inline:node.playsInline,muted:node.muted,loop:node.loop}))).toEqual({controls:true,inline:true,muted:true,loop:false});
await expect(modal.getByRole('button',{name:'Zoom in',exact:true})).toHaveAccessibleDescription('Image transforms are unavailable for video.');
await video.evaluate(node=>node.play());await expect.poll(()=>video.evaluate(node=>node.ended)).toBe(true);
await page.clock.runFor(10500);await expect(position(page)).toContainText('2 of 3 loaded');await expect(button).toHaveText('Stop');
await video.evaluate(node=>{node.loop=true;node.currentTime=0;return node.play();});
await button.click();await expect(button).toHaveText('Play');expect(await video.evaluate(node=>node.paused)).toBe(false);
await expect(button).toHaveAttribute('aria-disabled','true');await mediaStep(page,'Next');
await readyClockImage(page);await expect(button).toHaveText('Play');expect(await timerCount(page)).toBe(0);
await button.click();await mediaStep(page,'Previous');await expect(button).toHaveText('Stop');
await mediaStep(page,'Previous');await expect(button).toHaveText('Pause');await expect(position(page)).toContainText('Slideshow waiting for image.');await readyClockImage(page);await expect.poll(()=>timerCount(page)).toBe(1);
expect(new Set(originals.map(url=>new URL(url).pathname)).size).toBe(3);
await button.click();await modal.locator('.image-preview-details').evaluate(node=>{node.scrollTop=node.scrollHeight;});await page.clock.runFor(50);
await button.click();await mediaStep(page,'Previous');await readyClockImage(page);await expect(button).toHaveText('Pause');expect(await timerCount(page)).toBe(1);
});
test('slideshow stop fences old timer and readiness callbacks',async({page,request})=>{
await slideshowFixture(page,request);const button=slideshowButton(page);
await button.click();await page.clock.runFor(3499);await button.click();
await page.evaluate(()=>{for(const callback of window.slideshowCallbacks)callback();for(const callback of window.slideshowReady)callback();});
await page.clock.runFor(7000);await expect(position(page)).toContainText('1 of 3 loaded');await expect(button).toHaveText('Play');expect(await timerCount(page)).toBe(0);
await button.click();await mediaStep(page,'Previous');await expect(position(page)).toContainText('Slideshow waiting for image.');await button.click();
await page.evaluate(()=>{for(const callback of window.slideshowCallbacks)callback();for(const callback of window.slideshowReady)callback();});
await expect(button).toHaveText('Play');expect(await timerCount(page)).toBe(0);await mediaStep(page,'Next');await readyClockImage(page);
await page.route('**/images/serve/inspector/explicit-empty.png?*',route=>route.abort());
await button.click();await mediaStep(page,'Previous');await expect(position(page)).toContainText('Media could not be loaded.');
await expect(button).toHaveText('Play');await expect(button).toHaveAttribute('aria-disabled','true');expect(await timerCount(page)).toBe(0);
await mediaStep(page,'Next');await expect(button).toHaveText('Play');
await page.evaluate(()=>window.slideshowFixture.scope.dispose());await expect(mediaDialog(page)).toHaveCount(0);
await page.evaluate(()=>{for(const callback of window.slideshowCallbacks)callback();for(const callback of window.slideshowReady)callback();window.slideshowFixture.view.stopSlideshow();});
expect(await timerCount(page)).toBe(0);
});
test('slideshow production query and route disposal cancel the active deadline',async({page,request})=>{
await fullFixture(page,request);await page.clock.install();
await page.evaluate(()=>{
window.slideshowTimers=new Set();const schedule=window.setTimeout.bind(window),cancel=window.clearTimeout.bind(window);
window.setTimeout=(callback,delay,...args)=>{let id;id=schedule(()=>{window.slideshowTimers.delete(id);callback(...args);},delay);if(delay===3500)window.slideshowTimers.add(id);return id;};
window.clearTimeout=id=>{window.slideshowTimers.delete(id);cancel(id);};
});
await openCollection(page);await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
await page.clock.pauseAt(new Date(Date.now()+1000));await slideshowButton(page).click();expect(await timerCount(page)).toBe(1);
await page.clock.runFor(3499);
const pendingAtReset=await page.evaluate(()=>{const folder=document.getElementById('image-folder');folder.value='inspector';folder.dispatchEvent(new Event('change'));return window.slideshowTimers.size;});
expect(pendingAtReset).toBe(0);await page.clock.runFor(1);
await expect(mediaDialog(page)).toHaveCount(0);expect(await timerCount(page)).toBe(0);
await selectFile(page,'complete.png');await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();await readyClockImage(page);await expect(slideshowButton(page)).toHaveText('Play');
await slideshowButton(page).click();await page.evaluate(()=>{location.hash='/overview';});await expect(page.locator('h1')).toHaveText('Overview');await expect(mediaDialog(page)).toHaveCount(0);expect(await timerCount(page)).toBe(0);
await page.clock.runFor(7000);await expect(page.locator('.viewer-container')).toHaveCount(0);
});
test('slideshow interaction visibility sender and disposal clear intent',async({page,request})=>{
await slideshowFixture(page,request);const button=slideshowButton(page),modal=mediaDialog(page);
for(const action of [
()=>modal.locator('.prompt-positive pre').click(),
()=>modal.locator('.image-preview-details').dispatchEvent('wheel',{deltaY:100}),
()=>modal.getByRole('button',{name:'Copy positive prompt',exact:true}).click(),
async()=>{await modal.getByText('Workflow & raw metadata',{exact:true}).click();await downloadText(page,modal.getByRole('button',{name:'Download metadata JSON',exact:true}));},
async()=>{await page.evaluate(()=>{Object.defineProperty(document,'hidden',{configurable:true,value:true});document.dispatchEvent(new Event('visibilitychange'));Object.defineProperty(document,'hidden',{configurable:true,value:false});document.dispatchEvent(new Event('visibilitychange'));});},
async()=>{const trigger=modal.getByRole('button',{name:'Send prompt pair',exact:true});await trigger.click();await expect(page.getByRole('dialog',{name:'Send to ComfyUI',exact:true})).toBeVisible();await page.keyboard.press('Escape');await expect(trigger).toBeFocused();},
()=>modal.getByRole('group',{name:'Media controls',exact:true}).getByRole('link',{name:'Download original',exact:true}).click(),
()=>page.evaluate(()=>window.slideshowFixture.view.stopSlideshow()),
]) {
await button.click();expect(await timerCount(page)).toBe(1);await action();await expect(button).toHaveText('Play');expect(await timerCount(page)).toBe(0);await page.clock.runFor(3500);await expect(position(page)).toContainText('1 of 3 loaded');
}
for(let mount=0;mount<3;mount++) {
await button.click();await modal.getByRole('button',{name:'Close preview',exact:true}).click();await expect(modal).toHaveCount(0);expect(await timerCount(page)).toBe(0);
await page.evaluate(()=>window.slideshowFixture.open());await expect(button).toHaveText('Play');
await readyClockImage(page);
}
await button.click();await page.evaluate(()=>window.dispatchEvent(new PageTransitionEvent('pagehide',{persisted:true})));await expect(modal).toHaveCount(0);expect(await timerCount(page)).toBe(0);
await page.evaluate(()=>window.dispatchEvent(new PageTransitionEvent('pageshow',{persisted:true})));await expect(modal).toHaveCount(0);
});
test('slideshow preference failures leave native playback usable',async({page,request})=>{
await slideshowFixture(page,request,null);const modal=mediaDialog(page);
for(const failure of ['denied','malformed','rejected']) {
await page.evaluate(failure=>{
if(failure==='denied') {window.normalStorage=Object.getOwnPropertyDescriptor(window,'localStorage');Object.defineProperty(window,'localStorage',{configurable:true,get(){throw new DOMException('Denied','SecurityError');}});}
else {if(window.normalStorage)Object.defineProperty(window,'localStorage',window.normalStorage);localStorage.setItem('gallerySettings',failure==='malformed'?'{broken':JSON.stringify({videoAutoplay:true,videoMute:true,videoLoop:false}));}
if(failure==='rejected'){window.nativePlay=HTMLMediaElement.prototype.play;window.playRejections=0;HTMLMediaElement.prototype.play=function(){window.playRejections++;return Promise.reject(new DOMException('Blocked','NotAllowedError'));};}
},failure);
await mediaStep(page,'Next');const video=modal.locator('video');await expect.poll(()=>video.evaluate(node=>node.readyState)).toBeGreaterThanOrEqual(1);
expect(await video.evaluate(node=>({controls:node.controls,inline:node.playsInline,muted:node.muted,loop:node.loop}))).toEqual({controls:true,inline:true,muted:true,loop:failure!=='rejected'});
if(failure==='rejected') {expect(await page.evaluate(()=>window.playRejections)).toBe(1);await page.evaluate(()=>{HTMLMediaElement.prototype.play=window.nativePlay;});}
await video.evaluate(node=>node.play());expect(await video.evaluate(node=>node.paused)).toBe(false);
await mediaStep(page,'Previous');await expect(slideshowButton(page)).toHaveText('Play');
}
});
test('full inspector attaches matched theme and mobile evidence with reachable content',async({page,request},testInfo)=>{
await expect(await request.post('/__test__/reset',{data:{full_inspector:true,include_video:true}})).toBeOK();
await page.goto('/prompt_manager/workspace#/images');
const measurements=[];
async function capture(name,viewer=false) {
await page.evaluate(()=>document.fonts.ready);
if(viewer) {
const modal=mediaDialog(page),rail=modal.locator('.image-preview-details');
expect(await modal.evaluate(node=>node.scrollWidth<=node.clientWidth+1)).toBe(true);
measurements.push({name,...await modal.evaluate(node=>{
const box=element=>{const bounds=element.getBoundingClientRect();return{x:bounds.x,y:bounds.y,width:bounds.width,height:bounds.height};};
return{frame:box(node),header:box(node.querySelector('.image-preview-header')),stage:box(node.querySelector('.image-preview-stage')),
toolbar:box(node.querySelector('.image-preview-toolbar')),rail:box(node.querySelector('.image-preview-details')),
footer:node.querySelector('.file-inspector-footer')?box(node.querySelector('.file-inspector-footer')):null};
})});
if(page.viewportSize().width===1600)expect(await rail.evaluate(node=>node.getBoundingClientRect().width)).toBe(340);
else await expect(rail).toHaveCSS('overflow-y','visible');
}
const file=testInfo.outputPath(`${name}.png`);await page.screenshot({path:file});await testInfo.attach(name,{path:file,contentType:'image/png'});
}
for(const [width,height,theme]of [[1600,1100,'dark'],[1600,1100,'light'],[390,844,'dark'],[390,844,'light']]) {
const label=`${theme}-${width}`;await page.setViewportSize({width,height});
await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption(theme);
await selectFile(page,'complete.png');await expect(inspector(page).locator('.generation-facts dd').first()).toHaveText('9007199254740993');
if(width===1600)await page.evaluate(()=>scrollTo(0,0));else await inspector(page).locator('.file-cover').evaluate(node=>node.scrollIntoView({block:'start'}));
await capture(`inline-${label}`);
await openCollection(page);const modal=mediaDialog(page),rail=modal.locator('.image-preview-details');
await expect(modal.locator('.viewer-canvas img')).toBeVisible();await expect.poll(()=>modal.locator('.viewer-canvas img').evaluate(node=>node.complete&&node.naturalWidth>0)).toBe(true);
await modal.evaluate(node=>{node.scrollTop=0;});await capture(`viewer-${label}`,true);
await expect(rail.locator('.prompt-negative')).toContainText('blurred edges, watermark');await rail.locator('.generation-facts').scrollIntoViewIfNeeded();await capture(`facts-${label}`,true);
await rail.getByText('File & provenance',{exact:true}).click();await rail.locator('.file-facts').scrollIntoViewIfNeeded();await capture(`provenance-${label}`,true);
await rail.getByText('Workflow & raw metadata',{exact:true}).click();const raw=rail.locator('pre code').first();await raw.scrollIntoViewIfNeeded();await expect(raw).toBeInViewport();await capture(`raw-${label}`,true);
const send=rail.getByRole('button',{name:'Send prompt pair',exact:true});await send.focus();await expect(send).toBeInViewport();await send.click();await expect(page.getByRole('dialog',{name:'Send to ComfyUI',exact:true})).toBeVisible();await capture(`sender-${label}`);await page.keyboard.press('Escape');await expect(send).toBeFocused();
await modal.getByRole('button',{name:'Close preview',exact:true}).click();await selectFile(page,'clip.webm');await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();
await expect.poll(()=>modal.locator('video').evaluate(node=>node.readyState)).toBeGreaterThanOrEqual(1);await capture(`video-${label}`,true);await modal.getByRole('button',{name:'Close preview',exact:true}).click();
await selectFile(page,'control-heavy.png');await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();
await expect(modal).toContainText('Unavailable within inspection limits');await modal.getByText('Workflow & raw metadata',{exact:true}).click();
const limitedDownload=modal.getByRole('button',{name:'Download metadata JSON',exact:true});await limitedDownload.scrollIntoViewIfNeeded();await expect(limitedDownload).toHaveAttribute('aria-disabled','true');await capture(`limited-${label}`,true);
await modal.getByRole('button',{name:'Close preview',exact:true}).click();
}
const fs=await import('node:fs/promises'),geometryPath=testInfo.outputPath('inspector-geometry.json');
await fs.writeFile(geometryPath,JSON.stringify(measurements,null,2));await testInfo.attach('inspector-geometry',{path:geometryPath,contentType:'application/json'});
});
test('collection viewer preserves original download and rooted retries',async({page,request})=>{
await fullFixture(page,request);let deny=true;const originals=[];
await page.route('**/images/serve/inspector/complete.png?*',route=>{originals.push(route.request().url());return deny?route.fulfill({status:404,body:'Missing'}):route.continue();});
await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();const modal=mediaDialog(page);
await expect(modal).toContainText('Media could not be loaded. Retry this file.');await expect(modal.locator('.generation-facts dd').first()).toHaveText('9007199254740993');
await expect(modal.locator('.image-preview-position')).toContainText('Media could not be loaded.');
const download=modal.getByRole('group',{name:'Media controls',exact:true}).getByRole('link',{name:'Download original',exact:true});const original=await download.getAttribute('href');
await expect(modal.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','true');
const denied=page.waitForResponse(response=>response.url().includes('/images/serve/inspector/complete.png?')&&response.status()===404);
await modal.getByRole('button',{name:'Retry media',exact:true}).click();await denied;
await expect.poll(()=>mediaStage(page).locator('.image-preview-media > img').evaluate(node=>node.complete&&node.naturalWidth===0)).toBe(true);
await expect(modal.getByRole('button',{name:'Retry media',exact:true})).toBeVisible();
deny=false;await modal.getByRole('button',{name:'Retry media',exact:true}).click();await expect(modal.locator('.viewer-canvas img')).toBeVisible();
await expect(download).toHaveAttribute('href',original);
const stages=originals.map(value=>new URL(value)).filter(url=>url.searchParams.has('_pm_viewer'));
expect(new Set(stages.map(url=>url.searchParams.get('_pm_viewer'))).size).toBe(3);
for(const value of originals) {const actual=new URL(value),expected=new URL(original,'http://127.0.0.1');expect(actual.pathname).toBe(expected.pathname);for(const key of ['root','root_scope','v'])expect(actual.searchParams.get(key)).toBe(expected.searchParams.get(key));expect(actual.searchParams.getAll('_pm_viewer').length).toBeLessThanOrEqual(1);expect([...actual.searchParams.keys()].filter(key=>!expected.searchParams.has(key))).toEqual(actual.searchParams.has('_pm_viewer')?['_pm_viewer']:[]);}
const [saved]=await Promise.all([page.waitForEvent('download'),download.click()]);expect(saved.suggestedFilename()).toBe('complete.png');
await modal.getByRole('button',{name:'Close preview',exact:true}).click();await page.evaluate(()=>{window.availableViewer=window.Viewer;window.Viewer=undefined;});
await page.route('**/images/inspect/inspector/explicit-empty.png?*',route=>route.fulfill({status:503,json:{success:false,error:'Unavailable'}}),{times:1});
await selectFile(page,'explicit-empty.png');await expect(inspector(page)).toContainText('Could not read prompts from this file.');
await page.locator('article[data-image-key="0:inspector/explicit-empty.png"] .image-card-footer button').click();
await expect(mediaStage(page).locator('img')).toBeVisible();await expect(mediaStage(page).locator('img')).toHaveJSProperty('naturalWidth',720);
await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAccessibleDescription('Image transforms are unavailable because Viewer.js could not load.');
await page.evaluate(()=>{window.Viewer=window.availableViewer;});await mediaDialog(page).getByRole('button',{name:'Retry prompt details',exact:true}).click();
await expect(mediaDialog(page).locator('.file-name')).toHaveText('explicit-empty.png');await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','true');
});
test('collection viewer keeps nested dialog focus and route cleanup',async({page,request})=>{
await fullFixture(page,request);await openCollection(page);const modal=mediaDialog(page);
const send=modal.getByRole('button',{name:'Send prompt pair',exact:true});await send.click();const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
await expect(sender).toBeVisible();const before=await position(page).textContent();await page.keyboard.press('ArrowRight');await expect(position(page)).toHaveText(before);
await page.keyboard.press('Escape');await expect(sender).toBeHidden();await expect(modal).toBeVisible();await expect(send).toBeFocused();
const text=modal.locator('.prompt-positive pre');await text.focus();await page.keyboard.press('ArrowRight');await expect(position(page)).toHaveText(before);
await modal.getByText('Workflow & raw metadata',{exact:true}).focus();await page.keyboard.press('ArrowLeft');await expect(position(page)).toHaveText(before);
await modal.getByRole('button',{name:'Next media',exact:true}).click();const key=(await position(page).textContent()).split('loaded: ')[1];
await page.keyboard.press('Escape');await expect(modal).toHaveCount(0);await expect(page.locator('.image-open[aria-pressed="true"]')).toBeFocused();await expect(inspector(page).locator('.file-name')).toHaveText(key);
await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();await page.evaluate(()=>{location.hash='/overview';});
await expect(page.locator('h1')).toHaveText('Overview');await expect(page.locator('.viewer-container,.image-preview')).toHaveCount(0);
});
test('collection viewer matches approved responsive frame',async({page,request},testInfo)=>{
await page.setViewportSize({width:1600,height:1100});await fullFixture(page,request);await openCollection(page);
const modal=mediaDialog(page),body=modal.locator('.image-preview-body'),rail=modal.locator('.image-preview-details');
// Absolute gallery cards relayout through ResizeObserver and rAF after resize or CSS zoom.
const settleViewport=width=>expect.poll(()=>page.evaluate(()=>({inner:innerWidth,client:document.documentElement.clientWidth,scroll:document.documentElement.scrollWidth})),{message:`Images gallery relayout settles the ${width}px viewport`}).toEqual({inner:width,client:width,scroll:width});
await settleViewport(1600);
for(const theme of ['dark','light']) {
await page.evaluate(theme=>{const preference=document.getElementById('workspace-appearance');preference.value=theme;preference.dispatchEvent(new Event('change'));},theme);
const geometry=await modal.evaluate(node=>{const style=getComputedStyle(node),box=node.getBoundingClientRect(),rail=node.querySelector('.image-preview-details').getBoundingClientRect();return{width:box.width,height:box.height,padding:style.padding,radius:style.borderRadius,rail:rail.width,gap:getComputedStyle(node.querySelector('.image-preview-body')).columnGap};});
expect(geometry).toEqual({width:1500,height:940,padding:'18px',radius:'9px',rail:340,gap:'20px'});
await page.screenshot({path:testInfo.outputPath(`viewer-${theme}-1600.png`)});
}
for(const [width,expected] of [[1200,300],[900,270]]) {await page.setViewportSize({width,height:1000});await settleViewport(width);await expect(rail).toHaveCSS('width',`${expected}px`);}
await page.setViewportSize({width:390,height:844});await settleViewport(390);expect(await modal.evaluate(node=>node.getBoundingClientRect().width)).toBeCloseTo(356,1);
for(const [width,expected]of [[840,806],[850,816],[860,825.6]]) {
await page.setViewportSize({width,height:1000});await settleViewport(width);expect(await modal.evaluate(node=>node.getBoundingClientRect().width)).toBeCloseTo(expected,1);
}
await page.setViewportSize({width:390,height:844});await settleViewport(390);
for(const zoom of [1,2]) {
await page.evaluate(zoom=>{document.documentElement.style.zoom=String(zoom);},zoom);
await settleViewport(390);
expect(await modal.evaluate(node=>node.getBoundingClientRect().width)).toBeCloseTo(zoom===1?356:322,1);
expect(await modal.evaluate(node=>{const box=node.getBoundingClientRect();return box.top>=0&&box.bottom<=innerHeight+1;})).toBe(true);
await expect.poll(()=>modal.evaluate(node=>node.scrollWidth<=node.clientWidth+1)).toBe(true);
await expect(rail).toHaveCSS('overflow-y','visible');expect(await body.evaluate(node=>getComputedStyle(node).gridTemplateColumns.split(' ').length)).toBe(1);
await rail.getByText('Workflow & raw metadata',{exact:true}).click();const raw=rail.locator('pre code').first();await expect(raw).toBeVisible();
await rail.getByRole('button',{name:'Send prompt pair',exact:true}).focus();await expect(rail.getByRole('button',{name:'Send prompt pair',exact:true})).toBeInViewport();
await page.screenshot({path:testInfo.outputPath(`viewer-mobile-${zoom}x.png`)});await rail.getByText('Workflow & raw metadata',{exact:true}).click();
}
await rail.evaluate(node=>{node.style.fontFamily='"DejaVu Sans", sans-serif';});
await rail.getByText('Workflow & raw metadata',{exact:true}).click();
const sendPair=rail.getByRole('button',{name:'Send prompt pair',exact:true});await sendPair.focus();await expect(sendPair).toBeInViewport();
await rail.getByText('Workflow & raw metadata',{exact:true}).click();await sendPair.focus();await expect(sendPair).toBeFocused();await expect(sendPair).toBeInViewport();
const footer=await sendPair.evaluate(node=>{const box=node.getBoundingClientRect(),parent=node.closest('.file-inspector-footer').getBoundingClientRect();return{width:box.width,right:box.right,available:parent.width,parentRight:parent.right,height:box.height,minimum:matchMedia('(pointer: coarse)').matches?80:60};});
expect(footer.width).toBeLessThanOrEqual(footer.available);expect(footer.right).toBeLessThanOrEqual(footer.parentRight);expect(footer.height).toBeGreaterThanOrEqual(footer.minimum);await expect(sendPair.locator('svg')).toHaveCount(1);
expect(await modal.evaluate(node=>node.getBoundingClientRect().width)).toBeCloseTo(322,1);await expect.poll(()=>modal.evaluate(node=>node.scrollWidth<=node.clientWidth+1)).toBe(true);
await testInfo.attach('wider-font-footer-geometry',{body:JSON.stringify({footer,modal:await modal.evaluate(node=>({width:node.getBoundingClientRect().width,scrollWidth:node.scrollWidth,clientWidth:node.clientWidth}))}),contentType:'application/json'});
await page.screenshot({path:testInfo.outputPath('viewer-mobile-wider-font-2x.png')});
await modal.getByRole('button',{name:'Close preview',exact:true}).click();await page.evaluate(()=>{document.documentElement.style.zoom='1';});await settleViewport(390);
const longFiles=await(await request.get('/prompt_manager/images/output?mode=workspace&q=a-very-long&limit=40')).json();const long=longFiles.images[0];
await page.locator(`article[data-image-key="${long.root_index}:${long.path}"] .image-open`).click();await openCollection(page);
await page.evaluate(()=>{document.documentElement.style.zoom='2';});await settleViewport(390);await modal.evaluate(node=>{node.scrollTop=0;});
await expect(modal.getByRole('button',{name:'Close preview',exact:true})).toBeInViewport();
});
async function currentStageFit(page) {
return page.evaluate(()=>{
const instance=window.fitInstances.at(-1),stage=document.querySelector('.image-preview-stage');
const bounds=stage.getBoundingClientRect(),image=instance.image.getBoundingClientRect();
return {stage:{width:stage.offsetWidth,height:stage.offsetHeight},viewer:{...instance.viewerData},
image:{width:image.width,height:image.height},
center:{x:image.x+image.width/2-bounds.x-bounds.width/2,y:image.y+image.height/2-bounds.y-bounds.height/2},
contained:image.left>=bounds.left-1&&image.top>=bounds.top-1&&image.right<=bounds.right+1&&image.bottom<=bounds.bottom+1};
});
}
async function openStageFitFixture(page,request) {
await expect(await request.post('/__test__/reset',{data:{full_inspector:true}})).toBeOK();
await page.setViewportSize({width:1600,height:1100});await page.goto('/prompt_manager/workspace#/images');
let release;const pending=new Promise(resolve=>{release=resolve;});
await page.route('**/images/inspect/architecture/study-02.png?*',async route=>{
const response=await route.fetch(),data=await response.json();data.library.title='W'.repeat(220);await pending;
await route.fulfill({response,json:data}).catch(()=>{});
});
await page.evaluate(async()=>{
const [{createMediaViewer},{createInspectionController},{createScope},{createPromptSender}]=await Promise.all([
import('/prompt_manager/js/workspace/media-viewer.mjs'),import('/prompt_manager/js/workspace/inspection-state.mjs'),
import('/prompt_manager/js/shared/toolkit/core.js'),import('/prompt_manager/js/workspace/prompt-sender.mjs')]);
const api=window.ApiClient.createApiClient(),list=await api.get('/prompt_manager/images/output?mode=workspace&limit=40');
const tall=list.images.find(image=>image.root_index===1&&image.path==='architecture/study-02.png');
const next=list.images.find(image=>image.root_index===0&&image.path==='inspector/explicit-empty.png');
window.fitInstances=[];window.fitResizeCalls=[];window.fitObservers=[];
const Native=window.Viewer,resize=Native.prototype.resize;
Native.prototype.resize=function(...args){window.fitResizeCalls.push({destroyed:!!this.destroyed,connected:this.element.isConnected});return resize.apply(this,args);};
window.Viewer=class extends Native {constructor(...args){super(...args);window.fitInstances.push(this);}};
const NativeObserver=window.ResizeObserver;
window.ResizeObserver=class extends NativeObserver {
constructor(callback){super(callback);this.probe={callback,observer:this,disconnected:false};window.fitObservers.push(this.probe);}
observe(target,...args){this.probe.target=target;return super.observe(target,...args);}
disconnect(){this.probe.disconnected=true;super.disconnect();}
};
const scope=createScope(),controller=createInspectionController({api}),sender=createPromptSender({scope});scope.cleanup(()=>controller.dispose());
const view=createMediaViewer({container:document.getElementById('workspace-main'),parentScope:scope,inspectionController:controller,sender,onSelect:entry=>controller.select(entry)});
window.stageFitFixture={scope,controller,view};view.open({items:[tall,next],selectedKey:`${tall.root_index}:${tall.path}`,trigger:document.activeElement});
});
await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
await expect(mediaDialog(page).locator('.image-preview-reason')).toBeHidden();
return release;
}
async function attachStageFit(page,testInfo,name) {
const data=await currentStageFit(page);await testInfo.attach(name,{body:JSON.stringify(data,null,2),contentType:'application/json'});return data;
}
test('collection viewer fits current stage after readiness reason removal',async({page,request},testInfo)=>{
const release=await openStageFitFixture(page,request);
try {
await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
await attachStageFit(page,testInfo,'ready-stage-fit');
await page.screenshot({path:testInfo.outputPath('ready-stage-fit.png')});
await expect.poll(async()=>{const fit=await currentStageFit(page);return Math.abs(fit.viewer.height-fit.stage.height);}).toBeLessThanOrEqual(1);
const fit=await currentStageFit(page);expect(Math.abs(fit.center.x)).toBeLessThanOrEqual(1);expect(Math.abs(fit.center.y)).toBeLessThanOrEqual(1);expect(fit.contained).toBe(true);
} finally {release();await page.evaluate(()=>window.stageFitFixture.scope.dispose());}
});
test('collection viewer refits a late wrapped title without replacing current media',async({page,request},testInfo)=>{
const release=await openStageFitFixture(page,request);
try {
const modal=mediaDialog(page),fit=modal.getByRole('button',{name:'Fit image',exact:true});
await page.evaluate(()=>{window.fitImage=document.querySelector('.viewer-canvas img');window.fitControl=document.querySelector('[aria-label="Fit image"]');});
await fit.focus();const initial=await attachStageFit(page,testInfo,'before-late-title');release();
await expect(modal.locator('.image-preview-header h2')).toHaveText(`${'W'.repeat(220)} · study-02.png`);
await expect(fit).toBeFocused();await fit.click();await attachStageFit(page,testInfo,'late-title-fit');
await expect.poll(async()=>{const data=await currentStageFit(page);return Math.abs(data.viewer.height-data.stage.height);}).toBeLessThanOrEqual(1);
const current=await currentStageFit(page);expect(current.stage.height).toBeLessThan(initial.stage.height);expect(Math.abs(current.center.y)).toBeLessThanOrEqual(1);expect(current.contained).toBe(true);
await page.screenshot({path:testInfo.outputPath('late-title-fit.png')});
expect(await page.evaluate(()=>window.fitImage===document.querySelector('.viewer-canvas img')&&window.fitControl===document.querySelector('[aria-label="Fit image"]')&&window.fitInstances.length===1)).toBe(true);
await modal.getByRole('button',{name:'Zoom in',exact:true}).click();await modal.getByRole('button',{name:'Rotate image 90 degrees',exact:true}).click();await modal.getByRole('button',{name:'Flip image horizontally',exact:true}).click();
const transformed=await page.evaluate(()=>({...window.fitInstances[0].imageData}));await page.evaluate(()=>window.stageFitFixture.controller.retry());
await expect(modal.locator('.file-name')).toHaveText('study-02.png');await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
expect(await page.evaluate(()=>window.fitInstances[0].imageData)).toEqual(transformed);
await fit.click();const fitted=await currentStageFit(page);expect(Math.abs(fitted.center.y)).toBeLessThanOrEqual(1);expect(fitted.contained).toBe(true);
await modal.getByRole('button',{name:'Next media',exact:true}).click();await expect(modal.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
await page.evaluate(()=>{for(const probe of window.fitObservers.filter(probe=>probe.disconnected))probe.callback([],probe.observer);});
await modal.getByRole('button',{name:'Close preview',exact:true}).click();await expect(modal).toHaveCount(0);
await page.evaluate(async()=>{for(const probe of window.fitObservers)probe.callback([],probe.observer);await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));});
expect(await page.evaluate(()=>window.fitObservers.every(probe=>probe.disconnected))).toBe(true);
expect(await page.evaluate(()=>window.fitResizeCalls.filter(call=>call.destroyed||!call.connected))).toEqual([]);
} finally {release();await page.evaluate(()=>window.stageFitFixture.scope.dispose());}
});
@@ -1,219 +0,0 @@
import { writeFile } from 'node:fs/promises';
import { expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
// Content, row counts, and image bytes come from independent synthetic datasets.
// Saved choices and actual executions are separate production sections. The concept's
// illustrative repeat dialog is compared with Recorded run for native behavior;
// frozen evidence and current-file/unknown copy intentionally describe actual data.
// Parent acceptance scopes exact comparisons to history geometry/roles/native state.
// No content is masked and the immutable concept is never rewritten for comparison.
export const canonicalDifferences = {
data: ['Independent synthetic text, image bytes, IDs, counts and text wrapping'],
semantics: [
'Saved combinations are distinct from the additional chronological executions',
'Recorded run exposes exact frozen evidence; Repeat run is illustrative',
'Current-file candidates disclose unknown identity and context membership',
],
remainingApplicationPolish: [
'Gallery thumbnail selects details before Open full preview; direct Viewer/Info/tag/favorite/select parity remains outside #209',
'Shared Viewer stage background and toolbar alignment remain outside #209',
'Shared role-block spacing, padding and glyph styling remain outside #209; exact role order/actions and evidence disclosure are covered',
'Production keeps mobile toolbar inside media panel and readable scrolling details; the concept toolbar collision is not reproduced',
],
};
export async function captureCanonicalState(page, testInfo, name, selector) {
await page.evaluate(async () => {
await document.fonts.ready;
for (const image of document.images) image.loading = 'eager';
await Promise.all([...document.images].map(image => image.decode().catch(() => {})));
});
const target = selector ? page.locator(selector).first() : page;
const path = testInfo.outputPath(`${name}.png`);
await target.screenshot({ path, animations: 'disabled' });
await testInfo.attach(name, { path, contentType: 'image/png' });
}
export async function historyGeometry(page, production) {
const selectors = production ? {
surface: '.library-surface', timeline: '.library-branches', positive: '.library-positive-lane',
branch: '.library-variant-lane', label: '.library-variant-lane span', dot: '.library-variant-dot[aria-pressed="true"]',
positiveLabel: '.library-positive-lane span',
role: '[aria-label="Saved prompt texts"] .prompt-block',
prompt: '[aria-label="Saved prompt texts"] pre', gallery: '.library-shared-gallery',
heading: '.library-variants h2', intro: '.library-variants > p.muted',
footer: '.library-variant-footer', all: '.library-variants button:last-child',
recordedPositiveLabel: '.history-positive-lane span', recordedBranch: '.history-context-branch',
recordedHeading: '.recorded-history h2', recordedTimeline: '.history-timeline', recordedCanvas: '.history-canvas',
recordedPositive: '.history-positive-lane', recordedLabel: '.history-context-label',
recordedAnchor: '.history-context-anchor', recordedRepeat: '.history-run-marker:not(.history-context-anchor)',
recordedMarker: '.history-run-marker', recordedColumn: '.history-run-column',
} : {
surface: '.surface', timeline: '.timeline', positive: '.positive-lane', branch: '.variant-lane',
label: '.variant-lane span', dot: '.variant-dot[aria-pressed="true"]', role: '.surface .prompt-section',
positiveLabel: '.positive-lane span',
prompt: '.surface .prompt-text', gallery: '.history-gallery',
heading: '.timeline-panel h2', intro: '.timeline-panel .panel-heading .small',
footer: '.timeline-lower', all: '.timeline-lower button',
repeat: '.timeline .run-dot',
};
return page.evaluate(selectors => ({ pointerCoarse: matchMedia('(pointer: coarse)').matches,
...Object.fromEntries(Object.entries(selectors).map(([key, selector]) => {
const nodes = [...document.querySelectorAll(selector)];
return [key, nodes.map(node => {
const style = getComputedStyle(node), before = getComputedStyle(node, '::before');
const box = node.getBoundingClientRect();
return { x: box.x, y: box.y, width: box.width, height: box.height,
padding: style.padding, top: style.top, left: style.left, background: style.backgroundColor, color: style.color,
border: style.borderTopWidth, borderColor: style.borderTopColor, radius: style.borderRadius,
fontSize: style.fontSize, fontFamily: style.fontFamily, lineHeight: style.lineHeight,
fontWeight: style.fontWeight, text: node.textContent, contextId: node.dataset.contextId || node.dataset.contextBranch,
runId: node.dataset.runId,
marginLeft: style.marginLeft, outline: style.outlineWidth, outlineColor: style.outlineColor,
outlineOffset: style.outlineOffset, before: { width: before.width, height: before.height,
top: before.top, left: before.left, borderLeft: before.borderLeftWidth,
borderBottom: before.borderBottomWidth, radius: before.borderBottomLeftRadius,
background: before.backgroundColor, color: before.color, borderColor: before.borderTopColor,
borderLeftColor: before.borderLeftColor, borderBottomColor: before.borderBottomColor,
fontSize: before.fontSize, fontFamily: before.fontFamily, fontWeight: before.fontWeight,
content: before.content } };
})];
})) }), selectors);
}
export async function nativeDialogState(locator) {
return locator.evaluate(node => {
const style = getComputedStyle(node), box = node.getBoundingClientRect();
return { modal: node.matches(':modal'), focusInside: node.contains(document.activeElement),
background: style.backgroundColor, color: style.color, borderColor: style.borderTopColor,
border: style.borderTopWidth, width: box.width, height: box.height,
viewportWidth: innerWidth, viewportHeight: innerHeight };
});
}
export async function exerciseCurrentPairSend(page, roles) {
const host = await page.context().newPage();
await host.goto('/prompt_manager/workspace#/overview');
try {
await host.evaluate(async () => {
const { createBridgeHost } = await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');
const target = { id: 'canonical-pair', label: 'Canonical destination', type: 'PromptManagerPair', roles: ['positive', 'negative'] };
window.canonicalApplied = [];
window.canonicalHost = createBridgeHost({ origin: location.origin, namespace: '/', label: 'Canonical ComfyUI', adapter: {
listTargets: () => [target],
preview: (_id, roles) => ({ preview: { target, fields: Object.entries(roles).map(([role, after]) => ({ role, before: '', after })),
composition: { prepend: '', append: '', prependConnected: false, appendConnected: false, before: '', after: roles.positive } }, snapshot: roles }),
apply: roles => { window.canonicalApplied.push(roles);return { targetId: target.id, values: roles }; },
} });
});
const viewer = page.getByRole('dialog', { name: 'Image preview', exact: true });
const send = viewer.getByRole('button', { name: 'Send prompt pair', exact: true });await send.click();
const sender = page.getByRole('dialog', { name: 'Send to ComfyUI', exact: true });await expect(sender).toBeVisible();
await sender.getByRole('combobox', { name: 'ComfyUI window', exact: true }).selectOption({ label: 'Canonical ComfyUI' });
await sender.getByRole('combobox', { name: 'Destination node', exact: true }).selectOption({ label: 'Canonical destination' });
await sender.getByRole('button', { name: 'Preview changes', exact: true }).click();
await expect(sender).toContainText(roles.positive);await expect(sender).toContainText(roles.negative);
await sender.getByRole('button', { name: 'Apply to ComfyUI', exact: true }).click();await expect(sender).toContainText('Sent to ComfyUI.');
expect(await host.evaluate(() => window.canonicalApplied)).toEqual([roles]);
await page.keyboard.press('Escape');await expect(send).toBeFocused();
} finally {await host.evaluate(() => window.canonicalHost?.dispose());await host.close();}
}
export async function compareCanonicalHistory(reference, actual, testInfo) {
const path = testInfo.outputPath('canonical-history-geometry.json');
await writeFile(path, JSON.stringify({ reference, actual, comparisonScope: canonicalDifferences }, null, 2));
await testInfo.attach('canonical-history-geometry', { path, contentType: 'application/json' });
for (const geometry of [reference, actual]) {
expect.soft(geometry.role).toHaveLength(2);
expect.soft(geometry.prompt).toHaveLength(2);
expect.soft(geometry.heading[0].fontSize).toBe('14px');
expect.soft(geometry.intro[0].fontSize).toBe('12px');
expect.soft(geometry.footer).toHaveLength(1);
expect.soft(geometry.positiveLabel).toHaveLength(1);
expect.soft(geometry.positiveLabel[0]).toMatchObject({ top: '-25px', left: '38px', fontSize: '10px' });
expect.soft(geometry.surface[0].padding).toBe('22px');
expect.soft(geometry.surface[0].radius).toBe('7px');
expect.soft(geometry.timeline[0].padding).toBe('18px 20px');
expect.soft(geometry.positive[0].border).toBe('2px');
for (const [index, branch] of geometry.branch.entries()) {
expect.soft(branch.height).toBe(57);
expect.soft(branch.border).toBe('2px');
expect.soft(branch.marginLeft).toBe(`${38 + index * 47}px`);
expect.soft(branch.before).toMatchObject({ width: '30px', height: '25px', top: '-25px', left: '-30px', borderLeft: '2px', borderBottom: '2px', radius: '22px' });
}
}
expect.soft(actual.recordedPositiveLabel).toHaveLength(1);
expect.soft(actual.recordedPositiveLabel[0]).toMatchObject({ top: '-25px', left: '38px', fontSize: '10px' });
expect.soft(actual.recordedHeading[0].fontSize).toBe(reference.heading[0].fontSize);
expect.soft(actual.recordedCanvas[0].width).toBeGreaterThanOrEqual(actual.recordedTimeline[0].width - 42);
for (const geometry of [reference, actual]) {
expect.soft(geometry.runDialog).toMatchObject({ modal: true, focusInside: true, border: '1px' });
expect.soft(geometry.runDialog.width).toBeLessThanOrEqual(geometry.runDialog.viewportWidth);
expect.soft(geometry.runDialog.height).toBeLessThanOrEqual(geometry.runDialog.viewportHeight);
}
for (const property of ['background', 'color', 'borderColor']) expect.soft(actual.runDialog[property]).toBe(reference.runDialog[property]);
for (const [key, properties] of [
['surface', ['padding', 'background', 'border', 'borderColor', 'radius']],
['timeline', ['padding', 'background', 'border', 'borderColor', 'radius']],
['positive', ['border', 'borderColor']],
['positiveLabel', ['color', 'fontSize', 'top', 'left']],
['branch', ['height', 'border', 'borderColor', 'fontSize']],
['label', ['color', 'fontSize']],
['dot', ['background', 'borderColor', 'radius', 'outline', 'outlineColor', 'outlineOffset', 'color', 'fontSize']],
]) {
for (const property of properties) expect.soft(actual[key][0]?.[property], `${key}.${property}`).toBe(reference[key][0][property]);
}
for (const property of ['color', 'background']) expect.soft(actual.positive[0].before[property]).toBe(reference.positive[0].before[property]);
for (const [index, block] of actual.role.entries()) for (const property of ['border', 'borderColor', 'fontSize']) expect.soft(block[property]).toBe(reference.role[index][property]);
for (const [index, prompt] of actual.prompt.entries()) for (const property of ['fontSize', 'lineHeight', 'fontFamily']) expect.soft(prompt[property]).toBe(reference.prompt[index][property]);
assertRecordedHistory(reference, actual, { branchCount: 1, anchorCount: 1, repeatCount: 0 });
}
export function assertRecordedHistory(reference, actual, { branchCount, anchorCount, repeatCount }) {
expect(branchCount).toBeGreaterThan(0);
expect(reference.branch.length).toBeGreaterThanOrEqual(2);
expect(reference.dot).toHaveLength(1);
expect(reference.repeat.length).toBeGreaterThan(0);
expect.soft(actual.recordedBranch, 'chronological branches').toHaveLength(branchCount);
expect.soft(actual.recordedLabel, 'chronological labels').toHaveLength(branchCount);
expect.soft(actual.recordedAnchor, 'chronological anchors').toHaveLength(anchorCount);
expect.soft(actual.recordedRepeat, 'chronological repeats').toHaveLength(repeatCount);
expect.soft(actual.recordedMarker).toHaveLength(anchorCount + repeatCount);
expect.soft(actual.recordedColumn).toHaveLength(anchorCount + repeatCount);
expect.soft(actual.recordedPositive).toHaveLength(1);
for (const property of ['border', 'borderColor']) expect.soft(actual.recordedPositive[0][property], `recorded positive ${property}`).toBe(reference.positive[0][property]);
for (const property of ['color', 'background', 'width', 'height', 'fontSize']) expect.soft(actual.recordedPositive[0].before[property], `recorded positive glyph ${property}`).toBe(reference.positive[0].before[property]);
for (const [index, branch] of actual.recordedBranch.entries()) {
for (const property of ['height', 'border', 'borderColor']) expect.soft(branch[property], `recorded branch ${index} ${property}`).toBe(reference.branch[0][property]);
for (const property of ['width', 'height', 'top', 'left', 'borderLeft', 'borderBottom', 'radius', 'borderLeftColor', 'borderBottomColor']) expect.soft(branch.before[property], `recorded curve ${index} ${property}`).toBe(reference.branch[0].before[property]);
if (index) expect.soft(branch.y - actual.recordedBranch[index - 1].y, 'recorded 57px branch rhythm').toBe(reference.branch[1].y - reference.branch[0].y);
}
for (const label of actual.recordedLabel) for (const property of ['color', 'fontSize']) expect.soft(label[property], `recorded label ${property}`).toBe(reference.label[0][property]);
for (const [index, column] of actual.recordedColumn.entries()) {
expect.soft(column.left).toBe(`${38 + index * 64}px`);
if (index) expect.soft(column.x - actual.recordedColumn[index - 1].x, '64px chronological columns').toBe(64);
}
const checkMarker = (marker, canonical, anchor) => {
const visual = actual.pointerCoarse ? marker.before : marker;
const size = anchor ? 29 : 12;
expect.soft(visual.width, 'recorded marker visual width').toBe(actual.pointerCoarse ? `${size}px` : size);
expect.soft(visual.height, 'recorded marker visual height').toBe(actual.pointerCoarse ? `${size}px` : size);
for (const property of ['background', 'borderColor', 'radius']) expect.soft(visual[property], `recorded marker ${property}`).toBe(canonical[property]);
if (actual.pointerCoarse) {
expect.soft(marker.width, 'coarse marker hit width').toBeGreaterThanOrEqual(44);
expect.soft(marker.height, 'coarse marker hit height').toBeGreaterThanOrEqual(44);
expect.soft(visual.borderLeft).toBe('2px');
} else expect.soft(marker.border).toBe(canonical.border);
if (anchor) {
expect.soft(marker.text).toBe('−');
if (actual.pointerCoarse) expect.soft(visual.content).toBe('"−"');
for (const property of ['color', 'fontSize', 'fontFamily', 'fontWeight']) expect.soft(visual[property], `recorded minus ${property}`).toBe(canonical[property]);
const branch = actual.recordedBranch.find(value => value.contextId === marker.contextId);
expect(branch, 'each context anchor has its real branch').toBeTruthy();
expect.soft(Math.abs(marker.x + marker.width / 2 - branch.x)).toBeLessThanOrEqual(1);
expect.soft(Math.abs(marker.y + marker.height / 2 - branch.y)).toBeLessThanOrEqual(1);
} else expect.soft(marker.text).toBe('');
};
for (const marker of actual.recordedAnchor) checkMarker(marker, reference.dot[0], true);
for (const marker of actual.recordedRepeat) checkMarker(marker, reference.repeat[0], false);
}
-186
View File
@@ -1,186 +0,0 @@
import {readFileSync} from 'node:fs';
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
const pair={id:4,positive_text:'Library positive',negative_text:'Library negative',category:'Portrait',tags:['studio']};
test.beforeEach(async({page,browserName})=>{
await startCoverage(page,browserName);
page.pairErrors=[];page.on('pageerror',error=>page.pairErrors.push(error.message));
await page.route('**/scripts/app.js',route=>route.fulfill({contentType:'text/javascript',body:'export const app=window.fixtureApp;'}));
await page.route('**/scripts/api.js',route=>route.fulfill({contentType:'text/javascript',body:'export const api={fileURL:path=>path,queuePrompt:async(...args)=>args};'}));
await page.route('**/extensions/promptmanager/prompt_manager.js',route=>route.fulfill({contentType:'text/javascript',body:readFileSync(new URL('../../web/comfy/prompt_manager.js',import.meta.url),'utf8')}));
await page.route('**/prompt_manager/pairs?**',route=>route.fulfill({json:{success:true,pairs:[pair],total:1}}));
await page.goto('/prompt_manager/test');
await page.evaluate(async()=>{
document.body.replaceChildren();
const settings=new EventTarget();settings.getSettingValue=()=> 'dark';
window.fixtureApp={registerExtension(extension){this.extension=extension;},ui:{settings},graph:{_nodes:[],setDirtyCanvas(){}},canvas:{},graphToPrompt:async()=>({})};
await import('/extensions/promptmanager/prompt_manager.js');
class Node {
constructor(){this.id=1;this.type='PromptManagerPair';this.widgets=['text','negative_text','category','tags','search_text'].map(name=>({name,type:'text',value:'',options:{},inputEl:document.createElement('textarea')}));this.inputs=['clip','prepend_text','append_text'].map(name=>({name,link:null}));this.properties={};this.size=[1100,700];}
addWidget(type,name,value,callback,options){const widget={type,name,value,callback,options};this.widgets.push(widget);return widget;}
addDOMWidget(name,type,element){const host=document.createElement('div');host.style.cssText='height:700px;width:1100px;max-width:100%';host.className='fixture-dom-widget';host.append(element);document.body.append(host);return {element};}
setSize(size){this.size=size;}setDirtyCanvas(){}
serialize(){return {widgets_values:this.widgets.map(widget=>widget.value),properties:{...this.properties}};}
configure(data){this.widgets.forEach((widget,index)=>widget.value=data.widgets_values[index]);this.properties={...data.properties};}
}
await fixtureApp.extension.beforeRegisterNodeDef(Node,{name:'PromptManagerPair'},fixtureApp);
window.node=new Node();node.onNodeCreated?.();fixtureApp.graph._nodes=[node];
});
});
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.pairErrors).toEqual([]);});
async function selectPair(page){await page.getByRole('button',{name:'Recent',exact:true}).click();await page.locator('.pm-editor-result').click();}
async function draft(page,positive='Draft positive',negative='Draft negative'){await page.getByRole('textbox',{name:'Positive prompt',exact:true}).fill(positive);await page.getByRole('textbox',{name:'Negative prompt',exact:true}).fill(negative);}
test('paired editor previews both texts before explicitly replacing both',async({page})=>{
await expect(page.locator('.pm-pair')).toBeVisible();
await draft(page);
await selectPair(page);
await expect(page.locator('.pm-editor-selection')).toContainText('Library positive');
await expect(page.locator('.pm-editor-selection')).toContainText('Library negative');
await expect(page.getByRole('textbox',{name:'Positive prompt',exact:true})).toHaveValue('Draft positive');
await expect(page.getByRole('textbox',{name:'Negative prompt',exact:true})).toHaveValue('Draft negative');
await page.getByRole('switch',{name:'Load metadata'}).uncheck();
await page.getByRole('button',{name:'Use pair',exact:true}).click();
await expect(page.getByRole('textbox',{name:'Positive prompt',exact:true})).toHaveValue(pair.positive_text);
await expect(page.getByRole('textbox',{name:'Negative prompt',exact:true})).toHaveValue(pair.negative_text);
expect(await page.evaluate(()=>Object.fromEntries(node.widgets.map(w=>[w.name,w.value])))).toMatchObject({text:pair.positive_text,negative_text:pair.negative_text,category:'',tags:''});
await page.evaluate(()=>{const saved=node.serialize();node.configure(saved);});
await expect(page.getByRole('textbox',{name:'Negative prompt',exact:true})).toHaveValue(pair.negative_text);
});
test('paired editor sizes both editors equally and composes positive text only',async({page},testInfo)=>{
await draft(page);
const positive=page.getByRole('textbox',{name:'Positive prompt',exact:true});const negative=page.getByRole('textbox',{name:'Negative prompt',exact:true});
const [p,n]=await Promise.all([positive.boundingBox(),negative.boundingBox()]);expect(Math.abs(p.height-n.height)).toBeLessThan(1);expect(Math.abs(p.width-n.width)).toBeLessThan(1);
await page.getByText('Composition · prepend / append',{exact:true}).click();
await page.getByRole('textbox',{name:'Prepend',exact:true}).fill('trigger');
await page.getByRole('textbox',{name:'Append',exact:true}).fill('detail');
await page.getByText('Composed text preview',{exact:true}).click();
await expect(page.locator('[data-composed-preview]')).toHaveText('trigger Draft positive detail');
await expect(negative).toHaveValue('Draft negative');
await page.screenshot({path:testInfo.outputPath('pair-editor.png')});
});
for(const field of ['text','negative_text']) test(`paired loading is atomic when ${field} is connected`,async({page})=>{
await draft(page);
await selectPair(page);
await page.evaluate(field=>{node.inputs.push({name:field,link:8});node.onConnectionsChange();},field);
await expect(page.getByRole('button',{name:'Use pair',exact:true})).toBeDisabled();
await page.getByRole('button',{name:'Use pair',exact:true}).evaluate(button=>button.dispatchEvent(new Event('click',{bubbles:true})));
expect(await page.evaluate(()=>Object.fromEntries(node.widgets.map(w=>[w.name,w.value])))).toMatchObject({text:'Draft positive',negative_text:'Draft negative'});
await page.evaluate(()=>{node.inputs=node.inputs.filter(input=>input.link!==8);node.onConnectionsChange();});
await expect(page.getByRole('button',{name:'Use pair',exact:true})).toBeEnabled();
});
test('save pair accepts empty negatives, recovers after errors and never clears the draft',async({page})=>{
let attempts=0;const bodies=[];
await page.route('**/prompt_manager/pairs',route=>{bodies.push(route.request().postDataJSON());return ++attempts===1?route.fulfill({status:503,json:{success:false,error:'Temporary save error'}}):route.fulfill({json:{success:true,pair:{...pair,negative_text:''}}});});
await draft(page,'Save this','');
await page.getByRole('button',{name:'Save pair',exact:true}).click();
await expect(page.getByRole('alert')).toContainText('Temporary save error');
await expect(page.getByRole('textbox',{name:'Positive prompt',exact:true})).toHaveValue('Save this');
await page.getByRole('button',{name:'Save pair',exact:true}).click();
await expect(page.getByRole('status').filter({hasText:'Pair saved'})).toBeVisible();
expect(bodies).toEqual([{positive_text:'Save this',negative_text:'',category:'',tags:[]},{positive_text:'Save this',negative_text:'',category:'',tags:[]}]);
});
test('pair filters use repeated tags and removal cleans up',async({page})=>{
const urls=[];await page.route('**/prompt_manager/pairs?**',route=>{urls.push(new URL(route.request().url()));return route.fulfill({json:{success:true,pairs:[pair]}});});
await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('portrait tag:studio');
await page.getByLabel('Filter category',{exact:true}).fill('Portrait');
await page.getByLabel('Filter tags',{exact:true}).fill('film, studio, ');
await page.getByRole('button',{name:'Search',exact:true}).click();await expect(page.locator('.pm-editor-result')).toBeVisible();
expect(urls[0].searchParams.get('category')).toBe('Portrait');expect(urls[0].searchParams.getAll('tag')).toEqual(['studio','film']);
await page.evaluate(()=>node.onRemoved());await expect(page.locator('.ca-completions')).toHaveCount(0);
});
test('validating and saving prevents duplicate submissions while preserving pair metadata and drafts',async({page})=>{
let release;let requests=0;
await page.route('**/prompt_manager/pairs',async route=>{requests++;await new Promise(resolve=>release=resolve);await route.fulfill({json:{success:true,pair:{...pair,category:'Original category',tags:['original-tag']}}});});
await page.getByRole('button',{name:'Save pair',exact:true}).click();
await expect(page.getByRole('alert')).toContainText('positive');expect(requests).toBe(0);
await draft(page);
await page.getByRole('button',{name:'Save pair',exact:true}).click();
await expect(page.getByRole('button',{name:'Save pair',exact:true})).toBeDisabled();
await page.getByRole('button',{name:'Save pair',exact:true}).evaluate(button=>button.dispatchEvent(new Event('click',{bubbles:true})));
expect(requests).toBe(1);release();
await expect(page.getByRole('status').filter({hasText:'Pair saved'})).toBeVisible();
expect(await page.evaluate(()=>Object.fromEntries(node.widgets.map(w=>[w.name,w.value])))).toMatchObject({text:'Draft positive',negative_text:'Draft negative',category:'',tags:''});
});
test('a superseded library response never replaces the latest pair selection',async({page})=>{
let release;let started=false;
await page.route('**/prompt_manager/pairs?**',async route=>{
if(new URL(route.request().url()).searchParams.get('text')==='old'){started=true;await new Promise(resolve=>release=resolve);await route.fulfill({json:{success:true,pairs:[{...pair,positive_text:'Stale positive'}]}}).catch(()=>{});}
else await route.fulfill({json:{success:true,pairs:[pair]}});
});
const search=page.getByRole('searchbox',{name:'Search saved prompts'});
await search.fill('old');await page.getByRole('button',{name:'Search',exact:true}).click();await expect.poll(()=>started).toBe(true);
await search.fill('new');await page.getByRole('button',{name:'Search',exact:true}).click();
await expect(page.locator('.pm-editor-result')).toContainText('Library positive');release();
await expect(page.locator('.pm-editor-result')).not.toContainText('Stale positive');
});
test('queue hook synchronizes both editors, preserves links and forwards branch execution arguments',async({page})=>{
await draft(page);
const args=await page.evaluate(async()=>{
await fixtureApp.extension.setup();
const {api}=await import('/scripts/api.js');
const output={1:{inputs:{text:'stale',negative_text:'stale'}}};
const first=await api.queuePrompt(7,{output,workflow:{name:'workflow'}},['branch'],{preview:'latent'});
node.inputs.push({name:'negative_text',link:17});
const linked={1:{inputs:{text:'old',negative_text:['17',0]}}};
const second=await api.queuePrompt(8,{output:linked,workflow:{}},['another']);
return {first,second};
});
expect(args.first).toEqual([7,{output:{1:{inputs:{text:'Draft positive',negative_text:'Draft negative'}}},workflow:{name:'workflow'}},['branch'],{preview:'latent'}]);
expect(args.second[1].output[1].inputs).toEqual({text:'Draft positive',negative_text:['17',0]});
});
test('paired editors stack at narrow widths while keeping equal editing areas',async({page})=>{
await page.locator('.fixture-dom-widget').evaluate(host=>host.style.width='560px');
const [p,n]=await Promise.all(['Positive prompt','Negative prompt'].map(name=>page.getByRole('textbox',{name,exact:true}).boundingBox()));expect(n.y).toBeGreaterThanOrEqual(p.y+p.height);expect(Math.abs(p.width-n.width)).toBeLessThan(1);expect(Math.abs(p.height-n.height)).toBeLessThan(1);
});
test('removal aborts an in-flight pair save and restores host-owned textarea references',async({page})=>{
await draft(page);
const state=await page.evaluate(async()=>{
const previous=window.fetch;let signal;
window.fetch=(url,options)=>{if(String(url).endsWith('/pairs')){signal=options.signal;return new Promise((resolve,reject)=>signal.addEventListener('abort',()=>reject(new DOMException('Aborted','AbortError')),{once:true}));}return previous(url,options);};
const visible=node.widgets.find(w=>w.name==='negative_text').inputEl;
[...document.querySelectorAll('button')].find(button=>button.textContent==='Save pair').click();
node.onRemoved();
return {aborted:signal.aborted,restored:node.widgets.find(w=>w.name==='negative_text').inputEl!==visible};
});
expect(state).toEqual({aborted:true,restored:true});
});
test('actual pair saves coexist with resetting the disposable browser fixture',async({page,request})=>{
await draft(page,'Actual browser pair','Actual negative');await page.getByRole('button',{name:'Save pair',exact:true}).click();await expect(page.getByRole('status').filter({hasText:'Pair saved'})).toBeVisible();
const response=await request.get('/prompt_manager/pairs?text=Actual%20browser%20pair');expect(response.ok()).toBe(true);expect((await response.json()).pairs).toEqual(expect.arrayContaining([expect.objectContaining({positive_text:'Actual browser pair',negative_text:'Actual negative'})]));
const reset=await request.post('/__test__/reset');expect(reset.ok()).toBe(true);
const after=await request.get('/prompt_manager/pairs?text=Actual%20browser%20pair');expect((await after.json()).pairs).toHaveLength(0);
});
for(const field of ['category','tags'])test(`manual pair save waits for execution when ${field} comes from an upstream link`,async({page})=>{
let requests=0;await page.route('**/prompt_manager/pairs',route=>{requests++;return route.fulfill({json:{success:true,pair}});});await draft(page);
await page.evaluate(field=>{node.inputs.push({name:field,link:19});node.onConnectionsChange();},field);
const save=page.getByRole('button',{name:'Save pair',exact:true});await expect(save).toBeDisabled();await expect(page.getByRole('status').filter({hasText:'Run the node to save connected values.'})).toBeVisible();
await save.evaluate(button=>button.dispatchEvent(new Event('click',{bubbles:true})));expect(requests).toBe(0);
await page.evaluate(()=>{node.inputs=node.inputs.filter(input=>input.link!==19);node.onConnectionsChange();});await expect(save).toBeEnabled();await save.click();await expect.poll(()=>requests).toBe(1);
});
for(const field of ['positive','negative','category','tags'])test(`save completion identifies an earlier draft after editing ${field}`,async({page})=>{
let release;await page.route('**/prompt_manager/pairs',async route=>{await new Promise(resolve=>release=resolve);await route.fulfill({json:{success:true,pair}});});await draft(page);
await page.getByText('Metadata · category / tags',{exact:true}).click();await page.getByRole('button',{name:'Save pair',exact:true}).click();await expect.poll(()=>Boolean(release)).toBe(true);
const input=field==='positive'?page.getByRole('textbox',{name:'Positive prompt',exact:true}):field==='negative'?page.getByRole('textbox',{name:'Negative prompt',exact:true}):page.getByRole('combobox',{name:field==='category'?'Category':'Tags',exact:true});
await input.fill(`Changed ${field}`);release();await expect(page.getByRole('status').filter({hasText:'Earlier draft saved. Current changes are not saved yet.'})).toBeVisible();await expect(input).toHaveValue(`Changed ${field}`);
});
test('pair library rows distinguish negative variants and explicitly show empty negatives as text',async({page})=>{
const longNegative='Variant one '+ 'x'.repeat(200);
await page.route('**/prompt_manager/pairs?**',route=>route.fulfill({json:{success:true,pairs:[{...pair,negative_text:longNegative},{...pair,id:5,negative_text:'Variant two'},{...pair,id:6,negative_text:''},{...pair,id:7,negative_text:'<img src=x onerror=alert(1)>'}]}}));
await page.getByRole('button',{name:'Recent',exact:true}).click();const rows=page.locator('.pm-editor-result');await expect(rows).toHaveCount(4);await expect(rows.nth(0)).toContainText('Negative: Variant one');await expect(rows.nth(1)).toContainText('Negative: Variant two');await expect(rows.nth(2)).toContainText('Negative: (empty)');await expect(rows.nth(3)).toContainText('Negative: <img src=x onerror=alert(1)>');await expect(rows.locator('img')).toHaveCount(0);await expect(rows.nth(0)).not.toContainText('x'.repeat(180));await expect(rows.nth(0).locator('span').nth(1)).toHaveAttribute('title',longNegative);
});
-104
View File
@@ -1,104 +0,0 @@
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
const moduleUrl = '/prompt_manager/js/shared/toolkit/prompt-bridge.js';
test.beforeEach(async ({ page, browserName }) => {
await startCoverage(page, browserName);
page.bridgeErrors = [];
page.on('pageerror', error => page.bridgeErrors.push(error.message));
await page.goto('/prompt_manager/workspace#/overview');
});
test.afterEach(async ({ page, browserName }) => {
await page.evaluate(() => { window.client?.dispose(); window.host?.dispose(); });
await saveCoverage(page, browserName);
expect(page.bridgeErrors).toEqual([]);
});
async function host(page, namespace = '/', suffix = '') {
await page.evaluate(async ({ moduleUrl, namespace, suffix }) => {
const { createBridgeHost } = await import(moduleUrl + suffix);
window.applied = [];
window.host = createBridgeHost({ origin: location.origin, namespace, label: 'Independent host', adapter: {
listTargets: () => [{ id: 'node-1', label: 'Pair', roles: ['positive', 'negative'] }],
preview: (id, roles) => ({ preview: { target: { id }, fields: roles }, snapshot: structuredClone(roles) }),
apply: roles => { window.applied.push(roles); return { targetId: 'node-1', values: roles }; },
} });
}, { moduleUrl, namespace, suffix });
}
async function client(page, namespace = '/') {
await page.evaluate(async ({ moduleUrl, namespace }) => {
const { createBridgeClient } = await import(moduleUrl);
window.client = createBridgeClient({ origin: location.origin, namespace, timeoutMs: 1000 });
}, { moduleUrl, namespace });
}
test('separately opened tabs preview and acknowledge exact roles through the shipped transport', async ({ page, context }) => {
const destination = await context.newPage();
try {
await destination.goto('/prompt_manager/workspace#/overview');
await host(destination);
await client(page);
const result = await page.evaluate(async () => {
const [host] = await window.client.discover({ waitMs: 400 });
const preview = await window.client.preview(host.hostId, 'node-1', { positive: 'A mountain', negative: '' });
window.previewTicket = preview.ticket;
return { host, preview };
});
expect(result.host.label).toBe('Independent host');
expect(result.preview.fields).toEqual({ positive: 'A mountain', negative: '' });
expect(await destination.evaluate(() => window.applied)).toEqual([]);
const ack = await page.evaluate(hostId => window.client.apply(hostId, window.previewTicket), result.host.hostId);
expect(ack).toEqual({ targetId: 'node-1', values: { positive: 'A mountain', negative: '' } });
expect(await destination.evaluate(() => window.applied)).toEqual([ack.values]);
expect(await page.evaluate(async hostId => {
try { await window.client.apply(hostId, window.previewTicket); return false; } catch { return true; }
}, result.host.hostId)).toBe(true);
} finally {
await destination.evaluate(() => window.host?.dispose()).catch(() => {});
await destination.close();
}
});
test('independent bundled hosts coexist and deployment namespaces isolate discovery', async ({ page, context }) => {
const second = await context.newPage();
try {
await second.goto('/prompt_manager/workspace#/overview');
await host(page, '/', '?bundle=first');
await host(second, '/', '?bundle=second');
await client(page);
const hosts = await page.evaluate(() => window.client.discover({ waitMs: 400 }));
expect(new Set(hosts.map(host => host.hostId)).size).toBe(2);
await page.evaluate(() => window.client.dispose());
await client(page, '/other-deployment/');
expect(await page.evaluate(() => window.client.discover({ waitMs: 400 }))).toEqual([]);
} finally {
await second.evaluate(() => window.host?.dispose()).catch(() => {});
await second.close();
}
});
test('disconnected hosts, cancellation and disposal reject pending requests cleanly', async ({ page }) => {
await host(page);
await client(page);
const errors = await page.evaluate(async () => {
const [host] = await window.client.discover({ waitMs: 400 });
window.host.dispose();
const errors = [];
try { await window.client.preview(host.hostId, 'node-1', { positive: 'No destination' }); } catch (error) { errors.push(error.message); }
const controller = new AbortController();
const aborted = window.client.preview(host.hostId, 'node-1', { negative: 'Cancelled' }, { signal: controller.signal });
controller.abort();
try { await aborted; } catch (error) { errors.push(error.message); }
const pending = window.client.preview(host.hostId, 'node-1', { positive: 'Disposed' });
window.client.dispose();
try { await pending; } catch (error) { errors.push(error.message); }
return errors;
});
expect(errors).toHaveLength(3);
expect(errors[0]).toMatch(/timed out/);
expect(errors[1]).toMatch(/abort/);
expect(errors[2]).toMatch(/disposed/);
});
-252
View File
@@ -1,252 +0,0 @@
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
const workspace='/prompt_manager/workspace#/images';
const details=page=>page.getByRole('region',{name:'Image details',exact:true});
const role=(scope,name)=>scope.getByRole('region',{name:`${name} prompt`,exact:true});
const select=async(page,key)=>page.locator(`article[data-image-key="${key}"]`).getByRole('button',{name:/^Open /}).click();
const dialog=page=>page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
test.beforeEach(async({page,request,browserName})=>{
await startCoverage(page,browserName);await expect(await request.post('/__test__/reset',{data:{prompt_inspector:true}})).toBeOK();
page.promptErrors=[];page.on('pageerror',error=>page.promptErrors.push(error.message));
});
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.promptErrors).toEqual([]);});
async function fakeHost(context,page,{rejectApply=false}={}) {
const host=await context.newPage();await host.goto('/prompt_manager/workspace#/overview');
await host.evaluate(async rejectApply=>{
const {createBridgeHost}=await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');
window.hostValues={positive:'Existing positive',negative:'Existing negative'};window.applied=[];
const target={id:'pair-1',label:'My prompt pair',type:'PromptManagerPair',roles:['positive','negative']};
window.fixtureHost=createBridgeHost({origin:location.origin,namespace:'/',label:'Test ComfyUI',adapter:{
listTargets:()=>[target],
preview:(_id,roles)=>({preview:{target,fields:Object.entries(roles).map(([role,after])=>({role,before:window.hostValues[role],after})),composition:{prepend:'trigger',append:'detail',prependConnected:false,appendConnected:false,before:`trigger ${window.hostValues.positive} detail`,after:`trigger ${roles.positive??window.hostValues.positive} detail`}},snapshot:roles}),
apply:roles=>{if(rejectApply)throw new Error('Workflow changed. Preview again.');Object.assign(window.hostValues,roles);window.applied.push(roles);return{targetId:target.id,values:roles};},
}});
},rejectApply);
return host;
}
async function chooseDestination(page) {
const sender=dialog(page);await expect(sender).toBeVisible();
const host=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});
await expect(host.locator('option')).toContainText(['Test ComfyUI']);
await host.selectOption({label:'Test ComfyUI'});
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'My prompt pair'});
return sender;
}
test('collection viewer rejects stale source and sends only refreshed API evidence',async({page,request,context})=>{
const host=await fakeHost(context,page);
await page.addInitScript(()=>{window.copied=[];Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>window.copied.push(text)}});});
try {
const loaded=await(await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();const stale=loaded.images.find(image=>image.root_index===0&&image.path==='shared/same.png');
await page.goto(workspace);await select(page,'1:shared/same.png');await expect(details(page)).toContainText('Dog landscape');
await expect(await request.post('/prompt_manager/settings',{data:{gallery_root_paths:['input','output']}})).toBeOK();
const response=page.waitForResponse(result=>result.url().includes('/images/inspect/shared/same.png?root=0'));
await select(page,'0:shared/same.png');const changed=await response;expect(changed.status()).toBe(409);expect((await changed.json()).code).toBe('source-changed');
await page.locator('article[data-image-key="0:shared/same.png"] .image-card-footer button').click();const preview=page.getByRole('dialog',{name:'Image preview',exact:true});
const message='The source changed. Refresh the image collection before inspecting this file.';
await expect(preview).toContainText(message);await expect(preview.locator('[data-copy-role],[data-send-role],.file-inspector a[download]')).toHaveCount(0);
const original=preview.getByRole('group',{name:'Media controls',exact:true}).getByLabel('Download original',{exact:true});
await expect(original).toHaveAttribute('aria-disabled','true');await expect(original).not.toHaveAttribute('href',/./);
let downloads=0;page.on('download',()=>downloads++);await original.focus();await page.keyboard.press('Enter');expect(downloads).toBe(0);
await expect(preview.getByRole('button',{name:'Retry media',exact:true})).toBeHidden();
await expect(preview.getByRole('button',{name:'Retry prompt details',exact:true})).toHaveCount(0);await expect(preview).not.toContainText('Dog landscape');await expect(preview).not.toContainText('Cat portrait');
expect(await page.evaluate(()=>window.copied)).toEqual([]);expect(await host.evaluate(()=>window.applied)).toEqual([]);
const refreshed=page.waitForResponse(result=>result.url().includes('/images/output?'));
await preview.getByRole('button',{name:'Refresh image collection',exact:true}).click();await refreshed;await expect(preview).toHaveCount(0);
const collection=await(await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();const fresh=collection.images.find(image=>image.root_index===0&&image.path===stale.path);expect(fresh.root_scope).not.toBe(stale.root_scope);
const evidence=await(await request.get(`/prompt_manager/images/inspect/shared/same.png?root=0&root_scope=${fresh.root_scope}`)).json();
await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:'Open full preview',exact:true}).click();await expect(preview).toContainText(evidence.prompts.positive.text);
await preview.getByRole('button',{name:'Send prompt pair',exact:true}).click();const sender=await chooseDestination(page);await sender.getByRole('button',{name:'Preview changes',exact:true}).click();await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
expect(await host.evaluate(()=>window.applied)).toEqual([{positive:evidence.prompts.positive.text,negative:evidence.prompts.negative.text}]);expect(await page.evaluate(()=>window.copied)).toEqual([]);
} finally {await expect(await request.post('/prompt_manager/settings',{data:{gallery_root_paths:['output','input']}})).toBeOK();await host.evaluate(()=>window.fixtureHost.dispose());await host.close();}
});
test('physical image inspector resolves positive and negative metadata separately for duplicate filenames',async({page})=>{
await page.goto(workspace);await select(page,'0:shared/same.png');
await expect(role(details(page),'Positive')).toContainText('Cat portrait');await expect(role(details(page),'Negative')).toContainText('blurry, watermark');
await expect(details(page).getByRole('button',{name:'Send prompt pair',exact:true})).toBeEnabled();
await select(page,'1:shared/same.png');await expect(role(details(page),'Positive')).toContainText('Dog landscape');
await expect(role(details(page),'Negative')).toContainText('low quality');await expect(details(page)).not.toContainText('Cat portrait');
});
test('explicit empty negative is sendable while unrecorded text is never invented',async({page})=>{
await page.goto(workspace);await select(page,'0:empty-negative.png');
await expect(role(details(page),'Negative')).toContainText('Empty negative prompt');
await expect(details(page).getByRole('button',{name:'Send negative prompt',exact:true})).toBeEnabled();
await select(page,'0:uncertain.png');await expect(details(page)).toContainText('Not recorded in this file.');
await expect(details(page).getByRole('button',{name:/^Send (positive|negative) prompt$/})).toHaveCount(0);
});
test('inspection failures retry and a delayed previous selection cannot replace the current prompt',async({page})=>{
let fail=true;let release;const held=new Promise(resolve=>{release=resolve;});
await page.route('**/images/inspect/**',async route=>{
const url=new URL(route.request().url());
if(url.searchParams.get('root')==='0'&&url.pathname.endsWith('same.png')){
if(fail){return route.fulfill({status:503,json:{success:false,error:'Temporarily unavailable'}});}
await held;
}
await route.continue().catch(()=>{});
});
await page.goto(workspace);await select(page,'0:shared/same.png');
await expect(details(page)).toContainText('Could not read prompts from this file.');
fail=false;await details(page).getByRole('button',{name:'Retry prompt details',exact:true}).click();
await select(page,'1:shared/same.png');await expect(role(details(page),'Positive')).toContainText('Dog landscape');
release();await expect(role(details(page),'Positive')).toContainText('Dog landscape');
});
test('sender requires explicit destination, previews full changes and acknowledges only the applied pair',async({page,context},testInfo)=>{
const host=await fakeHost(context,page);
try {
await page.goto(workspace);await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:'Send prompt pair',exact:true}).click();
const sender=dialog(page);await expect(sender.getByRole('button',{name:'Apply to ComfyUI',exact:true})).toBeDisabled();
const chosen=await chooseDestination(page);await chosen.getByRole('button',{name:'Preview changes',exact:true}).click();
for(const text of ['Existing positive','Existing negative','Cat portrait','blurry, watermark','trigger Cat portrait detail'])await expect(chosen).toContainText(text);
expect(await host.evaluate(()=>window.applied)).toEqual([]);
await page.screenshot({path:testInfo.outputPath('sender-preview.png')});
await chosen.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(chosen).toContainText('Sent to ComfyUI.');
expect(await host.evaluate(()=>window.applied)).toEqual([{positive:'Cat portrait',negative:'blurry, watermark'}]);
await chosen.getByRole('button',{name:'Close',exact:true}).click();
await page.evaluate(()=>scrollTo(0,0));
await page.screenshot({path:testInfo.outputPath('inspector-dark.png'),fullPage:true});
await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption('light');
await page.screenshot({path:testInfo.outputPath('inspector-light.png'),fullPage:true});
await details(page).getByRole('button',{name:'Open full preview',exact:true}).click();
await expect(page.getByRole('dialog',{name:'Image preview',exact:true})).toBeVisible();
await page.screenshot({path:testInfo.outputPath('viewer-prompts.png')});
await page.getByRole('button',{name:'Close preview',exact:true}).click();
await page.setViewportSize({width:390,height:844});await details(page).scrollIntoViewIfNeeded();
await page.screenshot({path:testInfo.outputPath('inspector-mobile.png')});
} finally {await host.evaluate(()=>window.fixtureHost.dispose());await host.close();}
});
test('host apply rejection retains the current text and requires a new preview',async({page,context})=>{
const host=await fakeHost(context,page,{rejectApply:true});
try {
await page.goto(workspace);await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:'Send positive prompt',exact:true}).click();
const sender=await chooseDestination(page);await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText(/Workflow changed/);
await expect(sender.getByRole('button',{name:'Apply to ComfyUI',exact:true})).toBeDisabled();
expect(await host.evaluate(()=>window.hostValues.positive)).toBe('Existing positive');
} finally {await host.evaluate(()=>window.fixtureHost.dispose());await host.close();}
});
test('nested viewer sender Escape restores focus without closing the image and navigation disposes both overlays',async({page})=>{
await page.goto(workspace);await select(page,'0:shared/same.png');
await details(page).getByRole('button',{name:'Open full preview',exact:true}).click();
const viewer=page.locator('dialog[open]').filter({has:page.locator('.viewer-canvas')});await expect(viewer).toBeVisible();
const send=viewer.getByRole('button',{name:'Send positive prompt',exact:true});await send.click();await expect(dialog(page)).toBeVisible();
await page.keyboard.press('Escape');await expect(dialog(page)).toBeHidden();await expect(viewer).toBeVisible();await expect(send).toBeFocused();
await send.click();await page.evaluate(()=>{location.hash='#/overview';});await expect(page.locator('dialog[open]')).toHaveCount(0);
});
test('empty negative send clears only the negative destination and preserves its positive',async({page,context})=>{
const host=await fakeHost(context,page);
try {
await page.goto(workspace);await select(page,'0:empty-negative.png');
await details(page).getByRole('button',{name:'Send negative prompt',exact:true}).click();
const sender=await chooseDestination(page);await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
expect(await host.evaluate(()=>window.hostValues)).toEqual({positive:'Existing positive',negative:''});
} finally {await host.evaluate(()=>window.fixtureHost.dispose());await host.close();}
});
test('no open ComfyUI window is actionable and rediscovery finds a newly opened host',async({page,context})=>{
await page.goto(workspace);await select(page,'0:shared/same.png');
await details(page).getByRole('button',{name:'Send positive prompt',exact:true}).click();
const sender=dialog(page);await expect(sender).toContainText('No compatible ComfyUI windows found.');
const host=await fakeHost(context,page);
try {
await sender.getByRole('button',{name:'Find ComfyUI windows',exact:true}).click();
await chooseDestination(page);await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
expect(await host.evaluate(()=>window.hostValues)).toEqual({positive:'Cat portrait',negative:'Existing negative'});
} finally {await host.evaluate(()=>window.fixtureHost.dispose());await host.close();}
});
test('mobile inspector exposes full long prompts, truthful uncertainty and copy failure feedback',async({page})=>{
const text=`A long detailed prompt ${'with readable full composition, '.repeat(150)}END OF FULL PROMPT`;
await page.addInitScript(()=>Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async()=>{throw new Error('Permission denied');}}}));
await page.route('**/images/inspect/**',route=>route.fulfill({json:{success:true,root_scope:new URL(route.request().url()).searchParams.get('root_scope'),image:{root_index:0,path:'shared/same.png',filename:'same.png'},prompts:{positive:{status:'resolved',text,source:'execution'},negative:{status:'ambiguous',text:null,source:null}}}}));
await page.setViewportSize({width:390,height:844});await page.goto(workspace);await select(page,'0:shared/same.png');
const positive=role(details(page),'Positive');await expect(positive.locator('pre')).toHaveText(text);
await expect(role(details(page),'Negative')).toContainText('Multiple possible prompts. Source could not be determined.');
await expect(details(page).getByRole('button',{name:'Send negative prompt',exact:true})).toHaveCount(0);
await positive.getByRole('button',{name:'Copy positive prompt',exact:true}).click();await expect(positive).toContainText('Copy unavailable. Select the prompt text to copy it.');
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);
await positive.getByRole('button',{name:'Send positive prompt',exact:true}).click();
await expect(dialog(page)).toBeVisible();const bounds=await dialog(page).boundingBox();
expect(bounds.x).toBeGreaterThanOrEqual(0);expect(bounds.x+bounds.width).toBeLessThanOrEqual(391);
});
test('filtering away a selected image aborts its pending metadata request',async({page})=>{
await page.addInitScript(()=>{
const fetch=window.fetch;window.inspectionAborts=0;
window.fetch=function(input,options){
if(String(input).includes('/images/inspect/')&&options?.signal)options.signal.addEventListener('abort',()=>window.inspectionAborts++,{once:true});
return fetch.call(this,input,options);
};
});
let started;const requested=new Promise(resolve=>{started=resolve;});let release;
const hold=new Promise(resolve=>{release=resolve;});
await page.route('**/images/inspect/**',async route=>{started();await hold;await route.continue().catch(()=>{});});
try {
await page.goto(workspace);await select(page,'0:shared/same.png');await requested;
const baseline=await page.evaluate(()=>window.inspectionAborts);
await page.getByRole('searchbox',{name:'Search filenames',exact:true}).fill('no-such-file');
await expect(page.getByText('No matching images or media. Try changing the filters.',{exact:true})).toBeVisible();
await expect.poll(()=>page.evaluate(()=>window.inspectionAborts),{timeout:1500}).toBeGreaterThan(baseline);
} finally {release();}
});
test('responsive gallery defers observed size writes and still fits the new viewport',async({page})=>{
await page.addInitScript(()=>{
const Native=window.ResizeObserver;window.inlineResizeWrites=0;
window.ResizeObserver=class extends Native {
constructor(callback){super((entries,observer)=>{
const sizes=entries.map(entry=>entry.target.style.height);
callback(entries,observer);
entries.forEach((entry,index)=>{
if(entry.target.classList.contains('image-grid')&&entry.target.style.height!==sizes[index])window.inlineResizeWrites++;
});
});}
};
});
await page.setViewportSize({width:1280,height:900});await page.goto(workspace);
await expect(page.locator('.image-card')).toHaveCount(4);
await page.setViewportSize({width:390,height:844});
await expect.poll(()=>page.locator('.image-card').first().evaluate(node=>node.getBoundingClientRect().width)).toBeLessThan(390);
await expect.poll(()=>page.locator('.image-grid').evaluate(node=>Number.parseFloat(node.style.height))).toBeCloseTo(383.75,0);
expect(await page.evaluate(()=>window.inlineResizeWrites)).toBe(0);
});
test('unverified saved associations explain uncertainty and cannot be sent',async({page})=>{
await page.route('**/images/inspect/uncertain.png?root=0*',route=>route.fulfill({json:{success:true,root_scope:new URL(route.request().url()).searchParams.get('root_scope'),image:{root_index:0,path:'uncertain.png',filename:'uncertain.png'},prompts:{
positive:{status:'unresolved',text:null,source:'saved-association',message:'A saved library association exists, but it cannot be verified against this file.'},
negative:{status:'absent',text:null,source:null},
}}}));
await page.goto(workspace);await select(page,'0:uncertain.png');
await expect(role(details(page),'Positive')).toContainText('cannot be verified against this file');
await expect(details(page).getByRole('button',{name:/^Send /})).toHaveCount(0);
});
test('prompt role headings contain compact real actions and honest source evidence',async({page})=>{
await page.goto(workspace);await select(page,'0:shared/same.png');
for (const name of ['Positive','Negative']) {
const block=role(details(page),name),heading=block.locator('.prompt-block-heading');
await expect(heading.getByRole('heading',{name:`${name} prompt`,exact:true})).toBeVisible();
await expect(heading.getByRole('button',{name:`Copy ${name.toLowerCase()} prompt`,exact:true})).toBeVisible();
await expect(heading.getByRole('button',{name:`Send ${name.toLowerCase()} prompt`,exact:true})).toBeVisible();
await expect(heading.locator('[data-copy-role] svg')).toHaveCount(1);
expect(await heading.evaluate(node=>node.nextElementSibling.classList.contains('prompt-source'))).toBe(true);
}
await expect(role(details(page),'Positive').locator('.prompt-source')).toHaveText('Inferred from the saved workflow');
await select(page,'0:empty-negative.png');
await expect(role(details(page),'Negative')).toContainText('Empty negative prompt');
await expect(role(details(page),'Negative').locator('.prompt-block-heading [data-copy-role]')).toHaveCount(1);
await select(page,'0:uncertain.png');await expect(details(page).locator('[data-copy-role]')).toHaveCount(0);
await expect(details(page)).toContainText('Not recorded in this file.');
});
+5 -257
View File
@@ -14,28 +14,6 @@ from aiohttp import web
from PIL import Image
from PIL.PngImagePlugin import PngInfo
from tests.browser.workspace_visual_fixture import seed_workspace_visual
from tests.browser.workspace_inspector_fixture import seed_workspace_inspector
from tests.browser.workspace_history_fixture import (
capture_workspace_history_zero_outputs,
register_workspace_history_commands,
seed_workspace_history,
)
CANONICAL_ROOT = Path(__file__).resolve().parents[2]
CANONICAL_ASSETS = {
"/__test__/canonical/workspace": (
"docs/concepts/workspace/index.html",
"text/html",
),
"/workspace/app.js": ("docs/concepts/workspace/app.js", "text/javascript"),
"/workspace/style.css": ("docs/concepts/workspace/style.css", "text/css"),
"/toolkit/core.js": ("web/js/shared/toolkit/core.js", "text/javascript"),
"/toolkit/controls.css": ("web/js/shared/toolkit/controls.css", "text/css"),
"/vendor/viewer.js": ("web/lib/viewerjs/viewer.min.js", "text/javascript"),
"/vendor/viewer.css": ("web/lib/viewerjs/viewer.min.css", "text/css"),
}
class TestModel:
default_prompt = "Describe the image"
@@ -99,50 +77,16 @@ def create_app(root: Path) -> web.Application:
autotag.get_autotag_service = lambda: model
api = PromptManagerAPI()
api.add_routes(routes)
history_commands = register_workspace_history_commands(routes, api, root)
async def canonical_asset(request: web.Request) -> web.Response:
filename, content_type = CANONICAL_ASSETS[request.path]
return web.Response(
body=(CANONICAL_ROOT / filename).read_bytes(),
content_type=content_type,
headers={"Cache-Control": "no-store", "X-Content-Type-Options": "nosniff"},
)
for path in CANONICAL_ASSETS:
routes.get(path)(canonical_asset)
def seed(
extra_images: int = 0,
include_video: bool = False,
prompt_inspector: bool = False,
native_library: bool = False,
extra_prompts: int = 0,
extra_variants: int = 0,
) -> None:
def seed(extra_images: int = 0) -> None:
with api.db.model.get_connection() as conn:
for table in (
"image_prompt_pairs",
"prompt_pair_tags",
"prompt_pairs",
"generated_images",
"prompt_tags",
"tags",
"prompts",
):
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",
"Cat portrait" if native_library else "Dog landscape",
"dogs",
),
]
[("output", "Cat portrait", "cats"), ("input", "Dog landscape", "dogs")]
):
image_path = root / directory / "shared" / "same.png"
image_path.parent.mkdir(exist_ok=True)
@@ -155,25 +99,6 @@ def create_app(root: Path) -> web.Application:
)
with Image.new("RGB", (64, 64), (index * 255, 64, 128)) as image:
image.save(image_path, pnginfo=info)
if prompt_inspector or native_library:
graph = {
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": text}},
"2": {
"class_type": "CLIPTextEncode",
"inputs": {
"text": "blurry, watermark" if index == 0 else "low quality"
},
},
"3": {
"class_type": "KSampler",
"inputs": {"positive": ["1", 0], "negative": ["2", 0]},
},
"4": {"class_type": "SaveImage", "inputs": {"images": ["3", 0]}},
}
info = PngInfo()
info.add_text("prompt", json.dumps(graph))
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(
@@ -181,202 +106,25 @@ def create_app(root: Path) -> web.Application:
)
api.db.link_image_to_prompt(pid, str(image_path))
if index == 0:
video_prompt_id = pid
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))
if native_library:
positive = api.db.get_prompt_by_hash(generate_prompt_hash("Cat portrait"))
positive_id = positive["id"]
api.db.update_prompt_metadata(
positive_id, category="Animals", tags=["cats", "studio"]
)
variants = []
for index, negative in enumerate(
["blurry, watermark", "low quality", "low contrast", ""]
):
pair_id = api.db.save_prompt_pair(
"Cat portrait",
negative,
title=f"Portrait variant {index + 1}",
category="Portraits" if index == 0 else "Experiments",
tags=["studio"] if index == 0 else ["outdoors"],
)
variants.append(pair_id)
with api.db.model.get_connection() as conn:
images = conn.execute(
"SELECT id FROM generated_images WHERE prompt_id=? ORDER BY id",
(positive_id,),
).fetchall()
for index, image in enumerate(images[:2]):
api.db.link_image_to_pair(
variants[index], image["id"], {"source": "fixture", "anchor": index}
)
api.db.link_image_to_pair(
variants[0], images[0]["id"], {"source": "fixture second evidence"}
)
api.db.record_prompt_pair_use(variants[0], times=3)
for number in range(extra_variants):
api.db.save_prompt_pair("Cat portrait", f"Negative extra {number:04}")
legacy = root / "output" / "legacy-unassigned.png"
shutil.copyfile(root / "output" / "shared" / "same.png", legacy)
api.db.link_image_to_prompt(positive_id, str(legacy))
api.db.save_prompt("Dog landscape", category="Animals", tags=["dogs"])
api.db.save_prompt_pair("Dual role portrait", "Cat portrait")
for number in range(extra_prompts):
api.db.save_prompt(
f"Library extra {number:04}", category="Bulk", tags=["bulk"]
)
if prompt_inspector:
for name, parameters in (
(
"empty-negative.png",
"Explicit empty negative\nNegative prompt: \n"
"Steps: 20, Sampler: Euler",
),
("uncertain.png", None),
):
info = PngInfo()
if parameters:
info.add_text("parameters", parameters)
with Image.new("RGB", (64, 64), (100, 80, 120)) as image:
image.save(root / "output" / name, pnginfo=info)
os.utime(root / "output" / name, (time.time() - 100, time.time() - 100))
api._generate_gallery_thumbnails_sync(api._get_all_output_dirs(), (150, 150))
if include_video:
# Seed after thumbnail generation to exercise a real unthumbnailed video.
video = root / "output" / "clip.webm"
shutil.copyfile(
Path(__file__).resolve().parents[1] / "fixtures" / "tiny.webm", video
)
api.db.link_image_to_prompt(video_prompt_id, str(video))
api.invalidate_gallery_cache()
history_fixture = None
@routes.post("/__test__/history/capture-zero-outputs")
async def capture_history_outputs(request: web.Request) -> web.Response:
data = await request.json() if request.can_read_body else None
if (
not isinstance(data, dict)
or set(data) != {"run_id"}
or history_fixture is None
or data["run_id"] not in history_fixture["runs"].values()
):
raise web.HTTPBadRequest()
captured = await api._run_in_executor(
capture_workspace_history_zero_outputs, api, data["run_id"]
)
return web.json_response({"success": captured})
@routes.post("/__test__/reset")
async def reset(request: web.Request) -> web.Response:
nonlocal history_fixture
data = await request.json() if request.can_read_body else {}
if not isinstance(data, dict):
raise web.HTTPBadRequest()
chronological_history = data.get("chronological_history", False)
history_runs = data.get("history_runs", 0)
if (
not isinstance(chronological_history, bool)
or isinstance(history_runs, bool)
or not isinstance(history_runs, int)
or not 0 <= history_runs <= 1200
or (not chronological_history and "history_runs" in data)
or (
chronological_history
and set(data) - {"chronological_history", "history_runs"}
)
):
raise web.HTTPBadRequest()
if chronological_history:
manifest = await api._run_in_executor(
lambda: seed_workspace_history(api, root, history_runs=history_runs)
)
history_fixture = manifest
return web.json_response({"success": True, **manifest})
history_commands.reset()
workspace_visual = data.get("workspace_visual", False)
full_inspector = data.get("full_inspector", False)
extra = data.get("extra_images", 0)
include_video = data.get("include_video", False)
prompt_inspector = data.get("prompt_inspector", False)
native_library = data.get("native_library", False)
extra_prompts = data.get("extra_prompts", 0)
extra_variants = data.get("extra_variants", 0)
if (
not isinstance(workspace_visual, bool)
or not isinstance(full_inspector, bool)
or (
full_inspector
and (
workspace_visual
or prompt_inspector
or native_library
or extra_prompts != 0
or extra_variants != 0
)
)
or (
workspace_visual
and any(
key in data
for key in (
"extra_images",
"include_video",
"prompt_inspector",
"native_library",
"extra_prompts",
"extra_variants",
"full_inspector",
)
)
)
or isinstance(extra, bool)
isinstance(extra, bool)
or not isinstance(extra, int)
or not 0 <= extra <= 200
or not isinstance(include_video, bool)
or not isinstance(prompt_inspector, bool)
or not isinstance(native_library, bool)
or isinstance(extra_prompts, bool)
or not isinstance(extra_prompts, int)
or not 0 <= extra_prompts <= 200
or isinstance(extra_variants, bool)
or not isinstance(extra_variants, int)
or not 0 <= extra_variants <= 200
):
raise web.HTTPBadRequest()
if workspace_visual:
history_fixture = None
await api._run_in_executor(seed_workspace_visual, api, root)
return web.json_response({"success": True})
if full_inspector:
history_fixture = None
await api._run_in_executor(
lambda: seed_workspace_inspector(
api, root, extra_images=extra, include_video=include_video
)
)
return web.json_response({"success": True})
history_fixture = None
await api._run_in_executor(
seed,
extra,
include_video,
prompt_inspector,
native_library,
extra_prompts,
extra_variants,
)
return web.json_response({"success": True})
@routes.post("/__test__/add-nested-image")
async def add_nested_image(request: web.Request) -> web.Response:
destination = root / "output" / "project" / "day" / "new-output.png"
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(root / "output" / "shared" / "same.png", destination)
await api._run_in_executor(seed, extra)
return web.json_response({"success": True})
@routes.get("/api/settings/Comfy.ColorPalette")
-661
View File
@@ -1,661 +0,0 @@
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import {readFile,writeFile} from 'node:fs/promises';
import {createHash} from 'node:crypto';
test.use({locale:'en-US',timezoneId:'UTC',reducedMotion:'reduce'});
const recorded=page=>page.getByRole('region',{name:'Recorded executions',exact:true});
const dialog=page=>page.getByRole('dialog',{name:'Recorded run',exact:true});
const saved=page=>page.getByRole('region',{name:'Saved prompt texts',exact:true});
const gallery=page=>page.getByRole('region',{name:'Shared gallery',exact:true});
const open=async(page,id)=>{await page.goto(`/prompt_manager/workspace#/library/${id}`);await expect(recorded(page)).toBeVisible();};
const marker=(page,id,anchor='10')=>recorded(page).locator(`button[data-run-id="${id}"][data-anchor-id="${anchor}"]`);
const exact=async locator=>locator.evaluate(node=>node.textContent);
test.beforeEach(async({page,request,browserName})=>{
await startCoverage(page,browserName);page.historyErrors=[];page.on('pageerror',error=>page.historyErrors.push(error.message));
const reset=await request.post('/__test__/reset',{data:{chronological_history:true}});await expect(reset).toBeOK();page.fixture=await reset.json();
});
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.historyErrors).toEqual([]);});
const command=async(request,data)=>{const response=await request.post('/__test__/history/commands',{data});await expect(response).toBeOK();return response.json();};
const restoreSnapshot=async(request,bytes)=>{const response=await request.post('/prompt_manager/restore',{multipart:{database_file:{name:'history.db',mimeType:'application/octet-stream',buffer:bytes}}});await expect(response).toBeOK();expect((await response.json()).success).toBe(true);};
const historyRead=async(request,id)=>{const response=await request.get(`/prompt_manager/library/${id}/history`);await expect(response).toBeOK();return response.json();};
const frozenContexts=contexts=>contexts.map(({navigation,positive_prompt_id,negative_prompt_id,pair_id,...frozen})=>frozen);
const immutableReceipts=items=>items.map(({current_candidate,...receipt})=>receipt);
async function reviewCapture(page,testInfo,name,target) {
await target.scrollIntoViewIfNeeded();await expect(target).toBeVisible();await page.evaluate(()=>document.fonts.ready);
const path=testInfo.outputPath(`${name}.png`);await target.screenshot({path,animations:'disabled'});await testInfo.attach(name,{path,contentType:'image/png'});
const geometry=await target.evaluate(node=>{
const box=node.getBoundingClientRect();
return {bounds:{x:box.x,y:box.y,width:box.width,height:box.height},scrollLeft:node.scrollLeft,scrollTop:node.scrollTop,
buttons:[...node.querySelectorAll('button')].filter(button=>button.checkVisibility()).map(button=>{const box=button.getBoundingClientRect();return {label:button.getAttribute('aria-label')||button.textContent,disabled:button.disabled,pressed:button.getAttribute('aria-pressed'),width:box.width,height:box.height};})};
});
const geometryPath=testInfo.outputPath(`${name}-geometry.json`);await writeFile(geometryPath,JSON.stringify(geometry,null,2));
await testInfo.attach(`${name}-geometry`,{path:geometryPath,contentType:'application/json'});
expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
}
test('owned history commands validate fixed boundaries and expire after ordinary reset',async({request})=>{
for(const data of [{}, {action:'arm',boundary:'arbitrary'}, {action:'replace-file',path:'/tmp/other'}, {action:'release',extra:true}, {action:'restore'}]) expect((await request.post('/__test__/history/commands',{data})).status()).toBe(400);
await command(request,{action:'arm',boundary:'window'});await command(request,{action:'release'});
await expect(await request.post('/__test__/reset')).toBeOK();
expect((await request.post('/__test__/history/commands',{data:{action:'replace-file'}})).status()).toBe(409);
expect((await request.get('/__test__/history/snapshot')).status()).toBe(409);
});
for(const boundary of ['window','detail','candidate','inspection','media']) test(`real restore rejects in-flight ${boundary} publication and all old epoch URLs`,async({request,page})=>{
const f=page.fixture,window=await historyRead(request,f.detail_id),epoch=window.history_epoch;
const base=`/prompt_manager/history/runs/${f.runs.shared}`;
const detail=await(await request.get(`${base}?epoch=${epoch}`)).json();
const firstWindow=await(await request.get(`/prompt_manager/library/${f.detail_id}/history?limit=1`)).json();expect(firstWindow.next_cursor).toBeTruthy();
const outputs=await(await request.get(`${base}/outputs?epoch=${epoch}&limit=1`)).json();
const allOutputs=await(await request.get(`${base}/outputs?epoch=${epoch}`)).json();
const media=outputs.items[0].current_candidate.media;
const snapshot=await request.get('/__test__/history/snapshot');await expect(snapshot).toBeOK();
const bytes=await snapshot.body();
const target={window:`/prompt_manager/library/${f.detail_id}/history?limit=1`,detail:`${base}?epoch=${epoch}`,candidate:`${base}/outputs?epoch=${epoch}`,inspection:media.inspection_url,media:media.url}[boundary];
await command(request,{action:'arm',boundary});const pending=request.get(target);
try {
await expect.poll(async()=> (await(await request.get('/__test__/history/checkpoint')).json()).entered).toBe(true);
await restoreSnapshot(request,bytes);
} finally {await command(request,{action:'release'});}
const retired=await pending;expect(retired.status()).toBe(409);expect((await retired.json()).code).toBe('history-changed');
for(const url of [`/prompt_manager/library/${f.detail_id}/history?limit=1&cursor=${encodeURIComponent(firstWindow.next_cursor)}`,`${base}?epoch=${epoch}`,`${base}/outputs?epoch=${epoch}`,`${base}/outputs?epoch=${epoch}&cursor=${encodeURIComponent(outputs.next_cursor)}`,media.url,media.thumbnail_url,media.inspection_url]) {
const response=await request.get(url);expect(response.status()).toBe(409);expect((await response.json()).code).toBe('history-changed');
}
const fresh=await historyRead(request,f.detail_id);expect(fresh.history_epoch).not.toBe(epoch);
const after=await(await request.get(`${base}?epoch=${fresh.history_epoch}`)).json();expect(frozenContexts(after.contexts)).toEqual(frozenContexts(detail.contexts));
for(const context of after.contexts) {expect(context.positive_prompt_id).toBeNull();expect(context.navigation).toEqual({positive_available:false,negative_available:false,pair_available:false});}
const freshOutputs=await(await request.get(`${base}/outputs?epoch=${fresh.history_epoch}`)).json();
expect(immutableReceipts(freshOutputs.items)).toEqual(immutableReceipts(allOutputs.items));
expect(freshOutputs.items).toHaveLength(5);await expect(await request.get(`/prompt_manager/library/${f.detail_id}`)).toBeOK();
});
test('same-path replacement is current content and a during-inspection change is rejected',async({page,request})=>{
const f=page.fixture,window=await historyRead(request,f.detail_id),base=`/prompt_manager/history/runs/${f.runs.shared}`;
const before=await(await request.get(`${base}/outputs?epoch=${window.history_epoch}`)).json(),media=before.items[0].current_candidate.media;
const original=await request.get(media.url);expect(original.headers()['cache-control']).toBe('no-store');const bytes=await original.body();
await command(request,{action:'arm',boundary:'inspection'});const pending=request.get(media.inspection_url);
try {
await expect.poll(async()=> (await(await request.get('/__test__/history/checkpoint')).json()).entered).toBe(true);
await command(request,{action:'replace-file'});
} finally {await command(request,{action:'release'});}
const changed=await pending;expect(changed.status()).toBe(409);expect((await changed.json()).code).toBe('candidate-stale');
const after=await(await request.get(`${base}/outputs?epoch=${window.history_epoch}`)).json();expect(immutableReceipts(after.items)).toEqual(immutableReceipts(before.items));
const current=await request.get(after.items[0].current_candidate.media.url);await expect(current).toBeOK();expect(await current.body()).not.toEqual(bytes);
for(const field of ['url','thumbnail_url','inspection_url'])expect((await request.get(after.items[0].current_candidate.media[field])).headers()['cache-control']).toBe('no-store');
expect(after.items[0]).toMatchObject({file_identity:'unverified',newly_generated:'unknown',context_membership:'unknown'});
});
test('root reconfiguration rejects in-flight candidates and every old scope URL without changing receipts',async({page,request})=>{
const f=page.fixture,window=await historyRead(request,f.detail_id),base=`/prompt_manager/history/runs/${f.runs.shared}`;
await open(page,f.detail_id);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
const before=await(await request.get(`${base}/outputs?epoch=${window.history_epoch}`)).json(),media=before.items[0].current_candidate.media;
await command(request,{action:'arm',boundary:'candidate'});const pending=request.get(`${base}/outputs?epoch=${window.history_epoch}`);
try {
await expect.poll(async()=> (await(await request.get('/__test__/history/checkpoint')).json()).entered).toBe(true);
await command(request,{action:'reconfigure-root'});
} finally {await command(request,{action:'release'});}
expect((await pending).status()).toBe(409);
for(const field of ['url','thumbnail_url','inspection_url']) {const response=await request.get(media[field]);expect(response.status()).toBe(409);expect((await response.json()).code).toBe('candidate-stale');}
const after=await(await request.get(`${base}/outputs?epoch=${window.history_epoch}`)).json();expect(immutableReceipts(after.items)).toEqual(immutableReceipts(before.items));
expect(after.items[0].current_candidate).toMatchObject({status:'unavailable',reason:'namespace_mismatch'});
await marker(page,f.runs.shared).click();await expect(dialog(page)).toContainText('namespace_mismatch');
await page.keyboard.press('Escape');await expect(saved(page)).toBeVisible();await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
});
test('archive delete merge and prune preserve frozen evidence without repairing original saved hints',async({page,request})=>{
const f=page.fixture,window=await historyRead(request,f.detail_id),base=`/prompt_manager/history/runs/${f.runs.shared}?epoch=${window.history_epoch}`;
const before=await(await request.get(base)).json(),outputs=await(await request.get(`/prompt_manager/history/runs/${f.runs.shared}/outputs?epoch=${window.history_epoch}`)).json();
const details=await(await request.get(`/prompt_manager/library/${f.detail_id}`)).json();
await expect(await request.patch(`/prompt_manager/pairs/${f.saved_pair}`,{data:{archived:true}})).toBeOK();
expect(frozenContexts((await(await request.get(base)).json()).contexts)).toEqual(frozenContexts(before.contexts));
await expect(await request.delete(`/prompt_manager/images/${f.detail_image_id}`)).toBeOK();
for(const pair of details.variants)await expect(await request.delete(`/prompt_manager/pairs/${pair.id}`)).toBeOK();
const text=details.positive.text;
expect(f.merge_duplicate_id).not.toBe(f.detail_id);
const merge=await request.post('/prompt_manager/cleanup');await expect(merge).toBeOK();expect((await merge.json()).duplicates_removed).toBeGreaterThan(0);
await expect(await request.delete(`/prompt_manager/delete/${f.detail_id}`)).toBeOK();
await expect(await request.post('/prompt_manager/pairs',{data:{positive_text:text,negative_text:'replacement saved negative'}})).toBeOK();
const prune=await request.post('/prompt_manager/maintenance',{data:{operations:['prune_orphaned_prompts']}});await expect(prune).toBeOK();expect((await prune.json()).all_successful).toBe(true);
const after=await(await request.get(base)).json();expect(frozenContexts(after.contexts)).toEqual(frozenContexts(before.contexts));
for(const context of after.contexts) {expect(context.navigation.positive_available).toBe(false);expect(context.positive_prompt_id).toBeNull();}
const receipts=await(await request.get(`/prompt_manager/history/runs/${f.runs.shared}/outputs?epoch=${window.history_epoch}`)).json();expect(immutableReceipts(receipts.items)).toEqual(immutableReceipts(outputs.items));
});
test('actual restore with reused numeric owner rejects stale history and offers explicit saved recovery',async({page,request})=>{
const f=page.fixture;await open(page,f.detail_id);const original=await exact(saved(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('pre'));
const source=await request.get('/__test__/history/reused-snapshot');await expect(source).toBeOK();await restoreSnapshot(request,await source.body());
await marker(page,f.runs.shared).click();await expect(dialog(page)).toContainText('Recorded evidence changed');await page.keyboard.press('Escape');
await expect(page.locator('.library-parent-status')).toContainText('changed');expect(await exact(saved(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('pre'))).toBe(original);
await expect(recorded(page).getByRole('button',{name:'Reload recorded history',exact:true})).toBeVisible();
await page.getByRole('button',{name:'Reload saved Library view',exact:true}).click();await expect(saved(page)).toContainText('Restored owner with reused numeric ID');
expect((await request.get(`/prompt_manager/history/runs/${f.runs.shared}`)).status()).toBe(404);
});
for(const outcome of ['success','error'])test(`delayed actual old browser ${outcome} cannot repaint after restore and route return`,async({page,request})=>{
const f=page.fixture;await open(page,f.detail_id);const snapshot=await request.get('/__test__/history/snapshot');const bytes=await snapshot.body();
let entered,release;const started=new Promise(resolve=>{entered=resolve;}),held=new Promise(resolve=>{release=resolve;});
await page.route(new RegExp(`/history/runs/${f.runs.shared}\\?`),async route=>{
if(outcome==='error')await restoreSnapshot(request,bytes);
const response=await route.fetch();expect(response.status()).toBe(outcome==='success'?200:409);entered();await held;
await route.fulfill({response}).catch(()=>{});
});
try {
await marker(page,f.runs.shared).click();await started;if(outcome==='success')await restoreSnapshot(request,bytes);
await page.evaluate(()=>{location.hash='#/overview';});await expect(page.locator('#workspace-main h1')).toHaveText('Overview');release();
await page.unroute(new RegExp(`/history/runs/${f.runs.shared}\\?`));await open(page,f.detail_id);
await expect(recorded(page).locator('button[data-run-id]')).toHaveCount(0);
await expect(recorded(page).getByRole('button',{name:'Reload recorded history',exact:true})).toBeVisible();
await recorded(page).getByRole('button',{name:'Reload recorded history',exact:true}).click();
await expect(recorded(page).locator('button[data-run-id]').first()).toBeEnabled();await expect(page.locator('dialog[open]')).toHaveCount(0);
await marker(page,f.runs.empty).click();await expect(dialog(page)).toContainText(f.runs.empty);await page.keyboard.press('Escape');await expect(saved(page)).toBeVisible();
} finally {release();}
});
test('strict reset seeds real A,A,B positions, two contexts and independently selected saved gallery',async({page,request})=>{
for(const data of [{chronological_history:'true'},{chronological_history:true,native_library:true},{chronological_history:true,full_inspector:false},{chronological_history:true,extra_images:0},{chronological_history:true,history_runs:true},{chronological_history:true,unknown:1},{history_runs:1}])
expect((await request.post('/__test__/reset',{data})).status()).toBe(400);
const fixture=page.fixture;let galleryReads=0;page.on('request',request=>{if(new URL(request.url()).pathname===`/prompt_manager/library/${fixture.positive_id}/images`)galleryReads++;});
await open(page,fixture.positive_id);
const detail=await(await request.get(`/prompt_manager/library/${fixture.positive_id}`)).json();
const selected=detail.variants.findIndex(v=>v.negative_text==='A negative')+1;
await page.getByRole('button',{name:`Select negative variant ${selected}`,exact:true}).click();
await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
const before=galleryReads,keys=await gallery(page).locator('article[data-image-key]').evaluateAll(nodes=>nodes.map(n=>n.dataset.imageKey));
await expect(recorded(page)).toContainText('3 distinct recorded runs as of this read');
await expect(recorded(page)).toContainText('same positive raw text');
await expect(recorded(page).locator('[data-context-branch]')).toHaveCount(2);
const buttons=recorded(page).getByRole('list',{name:'Recorded run positions',exact:true}).getByRole('button');
await expect(buttons).toHaveCount(3);
expect(await buttons.evaluateAll(nodes=>nodes.map(n=>n.dataset.runId))).toEqual(fixture.aab);
const contexts=await buttons.evaluateAll(nodes=>nodes.map(n=>n.dataset.contextId));expect(contexts[0]).toBe(contexts[1]);expect(contexts[2]).not.toBe(contexts[0]);
for(const [index,id] of fixture.aab.entries()) {
await marker(page,id).focus();await page.keyboard.press(index===1?' ':'Enter');
await expect(dialog(page)).toContainText(id);
await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();
await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();
await expect(marker(page,id)).toBeFocused();
}
await expect(saved(page).getByRole('region',{name:'Negative prompt',exact:true})).toContainText('A negative');
await expect(page.getByRole('button',{name:`Select negative variant ${selected}`,exact:true})).toHaveAttribute('aria-pressed','true');
expect(galleryReads).toBe(before);expect(await gallery(page).locator('article[data-image-key]').evaluateAll(nodes=>nodes.map(n=>n.dataset.imageKey))).toEqual(keys);
});
test('frozen exact roles distinguish unknown, explicit empty, whitespace, mode and equal-looking snapshots',async({page,request})=>{
const f=page.fixture;await open(page,f.detail_id);
const window=await(await request.get(`/prompt_manager/library/${f.detail_id}/history`)).json();
const same=window.context_summaries.filter(c=>c.negative_raw.preview?.startsWith('same visible negative'));
expect(same.length).toBe(2);expect(same[0].context_id).not.toBe(same[1].context_id);
await expect(recorded(page).locator(`[data-context-branch="${same[0].context_id}"]`)).toHaveCount(1);
const shared=window.items.find(run=>run.run_id===f.runs.shared);
await marker(page,f.runs.shared,'z').click();await expect(dialog(page)).toBeVisible();
const contexts=dialog(page).getByRole('group',{name:'Recorded anchors',exact:true});
await expect(contexts.getByRole('button')).toHaveCount(3);await expect(contexts.locator('[aria-pressed="true"]')).toContainText('z');
const detail=await(await request.get(`/prompt_manager/history/runs/${f.runs.shared}?epoch=${window.history_epoch}`)).json();
const chosen=detail.contexts.find(c=>c.anchor_id==='z');
const pos=dialog(page).getByRole('region',{name:'Positive prompt',exact:true}),neg=dialog(page).getByRole('region',{name:'Negative prompt',exact:true});
expect(await exact(pos.locator('pre'))).toBe(chosen.positive.raw);expect(await exact(neg.locator('pre'))).toBe(chosen.negative.raw);
expect(await pos.evaluate(node=>node.nextElementSibling?.getAttribute('aria-label'))).toBe('Negative prompt');
await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();expect(await exact(neg.locator('pre'))).toBe(chosen.negative.effective);
await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();
await marker(page,f.runs.unknown).click();await dialog(page).getByRole('button',{name:'Raw',exact:true}).click();
const unknown=dialog(page).getByRole('region',{name:'Negative prompt',exact:true});await expect(unknown.getByRole('button')).toHaveCount(0);await expect(dialog(page).getByRole('button',{name:'Send prompt pair',exact:true})).toHaveCount(0);
await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();expect(await exact(unknown.locator('pre'))).toBe(' \n\t ');await expect(unknown).toContainText('host_cache_output');
await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();
await marker(page,f.runs.empty).click();await expect(dialog(page).getByRole('region',{name:'Negative prompt',exact:true})).toContainText('Empty negative prompt');
await expect(dialog(page).getByRole('button',{name:'Copy negative prompt',exact:true})).toBeVisible();await expect(dialog(page).getByRole('button',{name:'Send prompt pair',exact:true})).toBeVisible();
await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();
await marker(page,f.runs.whitespace).click();await dialog(page).getByRole('button',{name:'Raw',exact:true}).click();expect(await exact(dialog(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre'))).toBe(' \n\t ');
await expect(dialog(page)).toContainText('graph_literal');await expect(dialog(page)).toContainText('Role assignment: inferred');
await dialog(page).getByText('Frozen field provenance',{exact:true}).click();await expect(dialog(page)).toContainText('Prepend');await expect(dialog(page)).toContainText('Append');await expect(dialog(page)).toContainText('graph_semantics');
expect(shared.matching_anchors.map(a=>a.anchor_id)).toEqual(['10','2','z']);
});
test('every ordered receipt remains selected through distinct current locations, Viewer and nested Escape',async({page,request})=>{
const f=page.fixture;let outputReads=0;page.on('request',request=>{if(new URL(request.url()).pathname.endsWith('/outputs'))outputReads++;});await open(page,f.detail_id);await marker(page,f.runs.shared).click();
await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
const receipts=dialog(page).locator('[data-receipt]');
expect(await receipts.evaluateAll(nodes=>nodes.map(node=>[node.dataset.outputNodeId,Number(node.dataset.imageOrdinal)]))).toEqual([['10',0],['10',1],['2',0],['2',1],['z',0]]);
await expect(receipts.nth(3)).toContainText('missing');await expect(receipts.nth(4)).toContainText('Transient');
await expect(dialog(page)).toContainText('Context membership unknown');
const loadedReads=outputReads;const view=receipts.nth(1).getByRole('button',{name:'View current file',exact:true});await view.click();
const viewer=page.getByRole('dialog',{name:'Image preview',exact:true});await expect(viewer).toBeVisible();
await expect(viewer).toContainText('1 of 2 loaded');await expect(viewer.getByRole('region',{name:'Negative prompt',exact:true})).not.toContainText('same visible negative');
await viewer.getByRole('button',{name:'Next media',exact:true}).click();await expect(viewer).toContainText('2 of 2 loaded');
await page.keyboard.press('Escape');await expect(viewer).toHaveCount(0);await expect(dialog(page)).toBeVisible();
await expect(receipts.nth(2)).toHaveAttribute('aria-current','true');await expect(receipts.nth(2).getByRole('button',{name:'View current file',exact:true})).toBeFocused();
await receipts.nth(1).getByRole('button',{name:'View current file',exact:true}).click();await page.keyboard.press('Escape');
await expect(receipts.nth(1)).toHaveAttribute('aria-current','true');
expect(outputReads).toBe(loadedReads);
await page.keyboard.press('Escape');await expect(dialog(page)).toHaveCount(0);await expect(marker(page,f.runs.shared)).toBeFocused();
const window=await(await request.get(`/prompt_manager/library/${f.detail_id}/history`)).json();
const outputs=await(await request.get(`/prompt_manager/history/runs/${f.runs.shared}/outputs?epoch=${window.history_epoch}`)).json();expect(outputs.items).toHaveLength(5);
});
test('inspection first discovers a real restore and immediately retires every surrounding recorded action',async({page,request})=>{
const f=page.fixture;await open(page,f.detail_id);await marker(page,f.runs.shared).click();
await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
const snapshot=await request.get('/__test__/history/snapshot');await expect(snapshot).toBeOK();
await command(request,{action:'arm',boundary:'inspection'});
const inspectionRead=page.waitForResponse(response=>new URL(response.url()).pathname.endsWith('/output/current/inspection'));
try {
await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();
await expect(page.getByRole('dialog',{name:'Image preview',exact:true})).toBeVisible();
await expect.poll(async()=> (await(await request.get('/__test__/history/checkpoint')).json()).entered).toBe(true);
await page.evaluate(()=>{
window.recordedRecoveryStates=[];
const run=document.querySelector('.recorded-run-dialog');let changed=false;
const observer=new MutationObserver(()=>{
changed ||= run.querySelector('.recorded-run-status').textContent.includes('Recorded evidence changed');
if(changed)window.recordedRecoveryStates.push({receipts:run.querySelectorAll('[data-receipt]').length,active:[...run.querySelectorAll('[data-send-role],[data-copy-role],[data-view-receipt]')].filter(node=>!node.disabled&&node.checkVisibility()).length});
});observer.observe(run,{subtree:true,childList:true,attributes:true,characterData:true});
});
await restoreSnapshot(request,await snapshot.body());
} finally {await command(request,{action:'release'});}
const response=await inspectionRead;expect(response.status()).toBe(409);expect((await response.json()).code).toBe('history-changed');
await expect(dialog(page).locator('.recorded-run-status')).toContainText('Recorded evidence changed');
const viewer=page.getByRole('dialog',{name:'Image preview',exact:true});
if(await viewer.count())await page.keyboard.press('Escape');
await expect(dialog(page)).toBeVisible();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(0);
await expect(dialog(page).locator('[data-send-role]:enabled:visible,[data-copy-role]:enabled:visible,[data-view-receipt]:enabled:visible')).toHaveCount(0);
await expect(recorded(page).locator('button[data-run-id]')).toHaveCount(0);
await expect(page.locator('.library-parent-status')).toContainText('revalidated');
const states=await page.evaluate(()=>window.recordedRecoveryStates);expect(states.length).toBeGreaterThan(0);expect(states.every(state=>state.receipts===0&&state.active===0)).toBe(true);
await expect(dialog(page).getByRole('button',{name:'Reload recorded history',exact:true})).toBeVisible();
});
test('active stale inspection echo reaches history ownership without a second metadata implementation',async({page})=>{
const f=page.fixture;await open(page,f.detail_id);await marker(page,f.runs.shared).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
await page.route(/\/output\/current\/inspection\?/,async route=>{
const response=await route.fetch(),body=await response.json();body.history_epoch='33333333-3333-3333-3333-333333333333';await route.fulfill({response,json:body});
});
await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();
await expect(dialog(page).locator('.recorded-run-status')).toContainText('Recorded evidence changed');
await expect(dialog(page).locator('[data-receipt]')).toHaveCount(0);await expect(recorded(page).locator('button[data-run-id]')).toHaveCount(0);
});
test('unavailable exact receipt selection and targeted retry observe a newly available file without writes',async({page,request})=>{
const f=page.fixture;await open(page,f.detail_id);await page.getByRole('button',{name:'Select negative variant 1',exact:true}).click();
const keys=await gallery(page).locator('article[data-image-key]').evaluateAll(nodes=>nodes.map(node=>node.dataset.imageKey));
let writes=0,galleryReads=0,outputReads=0;
page.on('request',request=>{const url=new URL(request.url());if(url.pathname.startsWith('/prompt_manager/')&&request.method()!=='GET')writes++;if(url.pathname.endsWith('/images'))galleryReads++;if(url.pathname.endsWith('/outputs'))outputReads++;});
await marker(page,f.runs.shared,'z').click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();
const missing=dialog(page).locator('[data-output-node-id="2"][data-image-ordinal="1"]');await expect(missing).toContainText('missing');
await missing.getByRole('button',{name:'Select reported output',exact:true}).click();await expect(missing).toHaveAttribute('aria-current','true');
await expect(missing.getByRole('button',{name:'View current file',exact:true})).toBeHidden();
await command(request,{action:'make-missing-available'});
const beforeReads=outputReads,beforeGallery=galleryReads;
await missing.getByRole('button',{name:'Retry current availability',exact:true}).click();
await expect(missing).toContainText('Current candidate: available');await expect(missing).toHaveAttribute('aria-current','true');
expect(outputReads).toBe(beforeReads+1);expect(galleryReads).toBe(beforeGallery);expect(writes).toBe(0);
await expect(dialog(page).getByRole('button',{name:'Effective',exact:true})).toHaveAttribute('aria-pressed','true');
await expect(dialog(page).getByRole('group',{name:'Recorded anchors',exact:true}).locator('[aria-pressed="true"]')).toContainText('z');
await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
await missing.getByRole('button',{name:'View current file',exact:true}).click();await expect(page.getByRole('dialog',{name:'Image preview',exact:true})).toBeVisible();await page.keyboard.press('Escape');
await expect(missing).toHaveAttribute('aria-current','true');await page.keyboard.press('Escape');
await expect(page.getByRole('button',{name:'Select negative variant 1',exact:true})).toHaveAttribute('aria-pressed','true');
expect(await gallery(page).locator('article[data-image-key]').evaluateAll(nodes=>nodes.map(node=>node.dataset.imageKey))).toEqual(keys);expect(writes).toBe(0);
});
test('marker labels disclose exact observed dequeue and outcome including times outside display range',async({page,request})=>{
const f=page.fixture;await open(page,f.detail_id);const window=await historyRead(request,f.detail_id);
for(const run of window.items)for(const anchor of [...new Map(run.matching_anchors.map(anchor=>[anchor.context_id,run.matching_anchors.find(first=>first.context_id===anchor.context_id)])).values()]) {
const button=recorded(page).locator(`button[data-run-id="${run.run_id}"][data-context-id="${anchor.context_id}"]`);
const label=await button.getAttribute('aria-label');
expect(label).toContain(`Observed dequeue ${run.observed_dequeue_at} ms`);expect(label).toContain(`Outcome ${run.outcome}`);expect(label).toContain(`anchor ${anchor.anchor_id}`);
expect(await button.getAttribute('title')).toBe(label);
if(run.observed_dequeue_at==='9223372036854775807')expect(label).toContain('outside display range');
}
});
test('same-epoch refresh retains recorded context mode and exact receipt after fresh candidate reads',async({page})=>{
const f=page.fixture;await open(page,f.detail_id);await marker(page,f.runs.shared,'z').click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();
const selected=dialog(page).locator('[data-output-node-id="2"][data-image-ordinal="1"]');await selected.getByRole('button',{name:'Select reported output',exact:true}).click();
await page.keyboard.press('Escape');
let reads=0;page.on('request',request=>{if(new URL(request.url()).pathname.endsWith('/outputs'))reads++;});
await recorded(page).getByRole('button',{name:'Refresh history',exact:true}).click();
await expect(marker(page,f.runs.shared,'z')).toHaveAttribute('aria-pressed','true');await expect.poll(()=>reads).toBe(1);
await marker(page,f.runs.shared,'z').click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
await expect(selected).toHaveAttribute('aria-current','true');await expect(dialog(page).getByRole('button',{name:'Effective',exact:true})).toHaveAttribute('aria-pressed','true');
await expect(dialog(page).getByRole('group',{name:'Recorded anchors',exact:true}).locator('[aria-pressed="true"]')).toContainText('z');
});
test('route reentry restores older timeline range and both scroll axes after 61 new same-epoch runs',async({page,request})=>{
const reset=await request.post('/__test__/reset',{data:{chronological_history:true,history_runs:85}});await expect(reset).toBeOK();const f=await reset.json();
await page.addInitScript(()=>{window.IntersectionObserver=undefined;});await open(page,f.positive_id);
await expect(recorded(page)).toContainText('40 loaded positions');await recorded(page).getByRole('button',{name:'Load earlier runs',exact:true}).click();await expect(recorded(page)).toContainText('80 loaded positions');
const oldIds=await recorded(page).locator('[data-history-run]').evaluateAll(nodes=>nodes.map(node=>node.dataset.historyRun));const oldRun=oldIds[0];
await marker(page,oldRun).click();await expect(dialog(page)).toContainText(oldRun);await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();await page.keyboard.press('Escape');
const timeline=recorded(page).locator('.history-timeline');await timeline.evaluate(node=>{node.scrollLeft=731;node.scrollTop=95;node.dispatchEvent(new Event('scroll'));});
const scroll=await timeline.evaluate(node=>({left:node.scrollLeft,top:node.scrollTop}));expect(scroll.left).toBeGreaterThan(0);expect(scroll.top).toBeGreaterThan(0);
await page.getByRole('link',{name:'Back to library',exact:true}).evaluate(node=>node.click());await expect(recorded(page)).toHaveCount(0);await command(request,{action:'append-history-runs'});
let windowReads=0;page.on('request',request=>{if(/\/library\/\d+\/history$/.test(new URL(request.url()).pathname))windowReads++;});
let release,entered;const held=new Promise(resolve=>{release=resolve;}),started=new Promise(resolve=>{entered=resolve;});
await page.route(new RegExp(`/history/runs/${oldRun}\\?`),async route=>{const response=await route.fetch();entered();await held;await route.fulfill({response}).catch(()=>{});});
try {
await page.evaluate(id=>{location.hash=`#/library/${id}`;},f.positive_id);await started;
await expect(recorded(page)).toContainText('80 loaded positions');await expect(marker(page,oldRun)).toHaveAttribute('aria-disabled','true');
} finally {release();}
await expect(marker(page,oldRun)).toHaveAttribute('aria-pressed','true');await expect(marker(page,oldRun)).toHaveAttribute('aria-disabled','false');
expect(await recorded(page).locator('[data-history-run]').evaluateAll(nodes=>nodes.map(node=>node.dataset.historyRun))).toEqual(oldIds);expect(windowReads).toBe(2);
const restored=await timeline.evaluate(node=>({left:node.scrollLeft,top:node.scrollTop}));expect(Math.abs(restored.left-scroll.left)).toBeLessThanOrEqual(1);expect(Math.abs(restored.top-scroll.top)).toBeLessThanOrEqual(1);
await recorded(page).getByRole('button',{name:'Refresh history',exact:true}).click();await expect(recorded(page)).toContainText('149 distinct recorded runs');await expect(recorded(page)).toContainText('40 loaded positions');
await expect(recorded(page).locator('.history-status')).toContainText(`Selected run ${oldRun} is outside this loaded timeline window`);await expect(marker(page,oldRun)).toHaveCount(0);
await recorded(page).getByRole('button',{name:'View selected run',exact:true}).click();await expect(dialog(page)).toContainText(oldRun);await expect(dialog(page).getByRole('button',{name:'Effective',exact:true})).toHaveAttribute('aria-pressed','true');
});
for(const [width,height] of [[1600,1100],[390,844]])for(const theme of ['dark','light'])test(`final fix review states ${width} ${theme}`,async({page,request},testInfo)=>{
test.setTimeout(60000);await page.setViewportSize({width,height});await page.addInitScript(()=>{window.IntersectionObserver=undefined;});
const f=page.fixture;await open(page,f.detail_id);await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption(theme);
await marker(page,f.runs.shared,'z').click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
const missing=dialog(page).locator('[data-output-node-id="2"][data-image-ordinal="1"]');await missing.getByRole('button',{name:'Select reported output',exact:true}).click();
await expect(missing).toHaveAttribute('aria-current','true');await expect(missing.getByRole('button',{name:'Retry current availability',exact:true})).toBeVisible();
await reviewCapture(page,testInfo,`missing-selected-${width}-${theme}`,missing);
await reviewCapture(page,testInfo,`missing-dialog-${width}-${theme}`,dialog(page));
await command(request,{action:'make-missing-available'});await missing.getByRole('button',{name:'Retry current availability',exact:true}).click();
await expect(missing.getByRole('button',{name:'View current file',exact:true})).toBeVisible();await expect(missing).toHaveAttribute('aria-current','true');
await reviewCapture(page,testInfo,`available-selected-${width}-${theme}`,missing);await page.keyboard.press('Escape');
const reset=await request.post('/__test__/reset',{data:{chronological_history:true,history_runs:85}});await expect(reset).toBeOK();const range=await reset.json();await open(page,range.positive_id);
await recorded(page).getByRole('button',{name:'Load earlier runs',exact:true}).click();await expect(recorded(page)).toContainText('80 loaded positions');
const ids=await recorded(page).locator('[data-history-run]').evaluateAll(nodes=>nodes.map(node=>node.dataset.historyRun)),oldRun=ids[0];
await marker(page,oldRun).click();await expect(dialog(page)).toContainText(oldRun);await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();await page.keyboard.press('Escape');
const timeline=recorded(page).locator('.history-timeline');await timeline.evaluate(node=>{node.scrollLeft=731;node.scrollTop=95;node.dispatchEvent(new Event('scroll'));});
const before=await timeline.evaluate(node=>({left:node.scrollLeft,top:node.scrollTop}));await reviewCapture(page,testInfo,`range-before-${width}-${theme}`,timeline);
await page.getByRole('link',{name:'Back to library',exact:true}).evaluate(node=>node.click());await expect(recorded(page)).toHaveCount(0);await command(request,{action:'append-history-runs'});
await page.evaluate(id=>{location.hash=`#/library/${id}`;},range.positive_id);await expect(recorded(page)).toContainText('80 loaded positions');await expect(marker(page,oldRun)).toHaveAttribute('aria-disabled','false');await expect(marker(page,oldRun)).toHaveAttribute('aria-pressed','true');
const after=await timeline.evaluate(node=>({left:node.scrollLeft,top:node.scrollTop}));expect(Math.abs(after.left-before.left)).toBeLessThanOrEqual(1);expect(Math.abs(after.top-before.top)).toBeLessThanOrEqual(1);
expect(await recorded(page).locator('[data-history-run]').evaluateAll(nodes=>nodes.map(node=>node.dataset.historyRun))).toEqual(ids);
await reviewCapture(page,testInfo,`range-after-${width}-${theme}`,timeline);
await recorded(page).getByRole('button',{name:'Refresh history',exact:true}).click();await expect(recorded(page)).toContainText(`Selected run ${oldRun} is outside this loaded timeline window`);await expect(recorded(page)).toContainText('149 distinct recorded runs');
await reviewCapture(page,testInfo,`outside-window-${width}-${theme}`,recorded(page));
await recorded(page).getByRole('button',{name:'View selected run',exact:true}).click();await expect(dialog(page)).toContainText(oldRun);await expect(dialog(page).getByRole('button',{name:'Effective',exact:true})).toHaveAttribute('aria-pressed','true');
await reviewCapture(page,testInfo,`outside-selected-dialog-${width}-${theme}`,dialog(page));
const hashes={};
for(const path of ['docs/concepts/workspace/index.html','docs/concepts/workspace/app.js','docs/concepts/workspace/style.css','database/history_reads.py','py/api/history.py','web/js/workspace/history-state.mjs','web/js/workspace/history-media.mjs','web/js/workspace/history.mjs','web/js/workspace/recorded-run.mjs','web/js/workspace/library.css'])hashes[path]=createHash('sha256').update(await readFile(new URL(`../../${path}`,import.meta.url))).digest('hex');
expect(hashes['docs/concepts/workspace/index.html']).toBe('d84712c7284bb232919a1ed9b154575c8ac574f63b2404a4ac4bc06cbf323c03');expect(hashes['docs/concepts/workspace/app.js']).toBe('e33e7652da12a68cd3fe31e94d77d680aa439b6434ceee94f21cc4ce8883263e');expect(hashes['docs/concepts/workspace/style.css']).toBe('2c479ca1a2ddbbd14a550eae27dd731c326ccdb91fe9ddc87e2b81cd5298fce8');
const manifest=testInfo.outputPath('final-fix-visual-source-and-scroll.json');await writeFile(manifest,JSON.stringify({width,height,theme,oldRun,before,after,hashes},null,2));
await testInfo.attach('final-fix-visual-source-and-scroll.json',{path:manifest,contentType:'application/json'});
});
test('output capture distinguishes unknown, complete zero, partial and conflicts with exact out-of-range time',async({page})=>{
const f=page.fixture;await open(page,f.detail_id);
for(const [name,expected] of [['equal_looking','Reported outputs not captured'],['empty','0 reported outputs · complete'],['whitespace','1 retained reported output · incomplete'],['conflicting','First captured context · conflicting evidence']]) {
await marker(page,f.runs[name]).click();await expect(dialog(page)).toContainText(expected);
if(name==='conflicting') {await expect(dialog(page)).toContainText('Recorded time outside display range');await expect(dialog(page)).toContainText('9223372036854775807');}
await page.keyboard.press('Escape');
}
});
test('history, run and output failures retry their own reads and route exit ignores late callbacks',async({page})=>{
const f=page.fixture;let windowFailure=true,runFailure=true,outputFailure=true;
await page.route(/\/library\/\d+\/history\?/,async route=>windowFailure?route.fulfill({status:503,json:{success:false}}):route.continue());
await page.goto(`/prompt_manager/workspace#/library/${f.detail_id}`);await expect(recorded(page).getByRole('button',{name:'Retry recorded history',exact:true})).toBeVisible();
await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);windowFailure=false;await recorded(page).getByRole('button',{name:'Retry recorded history',exact:true}).click();
await page.route(/\/history\/runs\/[^/]+\?/,async route=>runFailure?route.fulfill({status:503,json:{success:false}}):route.continue());
await page.route(/\/history\/runs\/[^/]+\/outputs\?/,async route=>outputFailure?route.fulfill({status:503,json:{success:false}}):route.continue());
await marker(page,f.runs.shared).click();await expect(dialog(page).getByRole('button',{name:'Retry run details',exact:true})).toBeVisible();runFailure=false;await dialog(page).getByRole('button',{name:'Retry run details',exact:true}).click();
await expect(dialog(page).getByRole('button',{name:'Retry reported outputs',exact:true})).toBeVisible();outputFailure=false;await dialog(page).getByRole('button',{name:'Retry reported outputs',exact:true}).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
await page.keyboard.press('Escape');let release;const held=new Promise(resolve=>{release=resolve;});await page.route(/\/history\/runs\/[^/]+\?/,async route=>{await held;await route.continue().catch(()=>{});});
await marker(page,f.runs.empty).click();await page.evaluate(()=>{location.hash='#/overview';});release();await expect(page.locator('#workspace-main h1')).toHaveText('Overview');await expect(page.locator('dialog[open]')).toHaveCount(0);await expect(page.locator('.viewer-container')).toHaveCount(0);
});
test('more than 1000 real runs retain sparse columns, focused identity and pixel anchors on prepend with moveBefore absent',async({page,request})=>{
test.setTimeout(120000);const response=await request.post('/__test__/reset',{data:{chronological_history:true,history_runs:1001}});await expect(response).toBeOK();const f=await response.json();
await page.addInitScript(()=>{window.IntersectionObserver=undefined;Element.prototype.moveBefore=undefined;});await open(page,f.positive_id);
const more=recorded(page).getByRole('button',{name:'Load earlier runs',exact:true,includeHidden:true});
await expect(recorded(page).locator('[data-history-run]')).toHaveCount(40);
const first=recorded(page).getByRole('list',{name:'Recorded run positions',exact:true}).getByRole('button').first();
await first.focus();await first.evaluate(node=>{window.retainedHistoryButton=node;window.retainedHistoryRun=node.dataset.runId;window.retainedHistoryX=node.getBoundingClientRect().left;window.retainedHistoryY=node.getBoundingClientRect().top;});
await more.evaluate(node=>node.click());await expect(recorded(page)).toContainText('80 loaded positions');
const retained=await page.evaluate(()=>({same:window.retainedHistoryButton===document.querySelector(`[data-run-id="${window.retainedHistoryRun}"]`),focus:document.activeElement===window.retainedHistoryButton,delta:Math.abs(window.retainedHistoryX-window.retainedHistoryButton.getBoundingClientRect().left),vertical:Math.abs(window.retainedHistoryY-window.retainedHistoryButton.getBoundingClientRect().top)}));
expect(retained.same&&retained.focus).toBe(true);expect(retained.delta).toBeLessThanOrEqual(1);expect(retained.vertical).toBeLessThanOrEqual(1);
while(await more.isVisible()) {await more.evaluate(node=>node.click());await expect(more).not.toHaveAttribute('aria-disabled','true');}
await expect(recorded(page)).toContainText('1004 loaded positions');
const timeline=recorded(page).locator('.history-timeline');await timeline.evaluate(node=>{node.scrollLeft=node.scrollWidth/2;node.dispatchEvent(new Event('scroll'));});
const bound=await timeline.evaluate(node=>Math.ceil(node.clientWidth/64)+8);
await expect.poll(()=>recorded(page).locator('[data-history-run]').count()).toBeLessThanOrEqual(bound);
expect(await recorded(page).locator('[data-run-id]').count()).toBeLessThanOrEqual(bound);
expect(await page.evaluate(()=>window.retainedHistoryButton.isConnected&&document.activeElement===window.retainedHistoryButton)).toBe(true);
await page.getByRole('link',{name:'Back to library',exact:true}).evaluate(node=>node.click());await expect(recorded(page)).toHaveCount(0);
});
test('390px and 200 percent layout keeps scrolling inside timeline and coarse targets remain 44px',async({page})=>{
await page.setViewportSize({width:390,height:844});await open(page,page.fixture.detail_id);
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);
await page.locator('#workspace-main').evaluate(node=>{node.style.zoom='2';});
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);
await marker(page,page.fixture.runs.shared).click();expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);await page.keyboard.press('Escape');
await page.locator('#workspace-main').evaluate(node=>node.style.removeProperty('zoom'));
await page.emulateMedia({forcedColors:'active'});await expect(marker(page,page.fixture.runs.shared)).toBeVisible();
});
test('frozen Effective pair uses the shared acknowledged sender and Escape closes only its top dialog',async({page,context})=>{
const host=await context.newPage();await host.goto('/prompt_manager/workspace#/overview');
await host.evaluate(async()=>{
const {createBridgeHost}=await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');
const target={id:'history-pair',label:'History destination',type:'PromptManagerPair',roles:['positive','negative']};window.historyApplied=[];
window.historyHost=createBridgeHost({origin:location.origin,namespace:'/',label:'History ComfyUI',adapter:{listTargets:()=>[target],
preview:(_id,roles)=>({preview:{target,fields:Object.entries(roles).map(([role,after])=>({role,before:`Previous ${role}`,after})),composition:{prepend:'',append:'',prependConnected:false,appendConnected:false,before:'Previous positive',after:roles.positive??'Previous positive'}},snapshot:roles}),
apply:roles=>{window.historyApplied.push(roles);return{targetId:target.id,values:roles};}}});
});
try {
await open(page,page.fixture.detail_id);await marker(page,page.fixture.runs.unknown).click();await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();
const positive=await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('pre'));
const send=dialog(page).getByRole('button',{name:'Send prompt pair',exact:true});await send.click();
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'History ComfyUI'});
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'History destination'});await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
await expect(sender.getByRole('button',{name:'Apply to ComfyUI',exact:true})).toBeEnabled();await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
expect(await host.evaluate(()=>window.historyApplied)).toEqual([{positive,negative:' \n\t '}]);
await page.keyboard.press('Escape');await expect(sender).toHaveCount(0);await expect(dialog(page)).toBeVisible();await expect(send).toBeFocused();
await page.keyboard.press('Escape');await expect(dialog(page)).toHaveCount(0);
} finally {await host.evaluate(()=>window.historyHost.dispose());await host.close();}
});
test('pagehide tears down dialogs and persisted reentry revalidates before enabling recorded actions',async({page})=>{
await open(page,page.fixture.detail_id);await marker(page,page.fixture.runs.shared).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();await expect(page.getByRole('dialog',{name:'Image preview',exact:true})).toBeVisible();
await page.evaluate(()=>dispatchEvent(new PageTransitionEvent('pagehide',{persisted:true})));await expect(page.locator('dialog[open],.viewer-container')).toHaveCount(0);
let release;const held=new Promise(resolve=>{release=resolve;});let checks=0;
await page.route(/\/history\/runs\/[^/]+\?/,async route=>{checks++;await held;await route.continue().catch(()=>{});});
await page.evaluate(()=>dispatchEvent(new PageTransitionEvent('pageshow',{persisted:true})));await expect(recorded(page)).toBeVisible();
await expect(recorded(page).locator('button[data-run-id]').first()).toHaveAttribute('aria-disabled','true');
await marker(page,page.fixture.runs.shared).evaluate(node=>node.click());await expect(dialog(page)).toHaveCount(0);release();
await expect(recorded(page).locator('button[data-run-id]').first()).toHaveAttribute('aria-disabled','false');expect(checks).toBeGreaterThan(0);await expect(dialog(page)).toHaveCount(0);
await marker(page,page.fixture.runs.shared).click();await expect(dialog(page)).toContainText(page.fixture.runs.shared);await page.keyboard.press('Escape');
});
test('native connected moves and keyboard navigation preserve marker identity through earlier branch reorder',async({page,request})=>{
const result=await request.post('/__test__/reset',{data:{chronological_history:true,history_runs:140}});await expect(result).toBeOK();const f=await result.json();
await page.addInitScript(()=>{window.IntersectionObserver=undefined;window.historyMoves=0;const move=Element.prototype.moveBefore;window.hasHistoryMove=typeof move==='function';if(move)Element.prototype.moveBefore=function(...args){window.historyMoves++;return move.apply(this,args);};});
await open(page,f.positive_id);const buttons=recorded(page).locator('button[data-run-id]');await expect(buttons).toHaveCount(40);await buttons.first().focus();
await buttons.first().evaluate(node=>{window.keyedHistoryButton=node;window.keyedHistoryX=node.getBoundingClientRect().left;window.keyedHistoryY=node.getBoundingClientRect().top;});
await recorded(page).getByRole('button',{name:'Load earlier runs',exact:true}).evaluate(node=>node.click());await expect(recorded(page)).toContainText('80 loaded positions');
const retained=await page.evaluate(()=>({same:window.keyedHistoryButton.isConnected,focused:document.activeElement===window.keyedHistoryButton,delta:Math.abs(window.keyedHistoryX-window.keyedHistoryButton.getBoundingClientRect().left),vertical:Math.abs(window.keyedHistoryY-window.keyedHistoryButton.getBoundingClientRect().top)}));
expect(retained.same&&retained.focused).toBe(true);expect(retained.delta).toBeLessThanOrEqual(1);expect(retained.vertical).toBeLessThanOrEqual(1);
if(await page.evaluate(()=>window.hasHistoryMove)) expect(await page.evaluate(()=>window.historyMoves)).toBeGreaterThan(0);
await page.keyboard.press('End');await expect(recorded(page).locator('[data-history-run]').last()).toHaveAttribute('aria-posinset','80');
expect(await page.evaluate(()=>document.activeElement.closest('[data-history-run]')?.getAttribute('aria-posinset'))).toBe('80');
});
test('coarse-pointer markers expose 44px targets while retaining the small repeat visual',async({browser,request})=>{
const context=await browser.newContext({viewport:{width:390,height:844},hasTouch:true,isMobile:true});const page=await context.newPage();
try {
const reset=await request.post('/__test__/reset',{data:{chronological_history:true}});const f=await reset.json();await open(page,f.positive_id);const buttons=recorded(page).locator('button[data-run-id]');await expect(buttons).toHaveCount(3);
expect(await buttons.evaluateAll(nodes=>nodes.every(node=>node.getBoundingClientRect().width>=44&&node.getBoundingClientRect().height>=44))).toBe(true);
const repeat=marker(page,f.aab[1]);expect(await repeat.evaluate(node=>({width:getComputedStyle(node,'::before').width,height:getComputedStyle(node,'::before').height}))).toEqual({width:'12px',height:'12px'});
await repeat.tap();await expect(dialog(page)).toContainText(f.aab[1]);await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).tap();await expect(dialog(page)).toHaveCount(0);
} finally {await context.close();}
});
test('coarse recorded toolbar and disclosure expose 44px targets and real touch actions',async({browser,browserName,request},testInfo)=>{
const context=await browser.newContext({viewport:{width:390,height:844},hasTouch:true,isMobile:browserName!=='firefox'});const page=await context.newPage();
try {
const reset=await request.post('/__test__/reset',{data:{chronological_history:true}});const f=await reset.json();await open(page,f.positive_id);
expect(await page.evaluate(()=>matchMedia('(pointer: coarse)').matches)).toBe(true);
const controls=recorded(page).locator('.history-toolbar button:visible,.history-scope summary');
const sizes=await controls.evaluateAll(nodes=>nodes.map(node=>({label:node.textContent,width:node.getBoundingClientRect().width,height:node.getBoundingClientRect().height})));
await testInfo.attach('coarse-recorded-controls.json',{body:JSON.stringify(sizes,null,2),contentType:'application/json'});
for(const control of sizes) {expect.soft(control.width,control.label).toBeGreaterThanOrEqual(44);expect.soft(control.height,control.label).toBeGreaterThanOrEqual(44);}
await recorded(page).getByText('Recorded history scope',{exact:true}).tap();await expect(recorded(page).locator('.history-scope')).toHaveAttribute('open','');
await recorded(page).getByRole('button',{name:'Refresh history',exact:true}).tap();await expect(recorded(page).locator('button[data-run-id]')).toHaveCount(3);
} finally {await context.close();}
});
test('capture recovery reloads only reported outputs while retaining frozen run and saved gallery',async({page})=>{
const f=page.fixture;let failures=2,outputReads=0,runReads=0,galleryReads=0;
page.on('request',request=>{const path=new URL(request.url()).pathname;if(path===`/prompt_manager/history/runs/${f.runs.shared}`)runReads++;if(path===`/prompt_manager/library/${f.detail_id}/images`)galleryReads++;});
await page.route(/\/history\/runs\/[^/]+\/outputs\?/,async route=>{
outputReads++;if(failures-->0)return route.fulfill({status:409,json:{success:false,history_version:1,code:'capture-changed',error:'Reported output capture changed',recovery:{action:'reload-outputs'}}});
await route.continue();
});
await open(page,f.detail_id);await marker(page,f.runs.shared).click();
const reload=dialog(page).getByRole('button',{name:'Reload reported outputs',exact:true});await expect(reload).toBeVisible();expect(outputReads).toBe(2);
const runCount=runReads,galleryCount=galleryReads;await reload.click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
expect(runReads).toBe(runCount);expect(galleryReads).toBe(galleryCount);expect(outputReads).toBe(3);await expect(dialog(page)).toContainText(f.runs.shared);
});
test('history epoch recovery reports saved-parent read failures and retries without resetting the gallery',async({page})=>{
const f=page.fixture;await open(page,f.detail_id);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
let parentFailure=true,galleryReads=0;page.on('request',request=>{if(new URL(request.url()).pathname===`/prompt_manager/library/${f.detail_id}/images`)galleryReads++;});
await page.route(new RegExp(`/prompt_manager/library/${f.detail_id}(?:\\?|$)`),async route=>parentFailure?route.fulfill({status:503,json:{success:false,error:'Unavailable'}}):route.continue());
await page.route(/\/history\/runs\/[^/]+\/outputs\?/,async route=>route.fulfill({status:409,json:{success:false,history_version:1,code:'history-changed',error:'History changed',recovery:{action:'refresh-history'}}}));
await marker(page,f.runs.shared).click();await expect(dialog(page)).toContainText('Recorded evidence changed');await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();
const retry=page.getByRole('button',{name:'Retry saved Library details',exact:true});await expect(retry).toBeVisible();await expect(page.locator('.library-parent-status')).toContainText('could not be revalidated');
parentFailure=false;await retry.click();await expect(page.locator('.library-parent-status')).toContainText('revalidated');await expect(retry).toBeHidden();
await expect(recorded(page).getByRole('button',{name:'Reload recorded history',exact:true})).toBeVisible();expect(galleryReads).toBe(0);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
});
test('changed saved-owner identity is disclosed without silently relabeling or rebinding the existing gallery',async({page,request})=>{
const f=page.fixture;await open(page,f.detail_id);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
const positive=saved(page).getByRole('region',{name:'Positive prompt',exact:true});const original=await exact(positive.locator('pre'));
const cards=gallery(page).locator('article[data-image-key]');await cards.first().evaluate(node=>{window.savedGalleryCard=node;});let galleryReads=0;
page.on('request',request=>{if(new URL(request.url()).pathname===`/prompt_manager/library/${f.detail_id}/images`)galleryReads++;});
const details=await(await request.get(`/prompt_manager/library/${f.detail_id}`)).json();
await expect(await request.delete(`/prompt_manager/images/${f.detail_image_id}`)).toBeOK();
for(const variant of details.variants) await expect(await request.delete(`/prompt_manager/pairs/${variant.id}`)).toBeOK();
await expect(await request.put(`/prompt_manager/prompts/${f.detail_id}`,{data:{text:'Replaced saved owner'}})).toBeOK();
await page.route(/\/history\/runs\/[^/]+\/outputs\?/,async route=>route.fulfill({status:409,json:{success:false,history_version:1,code:'history-changed',error:'History changed',recovery:{action:'refresh-history'}}}));
await marker(page,f.runs.shared).click();await expect(dialog(page)).toContainText('Recorded evidence changed');await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();
await expect(page.locator('.library-parent-status')).toContainText('changed');expect(await exact(positive.locator('pre'))).toBe(original);
const reload=page.getByRole('button',{name:'Reload saved Library view',exact:true});await expect(reload).toBeVisible();
expect(await cards.first().evaluate(node=>node===window.savedGalleryCard)).toBe(true);expect(galleryReads).toBe(0);
await reload.click();await expect(saved(page).getByRole('region',{name:'Positive prompt',exact:true})).toContainText('Replaced saved owner');
});
test('a bounded saved-parent prefix leaves previously loaded variants unverified without claiming deletion',async({page,request})=>{
const f=page.fixture;const detail=await(await request.get(`/prompt_manager/library/${f.detail_id}`)).json();
for(let index=0;index<41;index++)await expect(await request.post('/prompt_manager/pairs',{data:{positive_text:detail.positive.text,negative_text:`Bounded negative ${index}`}})).toBeOK();
await open(page,f.detail_id);await expect(page.getByRole('button',{name:'Load more negative variants',exact:true})).toBeVisible();await page.getByRole('button',{name:'Load more negative variants',exact:true}).click();
const choices=page.getByRole('button',{name:/^Select negative variant /});await expect(choices).toHaveCount(43);await choices.last().click();await expect(choices.last()).toHaveAttribute('aria-pressed','true');
const negative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).textContent();
await page.route(/\/history\/runs\/[^/]+\/outputs\?/,async route=>route.fulfill({status:409,json:{success:false,history_version:1,code:'history-changed',error:'History changed',recovery:{action:'refresh-history'}}}));
await marker(page,f.runs.shared).click();await expect(dialog(page)).toContainText('Recorded evidence changed');await page.keyboard.press('Escape');
await expect(page.locator('.library-parent-status')).toContainText('not observed in this bounded revalidation page');await expect(page.locator('.library-parent-status')).not.toContainText(/deleted|removed/i);
await expect(choices.last()).toHaveAttribute('aria-pressed','true');expect(await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).textContent()).toBe(negative);
await expect(page.getByRole('button',{name:'Reload saved Library view',exact:true})).toBeVisible();await expect(choices.last()).toBeDisabled();
});
async function retainSavedSelection(page,request,fixture) {
await open(page,fixture.detail_id);
const details=await(await request.get(`/prompt_manager/library/${fixture.detail_id}`)).json();
const index=details.variants.findIndex(variant=>variant.id===fixture.saved_pair);
await page.getByRole('button',{name:`Select negative variant ${index+1}`,exact:true}).click();
await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
const negative=await exact(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre'));
await gallery(page).locator('article[data-image-key]').first().evaluate(node=>{window.fix1SavedGalleryCard=node;});
let reads=0;page.on('request',request=>{if(new URL(request.url()).pathname===`/prompt_manager/library/${fixture.detail_id}/images`)reads++;});
return async()=>{
await expect(page.getByRole('button',{name:`Select negative variant ${index+1}`,exact:true})).toHaveAttribute('aria-pressed','true');
expect(await exact(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre'))).toBe(negative);
expect(await gallery(page).locator('article[data-image-key]').first().evaluate(node=>node===window.fix1SavedGalleryCard)).toBe(true);expect(reads).toBe(0);
};
}
for(const change of ['mode','anchor']) test(`initial receipt read restarts after a held ${change} cancellation`,async({page,request})=>{
const f=page.fixture;const retained=await retainSavedSelection(page,request,f);let reads=0,release,entered;
const held=new Promise(resolve=>{release=resolve;}),started=new Promise(resolve=>{entered=resolve;});
let aborted=0;page.on('requestfailed',request=>{if(new URL(request.url()).pathname===`/prompt_manager/history/runs/${f.runs.shared}/outputs`)aborted++;});
await page.route(new RegExp(`/history/runs/${f.runs.shared}/outputs\\?`),async route=>{reads++;if(reads===1){entered();await held;}await route.continue().catch(()=>{});});
try {
await marker(page,f.runs.shared).click();await started;
if(change==='mode')await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();
else await dialog(page).getByRole('group',{name:'Recorded anchors',exact:true}).getByRole('button',{name:/^Anchor z /}).click();
release();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);expect(reads).toBe(2);
await expect.poll(()=>aborted).toBeGreaterThan(0);
if(change==='mode')await expect(dialog(page).getByRole('button',{name:'Effective',exact:true})).toHaveAttribute('aria-pressed','true');
else await expect(dialog(page).getByRole('group',{name:'Recorded anchors',exact:true}).getByRole('button',{name:/^Anchor z /})).toHaveAttribute('aria-pressed','true');
await page.keyboard.press('Escape');await retained();
} finally {release();}
});
test('same-run reopen loads page one after cancellation and a held fresh detail read',async({page,request})=>{
const f=page.fixture,retained=await retainSavedSelection(page,request,f);let reads=0,releaseOutputs,enteredOutputs,releaseDetail,enteredDetail;
const outputsHeld=new Promise(resolve=>{releaseOutputs=resolve;}),outputsStarted=new Promise(resolve=>{enteredOutputs=resolve;});
const detailHeld=new Promise(resolve=>{releaseDetail=resolve;}),detailStarted=new Promise(resolve=>{enteredDetail=resolve;});
await page.route(new RegExp(`/history/runs/${f.runs.shared}/outputs\\?`),async route=>{reads++;enteredOutputs();await outputsHeld;await route.continue().catch(()=>{});});
try {
await marker(page,f.runs.shared).click();await outputsStarted;await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();
await page.route(new RegExp(`/history/runs/${f.runs.shared}\\?`),async route=>{enteredDetail();await detailHeld;await route.continue().catch(()=>{});});
await marker(page,f.runs.shared).click();await detailStarted;await expect(dialog(page).locator('[data-receipt]')).toHaveCount(0);await expect(dialog(page)).toContainText('Loading recorded run');
releaseDetail();releaseOutputs();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);expect(reads).toBe(3);
await expect(dialog(page).getByRole('button',{name:'Effective',exact:true})).toHaveAttribute('aria-pressed','true');await page.keyboard.press('Escape');await retained();
} finally {releaseDetail();releaseOutputs();}
});
test('late output capture replaces the earlier uncaptured detail summary with real complete-zero evidence',async({page,request})=>{
const f=page.fixture,retained=await retainSavedSelection(page,request,f);let release,entered;
const held=new Promise(resolve=>{release=resolve;}),started=new Promise(resolve=>{entered=resolve;});
for(const data of [{}, {run_id:f.runs.equal_looking,extra:true}, {run_id:[]}, {run_id:'not-a-run'}])expect((await request.post('/__test__/history/capture-zero-outputs',{data})).status()).toBe(400);
await page.route(new RegExp(`/history/runs/${f.runs.equal_looking}/outputs\\?`),async route=>{entered();await held;await route.continue().catch(()=>{});});
const detailRead=page.waitForResponse(response=>new URL(response.url()).pathname===`/prompt_manager/history/runs/${f.runs.equal_looking}`);
try {
await marker(page,f.runs.equal_looking).click();await started;const detail=await(await detailRead).json();expect(detail.output_summary.state).toBe('not_captured');
const write=await request.post('/__test__/history/capture-zero-outputs',{data:{run_id:f.runs.equal_looking}});await expect(write).toBeOK();expect((await write.json()).success).toBe(true);
const pageRead=page.waitForResponse(response=>new URL(response.url()).pathname===`/prompt_manager/history/runs/${f.runs.equal_looking}/outputs`);release();const outputs=await(await pageRead).json();
expect(outputs.capture).toMatchObject({state:'complete',reason:'none_reported'});expect(outputs.retained_occurrence_count).toBe(0);expect(outputs.list_status).toBe('complete');
await expect(dialog(page).locator('.recorded-output-status')).toHaveText('0 reported outputs · complete · All retained receipts loaded.');
await expect(dialog(page).getByText('Output capture',{exact:true}).locator('..').locator('dd')).toHaveText('complete · none_reported');
await expect(dialog(page).getByText('Context capture',{exact:true}).locator('..').locator('dd')).toHaveText('complete · captured');
await page.keyboard.press('Escape');await retained();
} finally {release();}
});
test('capture-only recovery uses the changed real output capture without re-reading run or saved gallery',async({page,request})=>{
const f=page.fixture,retained=await retainSavedSelection(page,request,f);let outputsReads=0,runReads=0;
page.on('request',request=>{if(new URL(request.url()).pathname===`/prompt_manager/history/runs/${f.runs.whitespace}`)runReads++;});
await page.route(new RegExp(`/history/runs/${f.runs.whitespace}/outputs\\?`),async route=>{
outputsReads++;
if(outputsReads===1) {
const write=await request.post('/__test__/history/capture-zero-outputs',{data:{run_id:f.runs.whitespace}});await expect(write).toBeOK();expect((await write.json()).success).toBe(true);
return route.fulfill({status:409,json:{success:false,history_version:1,code:'capture-changed',error:'Reported output capture changed',recovery:{action:'reload-outputs'}}});
}
await route.continue();
});
const detailRead=page.waitForResponse(response=>new URL(response.url()).pathname===`/prompt_manager/history/runs/${f.runs.whitespace}`);
const outputsRead=page.waitForResponse(response=>new URL(response.url()).pathname===`/prompt_manager/history/runs/${f.runs.whitespace}/outputs`&&response.status()===200);
await marker(page,f.runs.whitespace).click();const detail=await(await detailRead).json();expect(detail.output_summary.state).toBe('partial');
const outputs=await(await outputsRead).json();expect(outputs.capture.state).toBe('conflicting');expect(outputs.retained_occurrence_count).toBe(1);expect(outputs.list_status).toBe('incomplete');
await expect(dialog(page).locator('[data-receipt]')).toHaveCount(1);await expect(dialog(page).locator('.recorded-output-status')).toContainText('1 retained reported output · incomplete · conflicting');
await expect(dialog(page).getByText('Output capture',{exact:true}).locator('..').locator('dd')).toHaveText('conflicting · conflicting');expect(runReads).toBe(1);expect(outputsReads).toBe(2);
expect(await exact(dialog(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre'))).toBe(' \n\t ');await page.keyboard.press('Escape');await retained();
});
test('completed page one stays bounded across mode changes until explicit output paging',async({page,request})=>{
const f=page.fixture,retained=await retainSavedSelection(page,request,f);let reads=0,releaseNext,enteredNext;
const held=new Promise(resolve=>{releaseNext=resolve;}),started=new Promise(resolve=>{enteredNext=resolve;});
await page.route(new RegExp(`/history/runs/${f.runs.paged}/outputs\\?`),async route=>{
reads++;if(new URL(route.request().url()).searchParams.has('cursor')){enteredNext();await held;}await route.continue().catch(()=>{});
});
try {
await marker(page,f.runs.paged).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(40);expect(reads).toBe(1);
await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();await dialog(page).getByRole('button',{name:'Raw',exact:true}).click();
const more=dialog(page).getByRole('button',{name:'Load more reported outputs',exact:true});await expect(more).toBeEnabled();expect(reads).toBe(1);await expect(dialog(page).locator('[data-receipt]')).toHaveCount(40);
await more.click();await started;expect(reads).toBe(2);releaseNext();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(41);await expect(more).toBeHidden();
await page.keyboard.press('Escape');await retained();
} finally {releaseNext();}
});
-743
View File
@@ -1,743 +0,0 @@
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
const workspace = '/prompt_manager/workspace#/images';
const cards = page => page.locator('article[data-image-key]');
const details = page => page.getByRole('region', { name: 'Image details', exact: true });
test.beforeEach(async ({ page, request, browserName }) => {
await startCoverage(page, browserName);
await expect(await request.post('/__test__/reset')).toBeOK();
page.imageErrors = [];
page.on('pageerror', error => page.imageErrors.push(error.message));
});
test.afterEach(async ({ page, browserName }) => {
await saveCoverage(page, browserName);
expect(page.imageErrors).toEqual([]);
});
test('native Images keeps colliding filenames distinct and inspects the selected source', async ({ page, request }, testInfo) => {
await page.setViewportSize({width:1600,height:1100});
async function capture(name) {
await page.evaluate(async()=>{await document.fonts.ready;await Promise.all([...document.images].map(image=>image.decode().catch(()=>{})));});
const path=testInfo.outputPath(`${name}.png`);await page.screenshot({path});
await testInfo.attach(name,{path,contentType:'image/png'});
}
const snapshot = await (await request.get('/prompt_manager/stats')).json();
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
expect(await cards(page).evaluateAll(elements => elements.map(element => element.dataset.imageKey)))
.toEqual(['0:shared/same.png', '1:shared/same.png']);
const originals = await cards(page).locator('img').evaluateAll(elements => elements.map(element => element.src));
expect(new Set(originals).size).toBe(2);
await cards(page).nth(1).getByRole('button', { name: 'Open same.png', exact: true }).click();
await expect(details(page)).toContainText('same.png');
await expect(details(page)).toContainText('shared/same.png');
await expect(details(page)).toContainText(/input/i);
await capture('after-images-source-collision-input-1600');
await cards(page).first().getByRole('button', { name: 'Open same.png', exact: true }).click();
await expect(details(page)).toContainText(/output/i);
await capture('after-images-source-collision-output-1600');
await page.setViewportSize({width:390,height:844});
for (const [index,source] of [[1,'input'],[0,'output']]) {
await cards(page).nth(index).getByRole('button',{name:'Open same.png',exact:true}).click();
await expect(details(page)).toContainText(new RegExp(source,'i'));
await details(page).scrollIntoViewIfNeeded();
await capture(`after-images-source-collision-${source}-390`);
}
expect((await (await request.get('/prompt_manager/stats')).json()).stats).toEqual(snapshot.stats);
});
test('workspace defaults to the native Images collection without a hash route', async ({ page }) => {
await page.goto('/prompt_manager/workspace');
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
await expect(cards(page)).toHaveCount(2);
await expect(page.getByRole('navigation', { name: 'Workspace', exact: true })
.getByRole('link', { name: 'Images', exact: true })).toHaveAttribute('aria-current', 'page');
});
test('image selection is announced on the selected image button', async ({ page }) => {
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
const selected = cards(page).last().getByRole('button', { name: 'Open same.png', exact: true });
await selected.click();
await expect(selected).toHaveAttribute('aria-pressed', 'true');
await expect(cards(page).first().getByRole('button', { name: 'Open same.png', exact: true }))
.toHaveAttribute('aria-pressed', 'false');
});
test('closing details preview restores its trigger focus', async ({ page }) => {
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
const selected = cards(page).last().getByRole('button', { name: 'Open same.png', exact: true });
await selected.click();
const preview = details(page).getByRole('button', { name: 'Open full preview', exact: true });
await preview.click();
await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.locator('dialog[open]')).toHaveCount(0);
await expect(preview).toBeFocused();
});
test('a short visible batch automatically continues until the sentinel is filled or exhausted', async ({ page, request }) => {
const real = await (await request.get('/prompt_manager/images/output?limit=40')).json();
let reads = 0;
await page.route('**/prompt_manager/images/output?*', async route => {
const current = reads++;
await route.fulfill({ json: {
success: true, images: [real.images[current]], total: 2,
has_more: current === 0, next_cursor: current === 0 ? 'next-short-batch' : null,
} });
});
await page.setViewportSize({ width: 1440, height: 1200 });
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
expect(reads).toBe(2);
await expect(page.getByRole('button', { name: 'Load more images', exact: true })).toBeHidden();
});
for (const embedded of [false, true]) {
for (const close of ['Close', 'Escape', 'parent abort']) {
test(`viewer closure resumes native continuation after a short pending page: ${embedded ? 'embedded' : 'Images'} ${close}`, async ({ page, request }, testInfo) => {
await expect(await request.post('/__test__/reset', { data: { extra_images: 80 } })).toBeOK();
const data = await (await request.get('/prompt_manager/images/output?limit=200')).json();
await page.setViewportSize({ width: 1600, height: 1100 });
await page.route('**/prompt_manager/workspace', async route => {
const response = await route.fetch();
const html = (await response.text()).replace('<script type="module" src="js/workspace/index.mjs"></script>', '');
await route.fulfill({ response, body: html });
});
await page.goto('/prompt_manager/workspace');
await page.evaluate(async ({ images, embedded }) => {
const { mountImages } = await import('/prompt_manager/js/workspace/images.mjs');
const { createScope } = await import('/prompt_manager/js/shared/toolkit/core.js');
window.ApiClient = { resolveUrl: value => value };
const fixture = window.continuationFixture = { reads: [], scope: createScope() };
const api = { get: async (url, options) => (await fetch(url, options)).json() };
const fetchPage = ({ parameters }) => {
fixture.reads.push(parameters.get('cursor'));
if (fixture.reads.length === 1) return { images: images.slice(0, 40), total: 80, has_more: true, next_cursor: 'next-0' };
if (fixture.reads.length === 2) return new Promise(resolve => {
fixture.finishPage = () => resolve({ images: [], total: 80, has_more: true, next_cursor: 'next-1' });
});
return { images: images.slice(40, 80), total: 80, has_more: false, next_cursor: null };
};
mountImages(document.querySelector('#workspace-main'), api, fixture.scope, { embedded, fetchPage });
}, { images: data.images, embedded });
await expect(cards(page)).toHaveCount(40);
expect(await page.evaluate(() => window.continuationFixture.reads)).toEqual([null]);
await page.locator('.image-continuation').scrollIntoViewIfNeeded();
await expect.poll(() => page.evaluate(() => window.continuationFixture.reads)).toEqual([null, 'next-0']);
const key = await cards(page).last().getAttribute('data-image-key');
const preview = page.locator(`article[data-image-key="${key}"]`).getByRole('button', { name: /^Preview / });
await preview.click();
const viewer = page.getByRole('dialog', { name: 'Image preview', exact: true });
await expect(viewer.locator('.viewer-canvas img')).toBeVisible();
await page.evaluate(() => window.continuationFixture.finishPage());
await expect(page.getByRole('button', { name: 'Load more images', exact: true })).toHaveAttribute('aria-disabled', 'false');
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
expect(await page.evaluate(() => ({ reads: window.continuationFixture.reads, nativeObserver: typeof IntersectionObserver, visible: document.querySelector('.image-continuation').getBoundingClientRect().top <= innerHeight })))
.toEqual({ reads: [null, 'next-0'], nativeObserver: 'function', visible: true });
if (close === 'parent abort') await page.evaluate(() => window.continuationFixture.scope.dispose());
else if (close === 'Close') await viewer.getByRole('button', { name: 'Close preview', exact: true }).click();
else await page.keyboard.press('Escape');
await expect(viewer).toHaveCount(0);
if (close !== 'parent abort') {
await expect(preview).toBeFocused();
await expect.poll(() => page.evaluate(() => window.continuationFixture.reads)).toEqual([null, 'next-0', 'next-1']);
await expect(page.locator('.image-toolbar')).toContainText(embedded ? '80 loaded' : '80 of 80');
}
await page.evaluate(() => window.continuationFixture.scope.dispose());
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
const reads = await page.evaluate(() => window.continuationFixture.reads);
expect(reads).toEqual(close === 'parent abort' ? [null, 'next-0'] : [null, 'next-0', 'next-1']);
await testInfo.attach('native-continuation-reads', { body: JSON.stringify({ embedded, close, reads }), contentType: 'application/json' });
});
}
}
test('source, folder and media filters operate on real backend records', async ({ page, request }) => {
await expect(await request.post('/__test__/reset', { data: { extra_images: 3 } })).toBeOK();
await page.goto(workspace);
await expect(cards(page)).toHaveCount(5);
await page.getByRole('combobox', { name: 'Source', exact: true }).selectOption('1');
await expect(cards(page)).toHaveCount(1);
await expect(cards(page).first()).toHaveAttribute('data-image-key', '1:shared/same.png');
await page.getByRole('combobox', { name: 'Source', exact: true }).selectOption('all');
await page.getByRole('combobox', { name: 'Folder', exact: true }).selectOption('bulk');
await expect(cards(page)).toHaveCount(3);
await expect(cards(page).first()).toHaveAttribute('data-image-key', /^0:bulk\//);
await page.getByRole('combobox', { name: 'Media', exact: true }).selectOption('video');
await expect(cards(page)).toHaveCount(0);
await expect(page.locator('#workspace-main')).toContainText(/no (matching )?(images|media)/i);
await page.getByRole('combobox', { name: 'Media', exact: true }).selectOption('image');
await expect(cards(page)).toHaveCount(3);
await page.getByRole('combobox', { name: 'Folder', exact: true }).selectOption('');
await expect(cards(page)).toHaveCount(5);
});
test('filename search finds records beyond the initial batch and sort orders them', async ({ page, request }) => {
await expect(await request.post('/__test__/reset', { data: { extra_images: 100 } })).toBeOK();
await page.goto(workspace);
await expect(cards(page)).toHaveCount(40);
await page.getByRole('searchbox', { name: 'Search filenames', exact: true }).fill('same.png');
await expect(cards(page)).toHaveCount(2);
await page.getByRole('searchbox', { name: 'Search filenames', exact: true }).fill('0099');
await expect(cards(page)).toHaveCount(1);
await expect(cards(page)).toHaveAttribute('data-image-key', '0:bulk/0099.png');
await page.getByRole('searchbox', { name: 'Search filenames', exact: true }).fill('same.png');
await expect(cards(page)).toHaveCount(2);
await page.getByRole('combobox', { name: 'Sort', exact: true }).selectOption('oldest');
await expect(cards(page).first()).toHaveAttribute('data-image-key', '1:shared/same.png');
await page.getByRole('combobox', { name: 'Sort', exact: true }).selectOption('newest');
await expect(cards(page).first()).toHaveAttribute('data-image-key', '0:shared/same.png');
});
test('layout changes retain selection and use one collection in Grid, List and Masonry', async ({ page }) => {
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
await cards(page).nth(1).getByRole('button', { name: 'Open same.png', exact: true }).click();
for (const layout of ['List', 'Masonry', 'Grid']) {
await page.getByRole('button', { name: layout, exact: true }).click();
await expect(page.getByRole('button', { name: layout, exact: true })).toHaveAttribute('aria-pressed', 'true');
await expect(cards(page)).toHaveCount(2);
await expect(details(page)).toContainText(/input/i);
const boxes = await cards(page).evaluateAll(elements => elements.map(element => {
const box = element.getBoundingClientRect();
return { x: box.x, y: box.y, right: box.right, bottom: box.bottom };
}));
expect(boxes[0].right <= boxes[1].x || boxes[1].right <= boxes[0].x ||
boxes[0].bottom <= boxes[1].y || boxes[1].bottom <= boxes[0].y).toBe(true);
}
});
test('returning to Images restores the filtered stream, layout, selection and scroll context', async ({ page, request }) => {
await page.addInitScript(() => { window.IntersectionObserver = undefined; });
await expect(await request.post('/__test__/reset', { data: { extra_images: 100 } })).toBeOK();
await page.goto(workspace);
const search = page.getByRole('searchbox', { name: 'Search filenames', exact: true });
await page.getByRole('combobox', { name: 'Source', exact: true }).selectOption('0');
await search.fill('00');
await expect(page.locator('.image-toolbar')).toContainText('40 of 100 images');
await page.getByRole('button', { name: 'List', exact: true }).click();
await page.getByRole('button', { name: 'Load more images', exact: true }).click();
await expect(page.locator('.image-toolbar')).toContainText('80 of 100 images');
await cards(page).first().getByRole('button', { name: /^Open / }).click();
const selectedTitle = await details(page).getByRole('heading', { level: 2 }).textContent();
await cards(page).nth(30).scrollIntoViewIfNeeded();
const previousScroll = await page.evaluate(() => window.scrollY);
expect(previousScroll).toBeGreaterThan(500);
// Hash navigation preserves the scroll position being tested; clicking the fixed
// navigation can otherwise scroll a non-sticky mobile sidebar into view first.
await page.evaluate(() => { window.location.hash = '/overview'; });
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
await page.evaluate(() => { window.location.hash = '/images'; });
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
await expect(search).toHaveValue('00');
await expect(page.getByRole('combobox', { name: 'Source', exact: true })).toHaveValue('0');
await expect(page.getByRole('button', { name: 'List', exact: true })).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator('.image-toolbar')).toContainText('80 of 100 images');
await expect(cards(page)).toHaveCount(80);
await expect(details(page).getByRole('heading', { level: 2 })).toHaveText(selectedTitle);
await expect.poll(async () => Math.abs((await page.evaluate(() => window.scrollY)) - previousScroll)).toBeLessThan(120);
});
test('continuous loading appends without replacing cards or losing selected details', async ({ page, request }) => {
// Exercise the keyboard fallback independently from IntersectionObserver scrolling.
await page.addInitScript(() => { window.IntersectionObserver = undefined; });
await expect(await request.post('/__test__/reset', { data: { extra_images: 100 } })).toBeOK();
await page.goto(workspace);
await expect(cards(page)).toHaveCount(40);
await cards(page).first().evaluate(element => { element.dataset.preserved = 'yes'; });
await cards(page).first().getByRole('button', { name: /^Open / }).click();
const selected = await cards(page).first().getAttribute('data-image-key');
const more = page.getByRole('button', { name: 'Load more images', exact: true });
await more.focus();
await page.keyboard.press('Enter');
await expect(cards(page)).toHaveCount(80);
await expect(cards(page).first()).toHaveAttribute('data-preserved', 'yes');
await expect(cards(page).first()).toHaveAttribute('data-image-key', selected);
await expect(details(page)).toBeVisible();
await expect(more).toBeFocused();
await page.keyboard.press('Enter');
await expect(cards(page)).toHaveCount(102);
expect(new Set(await cards(page).evaluateAll(elements => elements.map(element => element.dataset.imageKey))).size).toBe(102);
await expect(more).toBeHidden();
await expect(page.getByRole('button', { name: /next page|previous page/i })).toHaveCount(0);
});
test('scrolling to the sentinel automatically appends the next backend batch', async ({ page, request }) => {
await expect(await request.post('/__test__/reset', { data: { extra_images: 100 } })).toBeOK();
await page.goto(workspace);
await expect(cards(page)).toHaveCount(40);
const first = cards(page).first();
await first.evaluate(element => { element.dataset.preserved = 'scroll'; });
await page.getByRole('button', { name: 'Load more images', exact: true }).scrollIntoViewIfNeeded();
await expect(cards(page)).toHaveCount(80);
await expect(first).toHaveAttribute('data-preserved', 'scroll');
});
test('large continuous collections bound the mounted cards and restore earlier images on backscroll', async ({ page, request }) => {
await page.addInitScript(() => { window.IntersectionObserver = undefined; });
await expect(await request.post('/__test__/reset', { data: { extra_images: 200 } })).toBeOK();
await page.goto(workspace);
await expect(cards(page)).toHaveCount(40);
const first = cards(page).first();
const firstKey = await first.getAttribute('data-image-key');
await first.getByRole('button', { name: /^Open / }).click();
const selectedTitle = await details(page).getByRole('heading', { level: 2 }).textContent();
const more = page.getByRole('button', { name: 'Load more images', exact: true });
for (const loaded of [80, 120, 160, 200, 202]) {
await more.click();
await expect(page.locator('.image-toolbar')).toContainText(`${loaded} of 202 images`);
}
await expect(more).toBeHidden();
await expect.poll(() => cards(page).count()).toBeLessThan(120);
await expect(details(page).getByRole('heading', { level: 2 })).toHaveText(selectedTitle);
expect(new Set(await cards(page).evaluateAll(elements => elements.map(element => element.dataset.imageKey))).size)
.toBe(await cards(page).count());
await page.evaluate(() => window.scrollTo(0, 0));
await expect(page.locator(`article[data-image-key="${firstKey}"]`)).toBeVisible();
await expect.poll(() => cards(page).count()).toBeLessThan(120);
await expect(details(page).getByRole('heading', { level: 2 })).toHaveText(selectedTitle);
await page.locator(`article[data-image-key="${firstKey}"]`)
.getByRole('button', { name: /^Preview / }).click();
await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible();
await page.getByRole('button', { name: 'Close preview', exact: true }).click();
});
test('virtual gallery keyboard navigation reaches loaded endpoints and announces collection positions', async ({ page, request }) => {
await page.addInitScript(() => { window.IntersectionObserver = undefined; });
await expect(await request.post('/__test__/reset', { data: { extra_images: 200 } })).toBeOK();
await page.goto(workspace);
await expect(cards(page)).toHaveCount(40);
const firstKey = await cards(page).first().getAttribute('data-image-key');
const firstName = await cards(page).first().getByRole('button', { name: /^Open / }).getAttribute('aria-label');
await cards(page).first().getByRole('button', { name: /^Open / }).click();
const selectedTitle = await details(page).getByRole('heading', { level: 2 }).textContent();
const more = page.getByRole('button', { name: 'Load more images', exact: true });
for (const loaded of [80, 120, 160, 200, 202]) {
await more.click();
await expect(page.locator('.image-toolbar')).toContainText(`${loaded} of 202 images`);
}
await page.evaluate(() => window.scrollTo(0, 0));
const first = page.locator(`article[data-image-key="${firstKey}"]`);
await expect(first).toBeVisible();
await first.getByRole('button', { name: firstName, exact: true }).focus();
await page.keyboard.press('End');
await expect(page.locator('article[data-image-key="1:shared/same.png"]')
.getByRole('button', { name: 'Open same.png', exact: true })).toBeFocused();
await expect(page.locator('article[data-image-key="1:shared/same.png"]')).toHaveAttribute('aria-posinset', '202');
await expect(page.locator('article[data-image-key="1:shared/same.png"]')).toHaveAttribute('aria-setsize', '202');
await expect(page.getByRole('list', { name: 'Image collection', exact: true })).toBeVisible();
await expect(page.locator('article[data-image-key="1:shared/same.png"]')).toHaveAttribute('role', 'listitem');
await page.keyboard.press('Home');
await expect(first.getByRole('button', { name: firstName, exact: true })).toBeFocused();
await expect(first).toHaveAttribute('aria-posinset', '1');
await page.keyboard.press('ArrowRight');
await expect(page.locator('article[aria-posinset="2"]').getByRole('button', { name: /^Open / })).toBeFocused();
await expect(details(page).getByRole('heading', { level: 2 })).toHaveText(selectedTitle);
await expect.poll(() => cards(page).count()).toBeLessThan(120);
});
test('preview uses actual Viewer.js and cleans up when closing or leaving Images', async ({ page }) => {
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
await cards(page).last().getByRole('button', { name: 'Preview same.png', exact: true }).click();
await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible();
await expect(page.locator('dialog[open] .viewer-canvas img')).toHaveAttribute('src', /root=1/);
await page.getByRole('button', { name: 'Close preview', exact: true }).click();
await expect(page.locator('dialog[open]')).toHaveCount(0);
await cards(page).first().getByRole('button', { name: 'Preview same.png', exact: true }).click();
await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible();
await page.evaluate(() => { window.location.hash = '/overview'; });
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
await expect(page.locator('.viewer-container')).toHaveCount(0);
await expect(page.locator('dialog[open]')).toHaveCount(0);
});
test('real video media has a usable card and native playback cleans up on close and navigation', async ({ page, request }) => {
await expect(await request.post('/__test__/reset', { data: { include_video: true } })).toBeOK();
const media = await (await request.get('/prompt_manager/images/output?limit=40')).json();
expect(media.images.find(image => image.filename === 'clip.webm').thumbnail_url).toBeNull();
await page.goto(workspace);
await page.getByRole('combobox', { name: 'Media', exact: true }).selectOption('video');
await expect(cards(page)).toHaveCount(1);
const card = cards(page).first();
await expect(card).toHaveAttribute('data-image-key', '0:clip.webm');
await expect(card.locator('img')).toHaveCount(0);
await expect(card.getByRole('button', { name: 'Open clip.webm', exact: true })).toBeVisible();
await card.getByRole('button', { name: 'Preview clip.webm', exact: true }).click();
const video = page.locator('dialog[open] video');
await expect(video).toBeVisible();
await expect(video).toHaveJSProperty('controls', true);
await expect.poll(() => video.evaluate(element => element.readyState)).toBeGreaterThanOrEqual(1);
const playing = await video.evaluate(async element => {
window.__previewVideo = element;
element.muted = true;
await element.play();
return !element.paused;
});
expect(playing).toBe(true);
await page.getByRole('button', { name: 'Close preview', exact: true }).click();
await expect(page.locator('dialog video')).toHaveCount(0);
expect(await page.evaluate(() => window.__previewVideo.paused && !window.__previewVideo.isConnected)).toBe(true);
await card.getByRole('button', { name: 'Preview clip.webm', exact: true }).click();
await expect(video).toBeVisible();
await video.evaluate(async element => {
window.__previewVideo = element;
element.muted = true;
await element.play();
});
await page.evaluate(() => { window.location.hash = '/overview'; });
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
await expect(page.locator('video')).toHaveCount(0);
expect(await page.evaluate(() => window.__previewVideo.paused && !window.__previewVideo.isConnected)).toBe(true);
});
test('collection viewer ignores delayed media callbacks and keeps inspection retries independent',async({page})=>{
await page.goto(workspace);await expect(cards(page)).toHaveCount(2);
await page.evaluate(()=>{const Native=window.Viewer;window.liveViewers=[];window.Viewer=class extends Native {constructor(...args){super(...args);window.liveViewers.push(this);}};});
let release,held;const waiting=new Promise(resolve=>{held=resolve;});const pending=new Promise(resolve=>{release=resolve;});
await page.route('**/images/serve/shared/same.png?*',async route=>{
const url=new URL(route.request().url());
if(url.searchParams.get('root')==='0'&&url.searchParams.has('_pm_viewer')) {held();await pending;}
await route.continue().catch(()=>{});
});
await cards(page).first().getByRole('button',{name:'Preview same.png',exact:true}).click();await waiting;
const preview=page.getByRole('dialog',{name:'Image preview',exact:true});
await expect(preview.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','true');
await page.route('**/images/inspect/shared/same.png?*',route=>route.fulfill({status:503,json:{success:false,error:'Unavailable'}}),{times:1});
await preview.getByRole('button',{name:'Next media',exact:true}).click();
await expect(preview).toContainText('Could not read prompts from this file.');await expect(preview.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
await expect(preview.getByRole('group',{name:'Media controls',exact:true}).getByRole('link',{name:'Download original',exact:true})).toHaveAttribute('href',/root=1&root_scope=/);
await preview.getByRole('button',{name:'Rotate image 90 degrees',exact:true}).click();
await page.evaluate(()=>{window.latestCanvas=document.querySelector('.viewer-canvas img');window.liveViewers[0].options.viewed();});release();
expect(await page.evaluate(()=>window.liveViewers.at(-1).imageData.rotate)).toBe(90);
await preview.getByRole('button',{name:'Retry prompt details',exact:true}).click();
await expect(preview).toContainText('same.png');await expect(preview.getByRole('button',{name:'Retry prompt details',exact:true})).toHaveCount(0);
expect(await page.evaluate(()=>window.latestCanvas===document.querySelector('.viewer-canvas img'))).toBe(true);
await page.evaluate(()=>{location.hash='/overview';});await expect(page.locator('.viewer-container,.image-preview')).toHaveCount(0);
});
test('collection viewer describes video transforms and releases native playback on navigation',async({page,request})=>{
await expect(await request.post('/__test__/reset',{data:{include_video:true}})).toBeOK();await page.goto(workspace);
await page.locator('article[data-image-key="0:clip.webm"] .image-card-footer button').click();
const preview=page.getByRole('dialog',{name:'Image preview',exact:true}),video=preview.locator('video');
await expect(video).toHaveJSProperty('controls',true);await expect(video).toHaveJSProperty('playsInline',true);
const zoom=preview.getByRole('button',{name:'Zoom in',exact:true});await expect(zoom).toHaveAttribute('aria-disabled','true');await expect(zoom).toHaveAccessibleDescription('Image transforms are unavailable for video.');
await video.focus();const position=await preview.locator('.image-preview-position').textContent();await page.keyboard.press('ArrowRight');await expect(preview.locator('.image-preview-position')).toHaveText(position);
await video.evaluate(async node=>{window.previousClip=node;node.muted=true;await node.play();});
await preview.getByRole('button',{name:'Next media',exact:true}).click();await expect(preview.locator('video')).toHaveCount(0);await expect(zoom).toHaveAttribute('aria-disabled','false');
expect(await page.evaluate(()=>window.previousClip.paused&&!window.previousClip.isConnected&&!window.previousClip.hasAttribute('src'))).toBe(true);
await preview.getByRole('button',{name:'Close preview',exact:true}).click();await expect(page.locator('.image-preview')).toHaveCount(0);
});
test('failed image requests offer retry while stale results cannot replace a newer route', async ({ page }) => {
await page.route('**/prompt_manager/images/output?*', route => route.fulfill({
status: 503, json: { success: false, error: 'Images unavailable' },
}), { times: 1 });
await page.goto(workspace);
await expect(page.locator('#workspace-main').getByRole('alert')).toBeVisible();
await page.getByRole('button', { name: 'Retry', exact: true }).click();
await expect(cards(page)).toHaveCount(2);
let release;
const pending = new Promise(resolve => { release = resolve; });
let arrived;
const received = new Promise(resolve => { arrived = resolve; });
await page.route('**/prompt_manager/images/output?*', async route => {
const response = await route.fetch();
arrived();
await pending;
await route.fulfill({ response }).catch(() => {});
});
await page.getByRole('searchbox', { name: 'Search filenames', exact: true }).fill('nothing');
await received;
await page.getByRole('navigation', { name: 'Workspace', exact: true })
.getByRole('link', { name: 'Overview', exact: true }).click();
release();
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
await expect(cards(page)).toHaveCount(0);
await expect(details(page)).toHaveCount(0);
});
test('source and folder discovery failure stays visible and Refresh restores usable filters', async ({ page }) => {
await page.route('**/prompt_manager/gallery/subfolders?*', route => route.fulfill({
status: 503, json: { success: false, error: 'Sources unavailable' },
}), { times: 1 });
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
await expect(page.getByRole('alert')).toHaveText('Sources and folders could not be loaded. Refresh to retry.');
await page.getByRole('button', { name: 'Refresh', exact: true }).click();
await expect(page.getByRole('alert')).toHaveCount(0);
await page.getByRole('combobox', { name: 'Source', exact: true }).selectOption('1');
await expect(cards(page)).toHaveCount(1);
await expect(cards(page)).toHaveAttribute('data-image-key', '1:shared/same.png');
await page.getByRole('combobox', { name: 'Folder', exact: true }).selectOption('shared');
await expect(cards(page)).toHaveCount(1);
});
async function staleSourceRecovery(page, request, roots) {
await expect(await request.post('/__test__/reset', { data: { extra_images: 80 } })).toBeOK();
await page.setViewportSize({ width: 1600, height: 1100 });
await page.goto(workspace);
const source = page.getByRole('combobox', { name: 'Source', exact: true });
const folder = page.getByRole('combobox', { name: 'Folder', exact: true });
await source.selectOption('0');await expect(folder.locator('option[value="bulk"]')).toHaveCount(1);await folder.selectOption('bulk');
const configured = page.waitForResponse(response => {
const url = new URL(response.url());return url.pathname.endsWith('/images/output') && url.searchParams.get('root') === '0' && url.searchParams.get('subfolder') === 'bulk' && url.searchParams.get('q') === 'png' && url.searchParams.get('media') === 'image' && url.searchParams.get('sort') === 'name';
});
await page.getByRole('searchbox', { name: 'Search filenames', exact: true }).fill('png');
await page.getByRole('combobox', { name: 'Media', exact: true }).selectOption('image');await page.getByRole('combobox', { name: 'Sort', exact: true }).selectOption('name');
const initial = await (await configured).json();await expect(page.locator('.image-toolbar')).toContainText('40 of 80');
let oldPage;
await page.route('**/prompt_manager/images/output?*', async route => {
if (new URL(route.request().url()).searchParams.has('cursor') && !oldPage) {
const response = await route.fetch();oldPage = { route, data: await response.json() };
} else await route.continue();
});
await page.getByRole('button', { name: 'Load more images', exact: true }).scrollIntoViewIfNeeded();await expect.poll(() => !!oldPage).toBe(true);
await expect(await request.post('/prompt_manager/settings', { data: { gallery_root_paths: roots } })).toBeOK();
const selected = initial.images.at(-1), key = `${selected.root_index}:${selected.path}`;
const stale = page.waitForResponse(response => response.url().includes('/images/inspect/') && response.status() === 409);
await page.locator(`article[data-image-key="${key}"] .image-open`).click();expect((await stale).status()).toBe(409);
await expect(details(page)).toContainText('The source changed. Refresh the image collection before inspecting this file.');
await page.locator(`article[data-image-key="${key}"] .image-card-footer button`).click();
const viewer = page.getByRole('dialog', { name: 'Image preview', exact: true });await expect(viewer).toContainText('The source changed.');
const pages = [], inspections = [], options = [], mutations = [];
page.on('request', request => {
const url = new URL(request.url());
if (url.pathname.endsWith('/images/output')) pages.push(url);
if (url.pathname.includes('/images/inspect/')) inspections.push(url);
if (request.method() !== 'GET') mutations.push(url.pathname);
});
await page.route('**/prompt_manager/gallery/subfolders?*', route => { options.push(route); });
await viewer.getByRole('button', { name: 'Refresh image collection', exact: true }).click();
await expect(viewer).toHaveCount(0);await expect.poll(() => options.length).toBe(1);
await oldPage.route.fulfill({ json: oldPage.data });
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
return { source, folder, pages, inspections, options, mutations };
}
for (const [name, roots] of [['reordered', ['input', 'output']], ['removed', ['input']]]) {
test(`source-change recovery clears a ${name} root before discovery and safe retry`, async ({ page, request }, testInfo) => {
const recovery = await staleSourceRecovery(page, request, roots);
try {
expect(recovery.pages.map(url => url.search)).toEqual([]);expect(recovery.inspections).toEqual([]);
await expect(recovery.source).toHaveValue('all');await expect(recovery.source.locator('option')).toHaveText(['All sources']);await expect(recovery.folder).toHaveValue('');
for (const name of ['Source', 'Folder', 'Media', 'Sort']) await expect(page.getByRole('combobox', { name, exact: true })).toBeDisabled();
await expect(page.getByRole('searchbox', { name: 'Search filenames', exact: true })).toBeDisabled();
await expect(page.getByRole('button', { name: 'Load more images', exact: true })).toBeDisabled();
await page.getByRole('button', { name: 'Load more images', exact: true }).evaluate(node => node.click());await recovery.source.dispatchEvent('change');
await recovery.options[0].fulfill({ status: 503, json: { success: false, error: 'Discovery unavailable' } });
await expect(page.getByRole('alert').filter({ hasText: 'Sources and folders could not be loaded.' })).toBeVisible();
expect(recovery.pages).toEqual([]);expect(recovery.inspections).toEqual([]);await expect(cards(page)).toHaveCount(0);
const retry = details(page).getByRole('button', { name: 'Refresh image collection', exact: true });await expect(retry).toBeEnabled();
if (testInfo.project.name === 'chromium' && name === 'reordered') await page.screenshot({ path: testInfo.outputPath('safe-source-discovery-failure.png') });
await retry.click();await expect.poll(() => recovery.options.length).toBe(2);
expect(new URL(recovery.options[1].request().url()).searchParams.has('root')).toBe(false);
const response = await recovery.options[1].fetch();await recovery.options[1].fulfill({ response });
await expect.poll(() => recovery.pages.length).toBe(1);
const query = recovery.pages[0].searchParams;expect(query.has('root')).toBe(false);expect(query.has('subfolder')).toBe(false);
expect(query.get('q')).toBe('png');expect(query.get('media')).toBe('image');expect(query.get('sort')).toBe('name');
await expect(recovery.source).toBeEnabled();await expect(recovery.source).toHaveValue('all');
await expect(recovery.source.locator('option')).toHaveText(['All sources', ...roots]);
await expect.poll(() => recovery.inspections.length).toBe(1);expect(recovery.inspections[0].searchParams.get('root')).toBe(name === 'reordered' ? '1' : '0');
expect(recovery.mutations).toEqual([]);
if (testInfo.project.name === 'chromium' && name === 'reordered') await page.screenshot({ path: testInfo.outputPath('safe-source-recovery-success.png') });
} finally { await expect(await request.post('/prompt_manager/settings', { data: { gallery_root_paths: ['output', 'input'] } })).toBeOK(); }
});
}
test('source-change recovery ignores superseded discovery and route disposal', async ({ page, request }) => {
const recovery = await staleSourceRecovery(page, request, ['input', 'output']);
try {
expect(recovery.pages).toEqual([]);await details(page).getByRole('button', { name: 'Refresh image collection', exact: true }).click();await expect.poll(() => recovery.options.length).toBe(2);
await recovery.options[0].fulfill({ json: { roots: [{ index: 0, label: 'Obsolete source label' }], subfolders: ['obsolete'] } });
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
expect(recovery.pages).toEqual([]);await expect(recovery.source.locator('option')).toHaveText(['All sources']);
await page.getByRole('navigation', { name: 'Workspace', exact: true }).getByRole('link', { name: 'Overview', exact: true }).click();
await recovery.options[1].fulfill({ json: { roots: [{ index: 0, label: 'Late source label' }], subfolders: ['late'] } });
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');expect(recovery.pages).toEqual([]);expect(recovery.inspections).toEqual([]);expect(recovery.mutations).toEqual([]);
} finally { await expect(await request.post('/prompt_manager/settings', { data: { gallery_root_paths: ['output', 'input'] } })).toBeOK(); }
});
test('mobile Images keeps filters, layouts and selected details within the viewport', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
for (const layout of ['List', 'Masonry', 'Grid']) {
await page.getByRole('button', { name: layout, exact: true }).click();
await cards(page).last().getByRole('button', { name: 'Open same.png', exact: true }).click();
await expect(details(page)).toContainText(/input/i);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
}
await expect(page.getByRole('searchbox', { name: 'Search filenames', exact: true })).toBeVisible();
});
test('a delayed search cannot replace results for a newer filename query', async ({ page, request }) => {
await expect(await request.post('/__test__/reset', { data: { extra_images: 3 } })).toBeOK();
await page.goto(workspace);
await expect(cards(page)).toHaveCount(5);
let release;
let arrived;
let complete;
const pending = new Promise(resolve => { release = resolve; });
const received = new Promise(resolve => { arrived = resolve; });
const completed = new Promise(resolve => { complete = resolve; });
let delayed = false;
await page.route('**/prompt_manager/images/output?*', async route => {
if (delayed) return route.continue();
delayed = true;
const response = await route.fetch();
arrived();
await pending;
await route.fulfill({ response }).catch(() => {});
complete();
});
const search = page.getByRole('searchbox', { name: 'Search filenames', exact: true });
await search.fill('same.png');
await received;
await search.fill('0001');
await expect(cards(page)).toHaveCount(1);
await expect(cards(page)).toHaveAttribute('data-image-key', '0:bulk/0001.png');
release();
await completed;
await expect(cards(page)).toHaveCount(1);
await expect(cards(page)).toHaveAttribute('data-image-key', '0:bulk/0001.png');
});
test('selected layout stays blue with readable text on hover in either theme', async ({ page }) => {
await page.goto(workspace);
const layout = page.getByRole('group', { name: 'Image layout' }).getByRole('button', { name: 'Masonry', exact: true });
for (const theme of ['light', 'dark']) {
await page.getByRole('combobox', { name: 'Appearance', exact: true }).selectOption(theme);
await layout.click();
await layout.hover();
await expect(layout).toHaveCSS('background-color', 'rgb(8, 119, 187)');
await expect(layout).toHaveCSS('color', 'rgb(255, 255, 255)');
}
});
test('refresh releases the previous inspector video source', async ({ page, request }) => {
await expect(await request.post('/__test__/reset', { data: { include_video: true } })).toBeOK();
await page.goto(workspace);
const card = cards(page).filter({ hasText: 'clip.webm' });
await card.getByRole('button', { name: 'Open clip.webm', exact: true }).click();
await expect(details(page).locator('video')).toBeVisible();
await details(page).locator('video').evaluate(element => { window.__oldInspectorVideo = element; });
await page.getByRole('button', { name: 'Refresh', exact: true }).click();
await expect.poll(() => page.evaluate(() => !window.__oldInspectorVideo.isConnected)).toBe(true);
expect(await page.evaluate(() => window.__oldInspectorVideo.getAttribute('src'))).toBeNull();
});
test('Refresh rescans cached files and preserves the collection when rescan fails', async ({ page, request }) => {
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
await expect(await request.post('/__test__/add-nested-image')).toBeOK();
await page.route('**/prompt_manager/gallery/rescan', route => route.fulfill({
status: 503, json: { success: false, error: 'Rescan unavailable' },
}), { times: 1 });
await page.getByRole('button', { name: 'Refresh', exact: true }).click();
await expect(page.getByRole('alert')).toContainText('could not be refreshed');
await expect(cards(page)).toHaveCount(2);
await page.getByRole('button', { name: 'Refresh', exact: true }).click();
await expect(cards(page)).toHaveCount(3);
await expect(cards(page).filter({ hasText: 'new-output.png' })).toHaveCount(1);
await expect(page.getByRole('alert')).toBeHidden();
});
test('folder discovery offers ancestors that filter nested images', async ({ page, request }) => {
await expect(await request.post('/__test__/add-nested-image')).toBeOK();
await page.goto(workspace);
await expect(cards(page)).toHaveCount(3);
const folder = page.getByRole('combobox', { name: 'Folder', exact: true });
await expect(folder.locator('option[value="project"]')).toHaveCount(1);
await folder.selectOption('project');
await expect(cards(page)).toHaveCount(1);
await expect(cards(page)).toContainText('new-output.png');
});
test('leaving during Refresh cancels it without replacing the next workspace', async ({ page }) => {
let release;
const pending = new Promise(resolve => { release = resolve; });
await page.route('**/prompt_manager/gallery/rescan', async route => {
await pending;
await route.fulfill({ json: { success: true } }).catch(() => {});
});
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
const requested = page.waitForRequest('**/prompt_manager/gallery/rescan');
await page.getByRole('button', { name: 'Refresh', exact: true }).click();
await requested;
await page.getByRole('navigation', { name: 'Workspace', exact: true })
.getByRole('link', { name: 'Overview', exact: true }).click();
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
release();
await expect(page.getByRole('heading', { name: 'Continue creating', exact: true })).toBeVisible();
await expect(page.getByText('Images could not be refreshed. Try Refresh again.', { exact: true })).toHaveCount(0);
});
test('Images uses approved geometry while keeping real controls', async ({page,request}) => {
await request.post('/__test__/reset',{data:{workspace_visual:true}});
await page.setViewportSize({width:1600,height:1100});
await page.goto(workspace);
await expect(cards(page)).toHaveCount(24);
await expect(page.locator('.workspace-heading-copy > .intro')).toHaveCount(1);
await expect(details(page)).toHaveCSS('width','310px');
await expect(page.locator('.image-workspace')).toHaveCSS('column-gap','22px');
for (const label of ['Source','Folder','Media','Sort','Thumbnail size']) await expect(page.getByRole('combobox',{name:label,exact:true})).toBeVisible();
await expect(page.getByRole('searchbox',{name:'Search filenames',exact:true})).toHaveAttribute('placeholder',/filename or folder/);
await expect(page.getByRole('link',{name:'Group by positive prompt',exact:true})).toHaveAttribute('href','#/library');
const geometry = await cards(page).evaluateAll(nodes=>nodes.slice(0,4).map(node=>{
const box=node.getBoundingClientRect(),media=node.querySelector('.image-open').getBoundingClientRect();
return {x:box.x,y:box.y,right:box.right,bottom:box.bottom,width:box.width,mediaWidth:media.width,mediaHeight:media.height};
}));
expect(geometry[1].x-geometry[0].right).toBeCloseTo(13,0);
expect(geometry[3].y-geometry[0].bottom).toBeCloseTo(13,0);
expect(geometry[3].x).toBeCloseTo(geometry[0].x,0);
expect(geometry[0].mediaWidth/geometry[0].mediaHeight).toBeCloseTo(4/3,2);
expect((await page.locator('.image-toolbar').boundingBox()).y).toBeLessThan(geometry[0].y);
await expect(cards(page).first()).toHaveCSS('outline-style','solid');
const cover=details(page).getByRole('button',{name:'Open full preview',exact:true});
await expect(cover).toHaveCSS('height','150px');
await expect(cover.locator('img')).toBeVisible();await expect(cover.locator('img')).toHaveCSS('height','150px');
expect(await cover.evaluate(node=>node===node.parentElement.firstElementChild&&node.tagName==='BUTTON')).toBe(true);
await page.setViewportSize({width:390,height:844});
await expect.poll(()=>cards(page).nth(1).evaluate(node=>node.offsetTop)).toBe(0);
await expect.poll(()=>cards(page).nth(2).evaluate(node=>node.offsetTop)).toBeGreaterThan(0);
expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
});
test('density changes and resize preserve identity focus and selection', async ({page,request}) => {
await request.post('/__test__/reset',{data:{workspace_visual:true}});
await page.setViewportSize({width:1600,height:1100});await page.goto(workspace);
await expect(cards(page)).toHaveCount(24);
const first=cards(page).first();await first.evaluate(node=>{window.originalImageCard=node;});
await first.getByRole('button',{name:/^Open /}).click();
const key=await first.getAttribute('data-image-key');
let imageReads=0;page.on('request',request=>{if(request.url().includes('/images/output?')) imageReads++;});
for (const layout of ['Grid','List','Masonry']) for (const [density,columns] of [['comfortable',3],['large',2],['compact',4]]) {
await page.getByRole('button',{name:layout,exact:true}).click();
await page.getByRole('combobox',{name:'Thumbnail size',exact:true}).selectOption(density);
await first.getByRole('button',{name:/^Open /}).focus();await page.keyboard.press('ArrowDown');
await expect(cards(page).nth(layout==='List'?1:columns).getByRole('button',{name:/^Open /})).toBeFocused();
await expect(first).toHaveClass(/selected/);
expect(await first.evaluate(node=>node===window.originalImageCard)).toBe(true);
}
expect(imageReads).toBe(0);
await page.evaluate(()=>{location.hash='#/overview';});await expect(page.locator('h1')).toHaveText('Overview');
await page.evaluate(()=>{location.hash='#/images';});await expect(page.getByRole('combobox',{name:'Thumbnail size',exact:true})).toHaveValue('compact');
await expect(page.locator(`article[data-image-key="${key}"]`)).toHaveClass(/selected/);
await page.getByRole('button',{name:'Grid',exact:true}).click();
for (const [width,columns] of [[951,3],[950,3],[700,2],[390,2]]) {
await page.setViewportSize({width,height:844});
await expect.poll(()=>cards(page).nth(columns).evaluate(node=>node.offsetLeft)).toBe(0);
await first.getByRole('button',{name:/^Open /}).focus();await page.keyboard.press('ArrowDown');
await expect(cards(page).nth(columns).getByRole('button',{name:/^Open /})).toBeFocused();
expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
}
});
-443
View File
@@ -1,443 +0,0 @@
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
const route='/prompt_manager/workspace#/library';
const cards=page=>page.locator('article[data-prompt-id]');
const history=page=>page.getByRole('region',{name:'Prompt history',exact:true});
const saved=page=>history(page).getByRole('region',{name:'Saved prompt texts',exact:true});
const gallery=page=>page.getByRole('region',{name:'Shared gallery',exact:true});
const media=page=>gallery(page).locator('article[data-image-key]');
async function openHistory(page,text='Cat portrait') {
await cards(page).filter({has:page.getByText(text,{exact:true})}).getByRole('link',{name:'Explore history',exact:true}).click();
await expect(history(page)).toBeVisible();
}
async function selectVariant(page,index) {
const choice=history(page).getByRole('button',{name:`Select negative variant ${index+1}`,exact:true});
await choice.click();await expect(choice).toHaveAttribute('aria-pressed','true');
}
test.beforeEach(async({page,request,browserName})=>{
await startCoverage(page,browserName);await expect(await request.post('/__test__/reset',{data:{native_library:true}})).toBeOK();
page.libraryErrors=[];page.on('pageerror',error=>page.libraryErrors.push(error.message));
});
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.libraryErrors).toEqual([]);});
test('library groups reusable negatives under one positive without exposing negative-only components as parents',async({page})=>{
await page.goto(route);await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
await expect(cards(page)).toHaveCount(3);
await expect(cards(page).filter({has:page.getByText('Cat portrait',{exact:true})})).toHaveCount(1);
await expect(cards(page).filter({has:page.getByText('Dual role portrait',{exact:true})})).toHaveCount(1);
await expect(cards(page).filter({hasText:'low contrast'})).toHaveCount(0);
const cat=cards(page).filter({has:page.getByText('Cat portrait',{exact:true})});
await expect(cat).toContainText(/4.*variants/i);await expect(cat).toContainText(/3.*images/i);
await expect(page.locator('#workspace-main')).not.toContainText(/\b\d+ (?:tracked )?runs\b/i);
});
test('history displays the selected full negative beneath its positive and filters one shared physical gallery',async({page,request})=>{
const rows=await(await request.get('/prompt_manager/library')).json();const cat=rows.items.find(item=>item.text==='Cat portrait');
const data=await(await request.get(`/prompt_manager/library/${cat.id}`)).json();
await page.goto(route);await openHistory(page);await expect(media(page)).toHaveCount(3);
for(const negative of ['blurry, watermark','low quality','low contrast','']) {
const index=data.variants.findIndex(variant=>variant.negative_text===negative);await selectVariant(page,index);
const neg=saved(page).getByRole('region',{name:'Negative prompt',exact:true});
await expect(neg).toContainText(negative||'Empty negative prompt');
await expect(media(page)).toHaveCount(negative==='blurry, watermark'||negative==='low quality'?1:0);
const positive=saved(page).getByRole('region',{name:'Positive prompt',exact:true});
await expect(positive).toContainText('Cat portrait');
const order=await positive.evaluate((element)=>element.compareDocumentPosition(element.parentElement.querySelector('[aria-label="Negative prompt"]')));
expect(order&4).toBeTruthy();
}
await history(page).getByRole('button',{name:'All images for this positive',exact:true}).click();await expect(media(page)).toHaveCount(3);
expect(await media(page).evaluateAll(nodes=>nodes.map(node=>node.dataset.imageKey))).toEqual(expect.arrayContaining(['0:shared/same.png','1:shared/same.png','0:legacy-unassigned.png']));
});
test('search, category and tag filters preserve their constraints and return standalone positives',async({page})=>{
await page.goto(route);await page.getByRole('searchbox',{name:'Search prompts',exact:true}).fill('Dog landscape');
await expect(cards(page)).toHaveCount(1);await expect(cards(page).first()).toContainText('Dog landscape');
await page.getByRole('searchbox',{name:'Search prompts',exact:true}).fill('');
await page.getByRole('combobox',{name:'Category',exact:true}).selectOption('Portraits');
await page.getByRole('textbox',{name:'Tags',exact:true}).fill('studio');await expect(cards(page)).toHaveCount(1);
await page.getByRole('textbox',{name:'Tags',exact:true}).fill('outdoors');await expect(cards(page)).toHaveCount(0);
await page.getByRole('combobox',{name:'Category',exact:true}).selectOption('');await expect(cards(page)).toHaveCount(1);
});
test('library infinite loading keeps unique parents and keyboard-accessible history controls',async({page,request})=>{
await expect(await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:125}})).toBeOK();
await page.addInitScript(()=>{window.IntersectionObserver=undefined;});
await page.goto(route);await expect(cards(page).first()).toBeVisible();
const more=page.getByRole('button',{name:'Load more prompts',exact:true});
expect(await cards(page).count()).toBeLessThan(128);
let hasMore=true;
while(hasMore) {
const nextPage=page.waitForResponse(response=>{const url=new URL(response.url());return url.pathname==='/prompt_manager/library'&&Boolean(url.searchParams.get('cursor'));});
await more.focus();await page.keyboard.press('Enter');
const appended=await (await nextPage).json();expect(appended.items.length).toBeGreaterThan(0);hasMore=appended.has_more;
if(hasMore)await expect(more).toHaveAttribute('aria-disabled','false');
}
await expect(more).toBeHidden();expect(await cards(page).count()).toBeLessThan(80);
const ids=await cards(page).evaluateAll(nodes=>nodes.map(node=>node.dataset.promptId));expect(new Set(ids).size).toBe(ids.length);
await expect(page.getByRole('button',{name:/next page|previous page/i})).toHaveCount(0);
const firstId=await cards(page).first().getAttribute('data-prompt-id');
const explore=page.locator(`article[data-prompt-id="${firstId}"]`).getByRole('link',{name:'Explore history',exact:true});
await explore.focus();await expect(explore).toBeFocused();await page.keyboard.press('Enter');await expect(history(page)).toBeVisible();
});
test('failed library reads retry while late search responses cannot overwrite newer results',async({page})=>{
let fail=true;let release;const hold=new Promise(resolve=>{release=resolve;});
await page.route('**/prompt_manager/library?*',async request=>{
const url=new URL(request.request().url());
if(fail)return request.fulfill({status:503,json:{success:false,error:'Unavailable'}});
if(url.searchParams.get('text')==='Cat')await hold;
await request.continue().catch(()=>{});
});
await page.goto(route);await expect(page.getByRole('button',{name:'Retry prompt library',exact:true})).toBeVisible();
fail=false;await page.getByRole('button',{name:'Retry prompt library',exact:true}).click();await expect(cards(page)).toHaveCount(3);
const search=page.getByRole('searchbox',{name:'Search prompts',exact:true});const oldRequest=page.waitForRequest(request=>new URL(request.url()).searchParams.get('text')==='Cat');await search.fill('Cat');await oldRequest;await search.fill('Dog');
await expect(cards(page)).toHaveCount(1);await expect(cards(page).first()).toContainText('Dog landscape');release();await expect(cards(page).first()).toContainText('Dog landscape');
});
test('refresh atomically discards old row geometry and renders changed parent data',async({page,request})=>{
await page.goto(route);await expect(cards(page)).toHaveCount(3);
const card=cards(page).filter({has:page.getByText('Dog landscape',{exact:true})});const id=await card.getAttribute('data-prompt-id');
await expect(await request.put(`/prompt_manager/prompts/${id}`,{data:{text:'Revised dog landscape'}})).toBeOK();
await expect(await request.put(`/prompt_manager/prompts/${id}/rating`,{data:{rating:5}})).toBeOK();
await page.evaluate(()=>{
const native=requestAnimationFrame;const held=[];let paused=true;window.releaseLibraryFrames=()=>{paused=false;window.requestAnimationFrame=native;for(const callback of held)callback();};
window.requestAnimationFrame=callback=>native(time=>{if(paused)held.push(()=>callback(time));else callback(time);});
});
try {
await page.getByRole('button',{name:'Refresh library',exact:true}).click();
await expect(page.locator('.library-status')).not.toHaveText('Loading prompt library…');
} finally {await page.evaluate(()=>window.releaseLibraryFrames());}
await expect(cards(page)).toHaveCount(3);
const changed=page.locator(`article[data-prompt-id="${id}"]`);await expect(changed).toContainText('Revised dog landscape');
await expect(changed.getByRole('img',{name:'Rated 5 out of 5',exact:true})).toHaveCount(1);
await expect(page.getByRole('button',{name:'Retry prompt library',exact:true})).toBeHidden();
});
test('returning from history restores library context and mobile content stays inside the viewport',async({page})=>{
await page.setViewportSize({width:390,height:844});await page.goto(route);
await page.getByRole('combobox',{name:'Category',exact:true}).selectOption('Animals');
await page.getByRole('searchbox',{name:'Search prompts',exact:true}).fill('Cat portrait');await expect(cards(page)).toHaveCount(1);
await openHistory(page);await selectVariant(page,0);
await expect(saved(page).getByRole('region',{name:'Positive prompt',exact:true})).toContainText('Cat portrait');
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);
await history(page).getByRole('link',{name:'Back to library',exact:true}).click();
await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('Cat portrait');await expect(cards(page)).toHaveCount(1);
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Overview',exact:true}).click();
await expect(history(page)).toHaveCount(0);await expect(page.locator('dialog[open]')).toHaveCount(0);
});
test('empty shelf visits restore saved scroll without inventing a parent anchor',async({page})=>{
await page.setViewportSize({width:1600,height:300});await page.goto(route);
await page.getByRole('searchbox',{name:'Search prompts',exact:true}).fill('No matching saved positive');
await expect(cards(page)).toHaveCount(0);await expect(page.locator('.library-status')).toContainText('No matching prompts.');
await page.evaluate(()=>scrollTo(0,document.documentElement.scrollHeight));
await expect.poll(()=>page.evaluate(()=>scrollY)).toBeGreaterThan(0);
const position=await page.evaluate(()=>scrollY);
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Overview',exact:true}).evaluate(node=>node.click());
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Prompt library',exact:true}).evaluate(node=>node.click());
await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('No matching saved positive');
await expect(cards(page)).toHaveCount(0);
await expect.poll(()=>page.evaluate(expected=>Math.abs(scrollY-expected),position)).toBeLessThanOrEqual(1);
const search=page.getByRole('searchbox',{name:'Search prompts',exact:true});await search.focus();await search.fill('Dog landscape');
await expect(cards(page)).toHaveCount(1);await expect(search).toBeFocused();
await page.evaluate(()=>scrollTo(0,0));await page.getByRole('button',{name:'Refresh library',exact:true}).click();await expect(cards(page)).toHaveCount(1);
expect(await page.evaluate(()=>scrollY)).toBe(0);
const anchor=await firstVisibleParent(page);expect(anchor).not.toBeNull();
await cards(page).getByRole('link',{name:'Explore history',exact:true}).evaluate(node=>node.click());await expect(history(page)).toBeVisible();
await page.setViewportSize({width:390,height:844});await page.getByRole('link',{name:'Back to library',exact:true}).click();
await retainedParent(page,anchor);await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('Dog landscape');
});
test('history detail failures retry and returning to the list ignores late detail responses',async({page})=>{
let fail=true;let release;const hold=new Promise(resolve=>{release=resolve;});
await page.route(/\/prompt_manager\/library\/\d+(?:\?|$)/,async route=>{
if(fail)return route.fulfill({status:503,json:{success:false,error:'Unavailable'}});
await hold;await route.continue().catch(()=>{});
});
try {
await page.goto(route);await cards(page).filter({has:page.getByText('Cat portrait',{exact:true})}).getByRole('link',{name:'Explore history',exact:true}).click();
await expect(page.getByRole('button',{name:'Retry prompt history',exact:true})).toBeVisible();
fail=false;await page.getByRole('button',{name:'Retry prompt history',exact:true}).click();
await page.getByRole('link',{name:'Back to library',exact:true}).click();release();
await expect(cards(page)).toHaveCount(3);await expect(history(page)).toHaveCount(0);
} finally {release();}
});
test('history shares real image metadata, vendored Viewer and the acknowledged prompt sender',async({page,context,request})=>{
const host=await context.newPage();await host.goto('/prompt_manager/workspace#/overview');
await host.evaluate(async()=>{
const {createBridgeHost}=await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');
const target={id:'pair-1',label:'Library destination',type:'PromptManagerPair',roles:['positive','negative']};
window.libraryApplied=[];
window.libraryHost=createBridgeHost({origin:location.origin,namespace:'/',label:'Library ComfyUI',adapter:{
listTargets:()=>[target],preview:(_id,roles)=>({preview:{target,fields:Object.entries(roles).map(([role,after])=>({role,before:`Previous ${role}`,after})),composition:{prepend:'',append:'',prependConnected:false,appendConnected:false,before:'Previous positive',after:roles.positive??'Previous positive'}},snapshot:roles}),
apply:roles=>{window.libraryApplied.push(roles);return{targetId:target.id,values:roles};},
}});
});
try {
await page.goto(route);
const parentId=await cards(page).filter({has:page.getByText('Cat portrait',{exact:true})}).getAttribute('data-prompt-id');
const recipe=await(await request.get(`/prompt_manager/library/${parentId}`)).json();
await openHistory(page);await selectVariant(page,recipe.variants.findIndex(variant=>variant.negative_text==='blurry, watermark'));
const chosenNegative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre').textContent();
await saved(page).getByRole('button',{name:'Send prompt pair',exact:true}).click();
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
const hosts=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});
await expect(hosts.locator('option')).toHaveCount(2);await hosts.selectOption({label:'Library ComfyUI'});
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'Library destination'});
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();await expect(sender).toContainText(chosenNegative||'(empty)');
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
expect(await host.evaluate(()=>window.libraryApplied)).toEqual([{positive:'Cat portrait',negative:chosenNegative}]);
await sender.getByRole('button',{name:'Close',exact:true}).click();
await media(page).first().getByRole('button',{name:'Open same.png',exact:true}).click();
const inspector=page.getByRole('region',{name:'Image details',exact:true});await expect(inspector.getByRole('region',{name:'Positive prompt',exact:true})).toContainText('Cat portrait');
await expect(inspector.locator('.generation-facts dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);
await expect(inspector.locator('summary')).toHaveText(['File & provenance','Workflow & raw metadata']);
await inspector.getByRole('button',{name:'Open full preview',exact:true}).click();await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible();
await page.keyboard.press('Escape');await expect(page.locator('dialog[open]')).toHaveCount(0);
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Overview',exact:true}).click();await expect(page.locator('.viewer-container')).toHaveCount(0);
} finally {await host.evaluate(()=>window.libraryHost.dispose());await host.close();}
});
test('large variant histories append bounded pages without replacing the selected negative',async({page,request})=>{
await expect(await request.post('/__test__/reset',{data:{native_library:true,extra_variants:41}})).toBeOK();
await page.goto(route);await openHistory(page);
const variants=history(page).getByRole('button',{name:/^Select negative variant \d+$/});await expect(variants).toHaveCount(40);
await selectVariant(page,0);const chosenNegative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre').textContent();
await history(page).getByRole('button',{name:'Load more negative variants',exact:true}).click();await expect(variants).toHaveCount(45);
await expect(history(page).getByRole('button',{name:'Select negative variant 1',exact:true})).toHaveAttribute('aria-pressed','true');
await expect(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(chosenNegative);
});
async function observeLibrary(page, hold=false) {
await page.addInitScript(({hold})=>{
const NativeResizeObserver=window.ResizeObserver;
const records=[];let holding=hold;
const raf=window.requestAnimationFrame.bind(window),cancel=window.cancelAnimationFrame.bind(window);
const pending=new Set(),heldFrames=new Map();let requested=0,mutations=0,unmounted=0,holdingFrames=false;
window.requestAnimationFrame=callback=>{requested++;const id=raf(time=>{const deliver=()=>{pending.delete(id);callback(time);};if(holdingFrames)heldFrames.set(id,deliver);else deliver();});pending.add(id);return id;};
window.cancelAnimationFrame=id=>{pending.delete(id);heldFrames.delete(id);cancel(id);};
window.ResizeObserver=class {
constructor(callback) {
const record={callback,targets:new Set(),entries:new Map(),latest:new Map(),disconnected:false};
record.native=new NativeResizeObserver(entries=>{
const ready=[];
for(const entry of entries) {
if(entry.target.matches('.library-card')) {
if(!entry.target.isConnected)unmounted++;
record.latest.set(entry.target,entry);
if(holding) {record.entries.set(entry.target,entry);continue;}
}
ready.push(entry);
}
if(ready.length)callback(ready,this);
});records.push(record);this.record=record;
}
observe(target,options){this.record.targets.add(target);this.record.native.observe(target,options);}
unobserve(target){this.record.targets.delete(target);this.record.entries.delete(target);this.record.latest.delete(target);this.record.native.unobserve(target);}
disconnect(){this.record.disconnected=true;this.record.targets.clear();this.record.entries.clear();this.record.native.disconnect();}
};
new MutationObserver(entries=>{mutations+=entries.filter(entry=>entry.target.matches('.library-card,.library-row,.library-row-spacer')).length;}).observe(document,{subtree:true,attributes:true,attributeFilter:['style']});
window.libraryObservation={
hold(){holding=true;},
release(){holding=false;for(const record of records){const entries=[...record.entries.values()].filter(entry=>record.targets.has(entry.target));record.entries.clear();if(entries.length)record.callback(entries,record.native);}},
holdFrames(){holdingFrames=true;},
releaseFrames(){holdingFrames=false;const callbacks=[...heldFrames.values()];heldFrames.clear();for(const callback of callbacks)callback();},
equal(){for(const record of records){const entries=[...record.latest.values()].filter(entry=>record.targets.has(entry.target));if(entries.length)record.callback(entries,record.native);}},
stats(){return {requested,pending:pending.size,heldFrames:heldFrames.size,measurements:records.reduce((sum,record)=>sum+record.entries.size,0),mutations,unmounted,targets:records.reduce((sum,record)=>sum+record.targets.size,0)};},
};
},{hold});
}
async function settleLibrary(page) {
await expect.poll(async()=>page.evaluate(()=>window.libraryObservation.stats().pending)).toBe(0);
await page.evaluate(()=>new Promise(resolve=>setTimeout(resolve,60)));
await expect.poll(async()=>page.evaluate(()=>window.libraryObservation.stats().pending)).toBe(0);
}
async function firstVisibleParent(page) {
return cards(page).evaluateAll(nodes=>{
const card=nodes.find(node=>{const box=node.getBoundingClientRect();return box.bottom>0&&box.top<innerHeight;});
return card?{id:card.dataset.promptId,offset:card.getBoundingClientRect().top}:null;
});
}
async function retainedParent(page, anchor) {
const card=page.locator(`article[data-prompt-id="${anchor.id}"]`);
await expect(card).toHaveCount(1);
await expect.poll(async()=>Math.abs(await card.evaluate(node=>node.getBoundingClientRect().top)-anchor.offset)).toBeLessThanOrEqual(2);
}
test('Library natural rows retain long prompt content without overflow',async({page,request})=>{
await request.post('/__test__/reset',{data:{workspace_visual:true}});
const data=await(await request.get('/prompt_manager/library')).json();
for(const [width,height] of [[1600,1100],[390,844]]) {
await page.setViewportSize({width,height});await page.goto(route);await expect(cards(page)).toHaveCount(6);
expect(await cards(page).filter({hasText:'A study in very long creative context'}).evaluate(node=>node.getBoundingClientRect().height)).toBeGreaterThan(560);
await expect(page.locator('.workspace-heading-copy > .intro')).toContainText('One positive.');
await expect(page.locator('.library-shelf > .intro')).toHaveCount(0);
for(const theme of ['dark','light']) {
await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption(theme);
await expect(page.locator('.prompt-positive h3').first()).toHaveCSS('color',theme==='dark'?'rgb(115, 221, 169)':'rgb(23, 107, 70)');
}
await expect(page.locator('.library-card-art').first()).toHaveCSS('height',width===1600?'170px':'150px');
await expect(page.locator('.library-card-content').first()).toHaveCSS('padding',width===1600?'18px':'15px');
await expect(page.locator('.library-grid')).toHaveCSS('gap','18px');
const long=cards(page).filter({hasText:'A study in very long creative context'});
const prompt=long.locator('.prompt-text');expect((await prompt.textContent()).length).toBeGreaterThan(1500);
expect(await long.evaluate(node=>node.getBoundingClientRect().height)).toBeGreaterThan(560);
for(const selector of ['.library-card-title','.library-card-prompts','.prompt-text','.library-card-content > .library-tags']) {
expect(await long.locator(selector).evaluate(node=>node.scrollHeight<=node.clientHeight+1)).toBe(true);
}
await prompt.focus();await expect(prompt).toBeFocused();
await long.getByRole('link',{name:'Explore history',exact:true}).scrollIntoViewIfNeeded();
await expect(long.getByRole('link',{name:'Explore history',exact:true})).toBeVisible();
for(const item of data.items) {
const card=page.locator(`article[data-prompt-id="${item.id}"]`);
await expect(card.getByRole('img',{name:item.rating?`Rated ${item.rating} out of 5`:'Unrated',exact:true})).toHaveCount(1);
await expect(card.locator('.library-card-content > .library-tags .library-chip')).toHaveText(item.tags);
}
expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
await expect(page.locator('.library-shelf')).not.toContainText(/\b\d+ (?:tracked )?runs\b|Used today/i);
const boxes=await cards(page).evaluateAll(nodes=>nodes.map(node=>{const b=node.getBoundingClientRect();return {x:b.x,y:b.y,right:b.right,bottom:b.bottom};}));
for(let i=0;i<boxes.length;i++) for(const b of boxes.slice(i+1)) {const a=boxes[i];expect(a.right<=b.x+1||b.right<=a.x+1||a.bottom<=b.y+1||b.bottom<=a.y+1).toBe(true);}
}
});
test('row measurement preserves anchor after width invalidation and append',async({page,request})=>{
await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:200}});
await observeLibrary(page,true);await page.addInitScript(()=>{window.IntersectionObserver=undefined;});
let release;const held=new Promise(resolve=>{release=resolve;});
await page.route('**/images/serve/**',async route=>{await held;await route.continue().catch(()=>{});});
try {
await page.setViewportSize({width:1600,height:1100});await page.goto(route);
const more=page.getByRole('button',{name:'Load more prompts',exact:true});
for(const count of [40,80,120]) {await expect(page.locator('.library-caption')).toContainText(`${count} of 203`);await more.evaluate(node=>node.click());}
await expect(page.locator('.library-caption')).toContainText('160 of 203');
await page.evaluate(()=>scrollTo(0,document.querySelector('.library-grid').getBoundingClientRect().top+scrollY+40*486+25));
await settleLibrary(page);const anchor=await firstVisibleParent(page);expect(anchor).not.toBeNull();
const pinned=cards(page).first();const pinnedId=await pinned.getAttribute('data-prompt-id');await pinned.locator('a').first().evaluate(node=>node.focus({preventScroll:true}));
await page.evaluate(()=>window.libraryObservation.release());await settleLibrary(page);await retainedParent(page,anchor);
expect(await cards(page).count()).toBeLessThan(80);await expect(page.locator(`article[data-prompt-id="${pinnedId}"] a`).first()).toBeFocused();
const after=await firstVisibleParent(page);release();await page.evaluate(async()=>{await Promise.all([...document.querySelectorAll('.library-card img')].map(image=>image.decode().catch(()=>{})));});await settleLibrary(page);await retainedParent(page,after);
await more.evaluate(node=>node.click());await expect(page.locator('.library-caption')).toContainText('200 of 203');await settleLibrary(page);await retainedParent(page,after);
await expect(page.locator(`article[data-prompt-id="${pinnedId}"] a`).first()).toBeFocused();
await page.setViewportSize({width:390,height:844});await settleLibrary(page);await retainedParent(page,after);
await expect(page.locator(`article[data-prompt-id="${pinnedId}"] a`).first()).toBeFocused();
expect(await cards(page).count()).toBeLessThan(80);
const ids=await cards(page).evaluateAll(nodes=>nodes.map(node=>node.dataset.promptId));expect(new Set(ids).size).toBe(ids.length);
for(const changedWidth of [false,true]) {
const before=await firstVisibleParent(page);
await page.locator(`article[data-prompt-id="${before.id}"] .library-history-link`).evaluate(node=>node.click());await expect(history(page)).toBeVisible();
if(changedWidth)await page.setViewportSize({width:1600,height:1100});
await page.getByRole('link',{name:'Back to library',exact:true}).click();await settleLibrary(page);await retainedParent(page,before);
expect(await cards(page).count()).toBeLessThan(80);
}
expect((await page.evaluate(()=>window.libraryObservation.stats())).unmounted).toBe(0);
} finally {release();}
});
test('queued row growth preserves newer native wheel movement and correction events',async({page,request},testInfo)=>{
await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:200}});
await observeLibrary(page);await page.addInitScript(()=>{window.IntersectionObserver=undefined;});
await page.setViewportSize({width:1600,height:1100});await page.goto(route);
const more=page.getByRole('button',{name:'Load more prompts',exact:true});
for(const count of [40,80,120]) {await expect(page.locator('.library-caption')).toContainText(`${count} of 203`);await more.evaluate(node=>node.click());}
await expect(page.locator('.library-caption')).toContainText('160 of 203');
await page.evaluate(()=>scrollTo(0,document.querySelector('.library-grid').getBoundingClientRect().top+scrollY+20*486+25));
await settleLibrary(page);await page.mouse.move(1100,550);
const pinned=cards(page).first(),pinnedId=await pinned.getAttribute('data-prompt-id');await pinned.locator('a').first().evaluate(node=>node.focus({preventScroll:true}));
try {
// The same queued-frame wheel sequence is stable without changed measurements.
const control=await firstVisibleParent(page),controlY=await page.evaluate(()=>scrollY);
await page.evaluate(()=>window.libraryObservation.holdFrames());await page.mouse.wheel(0,160);
await expect.poll(()=>page.evaluate(()=>scrollY)).toBe(controlY+160);
await expect.poll(()=>page.evaluate(()=>window.libraryObservation.stats().heldFrames)).toBeGreaterThan(0);
await page.evaluate(()=>window.libraryObservation.releaseFrames());await settleLibrary(page);
expect(await page.evaluate(()=>scrollY)).toBe(controlY+160);await retainedParent(page,{...control,offset:control.offset-160});
const before=await firstVisibleParent(page),beforeY=await page.evaluate(()=>scrollY);
await page.evaluate(()=>{window.libraryObservation.hold();for(const content of document.querySelectorAll('.library-card-content'))content.style.paddingBlockEnd='45px';});
await expect.poll(()=>page.evaluate(()=>window.libraryObservation.stats().measurements)).toBeGreaterThan(0);
const parent=page.locator(`article[data-prompt-id="${before.id}"]`);
const growth=await parent.evaluate(node=>node.getBoundingClientRect().top)-before.offset;
expect(growth).toBeGreaterThan(0);expect(await page.evaluate(()=>scrollY)).toBe(beforeY);
await page.evaluate(()=>{window.libraryObservation.holdFrames();window.libraryObservation.release();});
await expect.poll(()=>page.evaluate(()=>window.libraryObservation.stats().heldFrames)).toBeGreaterThan(0);
await page.mouse.wheel(0,160);await expect.poll(()=>page.evaluate(()=>scrollY)).toBe(beforeY+160);
expect(await page.evaluate(()=>document.documentElement.scrollHeight-innerHeight-scrollY)).toBeGreaterThan(1000);
await page.evaluate(()=>window.libraryObservation.releaseFrames());await settleLibrary(page);
await testInfo.attach('queued-growth-wheel-geometry',{body:JSON.stringify({beforeY,offset:before.offset,growth,wheel:160,finalY:await page.evaluate(()=>scrollY),finalOffset:await parent.evaluate(node=>node.getBoundingClientRect().top)}),contentType:'application/json'});
await retainedParent(page,{...before,offset:before.offset-160});
expect(Math.abs(await page.evaluate(()=>scrollY)-(beforeY+160+growth))).toBeLessThanOrEqual(2);
// The correction's own native scroll event must not become new user motion.
const corrected=await firstVisibleParent(page);
await page.evaluate(()=>{window.libraryObservation.hold();for(const content of document.querySelectorAll('.library-card-content'))content.style.paddingBlockEnd='65px';});
await expect.poll(()=>page.evaluate(()=>window.libraryObservation.stats().measurements)).toBeGreaterThan(0);
await page.evaluate(()=>window.libraryObservation.release());await settleLibrary(page);await retainedParent(page,corrected);
await expect(page.locator(`article[data-prompt-id="${pinnedId}"] a`).first()).toBeFocused();
const ids=await cards(page).evaluateAll(nodes=>nodes.map(node=>node.dataset.promptId));expect(ids.length).toBeLessThan(80);expect(new Set(ids).size).toBe(ids.length);
await page.evaluate(()=>window.libraryObservation.holdFrames());await page.mouse.wheel(0,160);
await expect.poll(()=>page.evaluate(()=>window.libraryObservation.stats().heldFrames)).toBeGreaterThan(0);
await page.evaluate(()=>{location.hash='#/overview';});await expect(cards(page)).toHaveCount(0);
expect((await page.evaluate(()=>window.libraryObservation.stats())).targets).toBe(0);
expect((await page.evaluate(()=>window.libraryObservation.stats())).pending).toBe(0);
expect((await page.evaluate(()=>window.libraryObservation.stats())).heldFrames).toBe(0);
expect((await page.evaluate(()=>window.libraryObservation.stats())).unmounted).toBe(0);
} finally {await page.evaluate(()=>{window.libraryObservation.release();window.libraryObservation.releaseFrames();});}
});
test('unchanged row observations settle without resize feedback',async({page,request,browserName})=>{
await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:200}});
await observeLibrary(page);await page.addInitScript(()=>{window.IntersectionObserver=undefined;});
let session,breakpoint;
try {
if(browserName==='chromium') {
session=await page.context().newCDPSession(page);await session.send('Debugger.enable');
await page.addInitScript(()=>{window.libraryLayoutCalls=0;});
const scripts=[];session.on('Debugger.scriptParsed',script=>scripts.push(script));
await page.goto(route);await expect(cards(page).first()).toBeVisible();await settleLibrary(page);
const script=scripts.find(script=>script.url.endsWith('/workspace/library-layout.mjs'));expect(script).toBeTruthy();
const {scriptSource}=await session.send('Debugger.getScriptSource',{scriptId:script.scriptId});
const lines=scriptSource.split('\n');const start=lines.findIndex(line=>line.includes('export function layoutLibraryRows('));expect(start).toBeGreaterThanOrEqual(0);
const possible=await session.send('Debugger.getPossibleBreakpoints',{start:{scriptId:script.scriptId,lineNumber:start},end:{scriptId:script.scriptId,lineNumber:start+3},restrictToFunction:false});
const location=possible.locations.find(location=>location.lineNumber>start);expect(location).toBeTruthy();
breakpoint=(await session.send('Debugger.setBreakpoint',{location,condition:'(window.libraryLayoutCalls++, false)'})).breakpointId;
} else {await page.goto(route);await expect(cards(page).first()).toBeVisible();await settleLibrary(page);}
const more=page.getByRole('button',{name:'Load more prompts',exact:true});
for(const count of [40,80,120]) {await expect(page.locator('.library-caption')).toContainText(`${count} of 203`);await more.evaluate(node=>node.click());}
await expect(page.locator('.library-caption')).toContainText('160 of 203');await settleLibrary(page);
if(session)expect(await page.evaluate(()=>window.libraryLayoutCalls)).toBeGreaterThan(0);
const before=await page.evaluate(()=>({stats:window.libraryObservation.stats(),calls:window.libraryLayoutCalls}));
await page.evaluate(()=>{for(let i=0;i<12;i++){window.libraryObservation.equal();dispatchEvent(new Event('scroll'));}});await settleLibrary(page);
const after=await page.evaluate(()=>({stats:window.libraryObservation.stats(),calls:window.libraryLayoutCalls}));
expect(after.stats.requested-before.stats.requested).toBeLessThanOrEqual(1);expect(after.stats.mutations).toBe(before.stats.mutations);if(session)expect(after.calls).toBe(before.calls);
for(let i=0;i<3;i++) {await page.evaluate(()=>scrollBy(0,1));await settleLibrary(page);}
if(session)expect(await page.evaluate(()=>window.libraryLayoutCalls)).toBe(after.calls);
const equalBefore=await page.evaluate(()=>window.libraryObservation.stats().requested);await page.evaluate(()=>{for(let i=0;i<12;i++)window.libraryObservation.equal();});await settleLibrary(page);
expect((await page.evaluate(()=>window.libraryObservation.stats())).requested).toBe(equalBefore);
await page.evaluate(()=>{window.libraryObservation.hold();for(const content of document.querySelectorAll('.library-card-content'))content.style.paddingBlockEnd='35px';});await settleLibrary(page);
const calls=await page.evaluate(()=>window.libraryLayoutCalls);await page.evaluate(()=>window.libraryObservation.release());await settleLibrary(page);
if(session)expect(await page.evaluate(()=>window.libraryLayoutCalls)-calls).toBe(1);
await page.evaluate(()=>{dispatchEvent(new Event('scroll'));location.hash='#/overview';});await expect(cards(page)).toHaveCount(0);await settleLibrary(page);
expect((await page.evaluate(()=>window.libraryObservation.stats())).targets).toBe(0);
expect((await page.evaluate(()=>window.libraryObservation.stats())).pending).toBe(0);
expect((await page.evaluate(()=>window.libraryObservation.stats())).unmounted).toBe(0);
} finally {if(breakpoint)await session.send('Debugger.removeBreakpoint',{breakpointId:breakpoint});if(session)await session.detach();}
});
test('saved choices retain curved staggered lanes and the full negative immediately follows its positive',async({page,request})=>{
await page.goto(route);await openHistory(page);await selectVariant(page,1);
const positive=saved(page).getByRole('region',{name:'Positive prompt',exact:true});
expect(await positive.evaluate(node=>node.nextElementSibling?.getAttribute('aria-label'))).toBe('Negative prompt');
const values=await page.locator('.library-variant-lane').evaluateAll(nodes=>nodes.map(node=>({offset:parseFloat(getComputedStyle(node).marginInlineStart),radius:getComputedStyle(node,'::before').borderBottomLeftRadius})));
expect(values).toEqual([{offset:38,radius:'22px'},{offset:85,radius:'22px'},{offset:132,radius:'22px'},{offset:179,radius:'22px'}]);
const order=await page.locator('.library-history').evaluate(node=>[...node.querySelectorAll('section[aria-label]')].filter(section=>['Saved negative variants','Recorded executions','Shared gallery'].includes(section.getAttribute('aria-label'))).map(section=>section.getAttribute('aria-label')));
expect(order).toEqual(['Saved negative variants','Recorded executions','Shared gallery']);
const rows=await(await request.get('/prompt_manager/library')).json();const cat=rows.items.find(item=>item.text==='Cat portrait');
const details=await(await request.get(`/prompt_manager/library/${cat.id}`)).json();
const selected=details.variants[1].negative_text;
expect(await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre').evaluate(node=>node.textContent)).toBe(selected);
});

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