Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2b817781a | ||
|
|
2aa39ca1a6 | ||
|
|
ecd21165a7 | ||
|
|
10197026be | ||
|
|
ac23f22a37 | ||
|
|
d9537a726c | ||
|
|
e7cfcc2bfb | ||
|
|
4ef236aaf7 | ||
|
|
bae542c9ac | ||
|
|
763e6b37b1 | ||
|
|
fdba6fd012 | ||
|
|
dc345671ae | ||
|
|
76e9b51079 | ||
|
|
590aa2cd0e | ||
|
|
d4bb5c5ba9 |
@@ -18,7 +18,8 @@
|
||||
"black and flake8",
|
||||
"frontend modules",
|
||||
"viewerjs",
|
||||
"real ComfyUI compatibility"
|
||||
"real ComfyUI compatibility",
|
||||
"bandit (high severity)"
|
||||
]
|
||||
},
|
||||
"enforce_admins": true,
|
||||
|
||||
@@ -9,42 +9,15 @@ 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:
|
||||
@@ -58,5 +31,9 @@ jobs:
|
||||
- name: Install bandit
|
||||
run: pip install bandit[toml]
|
||||
|
||||
- name: Security scan
|
||||
run: bandit -r . -ll --exclude=./tests,./reference,./venv -q || true
|
||||
# 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
|
||||
|
||||
@@ -9,6 +9,12 @@ 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"
|
||||
|
||||
@@ -59,6 +65,9 @@ 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
|
||||
@@ -67,6 +76,13 @@ 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 }}
|
||||
@@ -87,6 +103,17 @@ 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
|
||||
@@ -148,5 +175,8 @@ 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
|
||||
|
||||
@@ -10,10 +10,17 @@ 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:
|
||||
viewerjs:
|
||||
frontend-coverage:
|
||||
name: frontend coverage (chromium and touch)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v7
|
||||
@@ -30,12 +37,10 @@ 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 firefox webkit
|
||||
- run: npx --prefix tools/vendor playwright install --with-deps chromium
|
||||
- name: Frontend unit, viewer, workflow, and coverage checks
|
||||
run: npm run --prefix tools/vendor test:coverage
|
||||
- name: Firefox and WebKit page workflows
|
||||
run: npm run --prefix tools/vendor test:workflows -- --project firefox --project webkit
|
||||
- name: Upload browser traces and frontend coverage
|
||||
- name: Upload frontend coverage and Chromium traces
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
@@ -45,3 +50,54 @@ 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
|
||||
|
||||
@@ -531,7 +531,7 @@ Keep your database optimized with built-in maintenance tools:
|
||||
|
||||
### Database Location
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
### Searching Prompts
|
||||
|
||||
@@ -773,6 +773,32 @@ 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:
|
||||
@@ -844,7 +870,7 @@ print(f"Average rating: {info['average_rating']}")
|
||||
# Create backup
|
||||
db.model.backup_database("backup_prompts.db")
|
||||
|
||||
# The database file can be copied directly for backup
|
||||
# The online backup includes committed data still in the WAL.
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
@@ -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 and SQLite | Inference is not queued; browser startup settings and host preference writes are isolated |
|
||||
| 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 |
|
||||
| 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,21 +99,51 @@ the baseline updater. Raise floors with `npm run --prefix tools/vendor
|
||||
coverage:update`; it reruns the suites and cannot lower an existing floor.
|
||||
Review baseline changes like code changes.
|
||||
|
||||
The `viewerjs` check runs on every PR, including backend-only changes. GitHub
|
||||
artifacts named `frontend-test-reports` contain HTML coverage, a JSON summary,
|
||||
and failure screenshots/traces. Open traces with Playwright's `show-trace` tool.
|
||||
The `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 real-host suite covers both Classic and Nodes 2.0 startup, switching both
|
||||
ways, actual database prompt selection, execution-text serialization, workflow
|
||||
reload, widget/style cleanup, node schemas and standalone-page opening. It does
|
||||
not queue inference or certify every existing feature, arbitrary host versions,
|
||||
not 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`, and `real ComfyUI compatibility`.
|
||||
combinations, Python coverage, formatting, frontend modules, `viewerjs`, `real ComfyUI compatibility`,
|
||||
and `bandit (high severity)`.
|
||||
The branch must be current with main, and administrators follow the same checks.
|
||||
It does not require an additional human approval.
|
||||
|
||||
@@ -129,24 +159,345 @@ 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 shell
|
||||
## Unified workspace
|
||||
|
||||
The opt-in `/prompt_manager/workspace` page uses the shipped toolkit and real
|
||||
`/prompt_manager/stats` API. Existing admin, gallery and metadata URLs remain
|
||||
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.
|
||||
They run automatically in the existing four-browser-project workflow and frontend
|
||||
coverage job. `tests/comfyui/workspace.spec.mjs` verifies the page against the
|
||||
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
|
||||
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.
|
||||
|
||||
@@ -87,6 +87,9 @@ 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
|
||||
@@ -158,6 +161,34 @@ 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
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
"""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
|
||||
@@ -0,0 +1,376 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,194 @@
|
||||
"""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
|
||||
@@ -0,0 +1,349 @@
|
||||
"""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
|
||||
@@ -0,0 +1,218 @@
|
||||
"""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
|
||||
@@ -0,0 +1,153 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,127 @@
|
||||
"""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
|
||||
@@ -0,0 +1,527 @@
|
||||
"""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
|
||||
)
|
||||
@@ -0,0 +1,321 @@
|
||||
"""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,
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import contextlib
|
||||
import datetime
|
||||
import json
|
||||
import math
|
||||
import shutil
|
||||
import sqlite3
|
||||
import os
|
||||
import threading
|
||||
@@ -16,6 +15,12 @@ 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,
|
||||
)
|
||||
|
||||
# Import logging system
|
||||
try:
|
||||
from ..utils.logging_config import get_logger
|
||||
@@ -119,7 +124,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 restore_from_file can close them all.
|
||||
# is also tracked here so close_all can clean up across threads.
|
||||
self._local = threading.local()
|
||||
self._connections: Set[sqlite3.Connection] = set()
|
||||
self._conn_lock = threading.Lock()
|
||||
@@ -274,6 +279,7 @@ 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")
|
||||
@@ -544,7 +550,7 @@ class PromptModel:
|
||||
if conn is not None and self._local.generation == self._generation:
|
||||
return conn
|
||||
|
||||
# check_same_thread=False only so restore_from_file can close other
|
||||
# check_same_thread=False only so close_all can clean up 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
|
||||
@@ -1037,6 +1043,9 @@ class PromptModel:
|
||||
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}"
|
||||
@@ -1051,29 +1060,17 @@ 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. 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.
|
||||
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.
|
||||
"""
|
||||
source = sqlite3.connect(
|
||||
Path(src_path).resolve().as_uri() + "?mode=ro", uri=True
|
||||
)
|
||||
try:
|
||||
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"
|
||||
)
|
||||
source.backup(self.get_connection())
|
||||
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:
|
||||
"""
|
||||
@@ -1084,6 +1081,8 @@ class PromptModel:
|
||||
``<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.
|
||||
|
||||
Args:
|
||||
src_path: SQLite file to restore
|
||||
@@ -1094,7 +1093,7 @@ class PromptModel:
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the previous database could not be backed up
|
||||
OSError: If the restored file could not be copied into place
|
||||
sqlite3.OperationalError: If SQLite refuses online replacement
|
||||
"""
|
||||
ok, reason = self.verify_database_file(src_path)
|
||||
if not ok:
|
||||
@@ -1112,6 +1111,7 @@ class PromptModel:
|
||||
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)
|
||||
@@ -1123,10 +1123,17 @@ class PromptModel:
|
||||
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}")
|
||||
self._replace_contents_from(staged_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
|
||||
|
||||
notify_history_restore(self.db_path)
|
||||
return backup_path
|
||||
finally:
|
||||
if staged is not None:
|
||||
|
||||
@@ -10,6 +10,11 @@ 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:
|
||||
@@ -85,7 +90,10 @@ def _resolve_db_path(db_path: Optional[str] = None) -> str:
|
||||
|
||||
if db_path is None:
|
||||
try:
|
||||
from py.config import PromptManagerConfig
|
||||
try:
|
||||
from ..py.config import PromptManagerConfig
|
||||
except ImportError:
|
||||
from py.config import PromptManagerConfig
|
||||
|
||||
db_path = PromptManagerConfig.DEFAULT_DB_PATH
|
||||
except Exception:
|
||||
@@ -147,7 +155,14 @@ def _coerce_file_info(metadata: Any) -> tuple:
|
||||
)
|
||||
|
||||
|
||||
class PromptDatabase(PairOperations):
|
||||
class PromptDatabase(
|
||||
PairOperations,
|
||||
LibraryOperations,
|
||||
ExecutionHistoryOperations,
|
||||
ExecutionContextOperations,
|
||||
ExecutionOutputOperations,
|
||||
HistoryReadOperations,
|
||||
):
|
||||
"""Database operations class for managing prompts."""
|
||||
|
||||
def __init__(self, db_path: Optional[str] = None):
|
||||
@@ -2001,12 +2016,18 @@ class PromptDatabase(PairOperations):
|
||||
return count
|
||||
|
||||
def get_image_prompt_info(
|
||||
self, image_path: str, allow_filename_fallback: bool = True
|
||||
self,
|
||||
image_path: str,
|
||||
allow_filename_fallback: bool = True,
|
||||
*,
|
||||
report_ambiguity: bool = False,
|
||||
) -> 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(
|
||||
@@ -2032,6 +2053,8 @@ class PromptDatabase(PairOperations):
|
||||
]
|
||||
matches = exact or rows
|
||||
if len(matches) != 1:
|
||||
if report_ambiguity and matches:
|
||||
return {"association_status": "ambiguous"}
|
||||
return None
|
||||
row = matches[0]
|
||||
associations = self.get_image_prompt_pairs(row[9])
|
||||
|
||||
@@ -4,6 +4,15 @@ 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
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
# Prompt Manager application redesign concept
|
||||
|
||||
Status: visual concept approved by the maintainer, 2026-10-17. Production
|
||||
implementation is pending; see the [development plan](../../application-redesign.md).
|
||||
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
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,34 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,49 @@
|
||||
# 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.
|
||||
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 47 KiB |
@@ -0,0 +1,13 @@
|
||||
# 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.
|
||||
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 231 KiB |
|
After Width: | Height: | Size: 231 KiB |
|
After Width: | Height: | Size: 54 KiB |
@@ -6,6 +6,11 @@ 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:
|
||||
@@ -256,7 +261,11 @@ class PromptManager(PromptManagerBase, ComfyNodeABC):
|
||||
)
|
||||
|
||||
self.logger.info(f"CLIP encoding completed, text: {repr(encoding_text)[:80]}")
|
||||
return (conditioning, encoding_text)
|
||||
result = (conditioning, encoding_text)
|
||||
record_runtime_context(
|
||||
type(self).__name__, text, prepend_text, append_text, encoding_text
|
||||
)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(
|
||||
|
||||
@@ -5,6 +5,11 @@ 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
|
||||
@@ -204,7 +209,19 @@ class PromptManagerPair(PromptManagerFocused):
|
||||
)
|
||||
except Exception as error:
|
||||
self.logger.warning(f"Could not register paired metadata: {error}")
|
||||
return positive_conditioning, negative_conditioning, positive, negative_text
|
||||
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(
|
||||
|
||||
@@ -6,6 +6,11 @@ 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:
|
||||
@@ -227,7 +232,11 @@ class PromptManagerText(PromptManagerBase, ComfyNodeABC):
|
||||
)
|
||||
|
||||
self.logger.debug(f"Text processing completed: {final_text[:100]}...")
|
||||
return (final_text,)
|
||||
result = (final_text,)
|
||||
record_runtime_context(
|
||||
type(self).__name__, text, prepend_text, append_text, final_text
|
||||
)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(
|
||||
|
||||
@@ -23,6 +23,8 @@ 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
|
||||
@@ -243,6 +245,8 @@ async def _gzip_middleware(request, handler):
|
||||
|
||||
class PromptManagerAPI(
|
||||
PairRoutesMixin,
|
||||
LibraryRoutesMixin,
|
||||
HistoryRoutesMixin,
|
||||
PromptRoutesMixin,
|
||||
ImageRoutesMixin,
|
||||
AdminRoutesMixin,
|
||||
@@ -271,6 +275,8 @@ class PromptManagerAPI(
|
||||
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._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)
|
||||
@@ -399,6 +405,8 @@ 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)
|
||||
|
||||
@@ -1248,9 +1248,24 @@ class AdminRoutesMixin:
|
||||
{"success": False, "error": reason}, status=400
|
||||
)
|
||||
|
||||
backup_path = await self._run_in_executor(
|
||||
model.restore_from_file, temp_path
|
||||
)
|
||||
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
|
||||
removed = await self._run_in_executor(_prune_safety_backups, model.db_path)
|
||||
if removed:
|
||||
self.logger.info(f"Removed {len(removed)} old safety backups")
|
||||
|
||||
@@ -0,0 +1,748 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,399 @@
|
||||
"""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()
|
||||
@@ -1,13 +1,17 @@
|
||||
"""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
|
||||
@@ -16,29 +20,23 @@ 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
|
||||
|
||||
# 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",
|
||||
}
|
||||
)
|
||||
|
||||
IMAGE_EXTENSIONS = _IMAGE_EXTENSIONS
|
||||
|
||||
|
||||
def _public_path(path):
|
||||
@@ -90,11 +88,7 @@ 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
|
||||
@@ -228,6 +222,70 @@ 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."""
|
||||
|
||||
@@ -298,7 +356,9 @@ 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):
|
||||
def _output_image_entry(
|
||||
media_path, output_path, root_index, public_root=None, *, root_scope=None
|
||||
):
|
||||
"""Build one gallery entry for *media_path* (blocking stat).
|
||||
|
||||
``path`` is relative to its root (what delete/autotag resolve against) and
|
||||
@@ -319,15 +379,22 @@ def _output_image_entry(media_path, output_path, root_index, public_root=None):
|
||||
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}"
|
||||
|
||||
return {
|
||||
"id": hashlib.sha1(str(media_path).encode("utf-8")).hexdigest()[:16],
|
||||
entry = {
|
||||
"id": hashlib.sha1(
|
||||
str(media_path).encode("utf-8"), usedforsecurity=False
|
||||
).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": f"/prompt_manager/images/serve/{encoded_path}?root={root_index}",
|
||||
"url": url,
|
||||
"thumbnail_url": thumbnail_url,
|
||||
"size": stat.st_size,
|
||||
"modified_time": stat.st_mtime,
|
||||
@@ -335,6 +402,9 @@ def _output_image_entry(media_path, output_path, root_index, public_root=None):
|
||||
"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):
|
||||
@@ -344,6 +414,48 @@ 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):
|
||||
@@ -381,6 +493,10 @@ 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)
|
||||
@@ -594,6 +710,8 @@ 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(
|
||||
{
|
||||
@@ -629,6 +747,119 @@ 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:
|
||||
@@ -714,15 +945,17 @@ 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(
|
||||
list(self._get_all_output_dirs()), request.query.get("root")
|
||||
)
|
||||
output_dirs = self._select_root(roots, request.query.get("root"))
|
||||
if not output_dirs:
|
||||
return _forbidden("No allowed image directories configured")
|
||||
|
||||
@@ -775,7 +1008,20 @@ class ImageRoutesMixin:
|
||||
async def get_gallery_subfolders(self, request):
|
||||
"""Get distinct subfolders from gallery output directories."""
|
||||
try:
|
||||
output_dirs = self._get_all_output_dirs()
|
||||
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]]
|
||||
subfolders = set()
|
||||
for output_path in output_dirs:
|
||||
files = await self._get_gallery_files(output_path)
|
||||
@@ -795,9 +1041,10 @@ class ImageRoutesMixin:
|
||||
ancestors.add("/".join(parts[:i]))
|
||||
subfolders.update(ancestors)
|
||||
|
||||
return web.json_response(
|
||||
{"success": True, "subfolders": sorted(subfolders)}
|
||||
)
|
||||
payload = {"success": True, "subfolders": sorted(subfolders)}
|
||||
if workspace:
|
||||
payload["roots"] = roots
|
||||
return web.json_response(payload)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Subfolders error: {e}")
|
||||
return web.json_response(
|
||||
@@ -1432,6 +1679,127 @@ 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:
|
||||
@@ -1529,7 +1897,27 @@ class ImageRoutesMixin:
|
||||
if _in_subfolder(str(entry[0].relative_to(entry[2]).parent), subfolder)
|
||||
]
|
||||
|
||||
def _format_output_page(self, page):
|
||||
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):
|
||||
entries = []
|
||||
public_roots = {}
|
||||
for media_path, _mtime, output_path, root_index in page:
|
||||
@@ -1538,7 +1926,15 @@ class ImageRoutesMixin:
|
||||
try:
|
||||
entries.append(
|
||||
_output_image_entry(
|
||||
media_path, output_path, root_index, public_roots[output_path]
|
||||
media_path,
|
||||
output_path,
|
||||
root_index,
|
||||
public_roots[output_path],
|
||||
root_scope=(
|
||||
self._gallery_root_scope(output_path, root_index)
|
||||
if scoped
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
"""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,
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
"""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})
|
||||
@@ -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()).hexdigest()[:12]
|
||||
url_hash = hashlib.md5(url.encode(), usedforsecurity=False).hexdigest()[:12]
|
||||
local_path = lora_cache / f"{url_hash}.jpg"
|
||||
|
||||
if local_path.exists():
|
||||
|
||||
@@ -0,0 +1,681 @@
|
||||
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());}
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
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/);
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
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.');
|
||||
});
|
||||
@@ -14,6 +14,28 @@ 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"
|
||||
@@ -77,8 +99,27 @@ 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)
|
||||
|
||||
def seed(extra_images: int = 0) -> None:
|
||||
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:
|
||||
with api.db.model.get_connection() as conn:
|
||||
for table in (
|
||||
"image_prompt_pairs",
|
||||
@@ -94,7 +135,14 @@ def create_app(root: Path) -> web.Application:
|
||||
shutil.rmtree(root / name)
|
||||
(root / name).mkdir()
|
||||
for index, (directory, text, tag) in enumerate(
|
||||
[("output", "Cat portrait", "cats"), ("input", "Dog landscape", "dogs")]
|
||||
[
|
||||
("output", "Cat portrait", "cats"),
|
||||
(
|
||||
"input",
|
||||
"Cat portrait" if native_library else "Dog landscape",
|
||||
"dogs",
|
||||
),
|
||||
]
|
||||
):
|
||||
image_path = root / directory / "shared" / "same.png"
|
||||
image_path.parent.mkdir(exist_ok=True)
|
||||
@@ -107,6 +155,25 @@ 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(
|
||||
@@ -114,25 +181,202 @@ 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 {}
|
||||
extra = data.get("extra_images", 0)
|
||||
if not isinstance(data, dict):
|
||||
raise web.HTTPBadRequest()
|
||||
chronological_history = data.get("chronological_history", False)
|
||||
history_runs = data.get("history_runs", 0)
|
||||
if (
|
||||
isinstance(extra, bool)
|
||||
or not isinstance(extra, int)
|
||||
or not 0 <= extra <= 200
|
||||
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()
|
||||
await api._run_in_executor(seed, extra)
|
||||
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)
|
||||
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)
|
||||
return web.json_response({"success": True})
|
||||
|
||||
@routes.get("/api/settings/Comfy.ColorPalette")
|
||||
|
||||
@@ -0,0 +1,661 @@
|
||||
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();}
|
||||
});
|
||||
@@ -0,0 +1,743 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,443 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,622 @@
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { captureCanonicalState, historyGeometry, compareCanonicalHistory, nativeDialogState, exerciseCurrentPairSend, assertRecordedHistory } from './history-canonical-comparison.mjs';
|
||||
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' });
|
||||
const workspace = '/prompt_manager/workspace';
|
||||
const primary = ['images', 'library', 'tags', 'inspect'];
|
||||
const tools = ['overview', 'system'];
|
||||
|
||||
async function noOverflow(page) {
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
|
||||
}
|
||||
async function groupedNavigation(page) {
|
||||
for (const [group, routes] of [['primary', primary], ['tools', tools]]) {
|
||||
const region = page.locator(`.workspace-nav-${group}`);
|
||||
await expect(region).toHaveCount(1);
|
||||
expect(await region.locator('[data-route]').evaluateAll(nodes => nodes.map(node => node.dataset.route))).toEqual(routes);
|
||||
}
|
||||
}
|
||||
async function attachEvidence(page, testInfo, name, fullPage = true) {
|
||||
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 screenshotPath = testInfo.outputPath(`${name}.png`);
|
||||
await page.screenshot({ path: screenshotPath, fullPage });
|
||||
await testInfo.attach(name, { path: screenshotPath, contentType: 'image/png' });
|
||||
const geometry = await page.evaluate(() => Object.fromEntries(['.sidebar', '.topbar', '#workspace-main', '.workspace-heading', 'h1', '.image-workspace', '.image-grid', '.image-card', '.image-details', '.prompt-block-heading', '.library-toolbar', '.library-grid', '.library-card', '.library-card-art', '.library-card-content'].map(selector => {
|
||||
const node = document.querySelector(selector);
|
||||
if (!node) return [selector, null];
|
||||
const box = node.getBoundingClientRect(), style = getComputedStyle(node);
|
||||
return [selector, { x: box.x, y: box.y, width: box.width, height: box.height, padding: style.padding, fontSize: style.fontSize }];
|
||||
})));
|
||||
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' });
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page, request, browserName }) => {
|
||||
await startCoverage(page, browserName);
|
||||
await expect(await request.post('/__test__/reset')).toBeOK();
|
||||
});
|
||||
test.afterEach(async ({ page, browserName }) => { await saveCoverage(page, browserName); });
|
||||
|
||||
test('canonical history assets are the exact immutable approved source', async ({ request }) => {
|
||||
const assets = [
|
||||
['/__test__/canonical/workspace', 'docs/concepts/workspace/index.html', 'd84712c7284bb232919a1ed9b154575c8ac574f63b2404a4ac4bc06cbf323c03'],
|
||||
['/workspace/app.js', 'docs/concepts/workspace/app.js', 'e33e7652da12a68cd3fe31e94d77d680aa439b6434ceee94f21cc4ce8883263e'],
|
||||
['/workspace/style.css', 'docs/concepts/workspace/style.css', '2c479ca1a2ddbbd14a550eae27dd731c326ccdb91fe9ddc87e2b81cd5298fce8'],
|
||||
];
|
||||
for (const [url, source, sha256] of assets) {
|
||||
const response = await request.get(url);await expect(response).toBeOK();
|
||||
const bytes = await response.body();
|
||||
expect(bytes).toEqual(await readFile(new URL(`../../${source}`, import.meta.url)));
|
||||
expect(createHash('sha256').update(bytes).digest('hex')).toBe(sha256);
|
||||
expect(response.headers()['cache-control']).toBe('no-store');
|
||||
expect(response.headers()['x-content-type-options']).toBe('nosniff');
|
||||
}
|
||||
for (const url of ['/workspace/missing.js', '/__test__/canonical/server.py', '/toolkit/missing.js', '/vendor/missing.js']) {
|
||||
expect((await request.get(url)).status()).toBe(404);
|
||||
}
|
||||
});
|
||||
|
||||
for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of ['dark', 'light']) {
|
||||
test(`canonical chronological history ${width} ${theme}`, async ({ page, request }, testInfo) => {
|
||||
await page.setViewportSize({ width, height });
|
||||
await page.goto('/__test__/canonical/workspace#prompt/alpine');
|
||||
await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption(theme);
|
||||
await expect(page.locator('.timeline')).toBeVisible();
|
||||
const referenceDot = page.getByRole('button', { name: 'Select negative Quality baseline', exact: true });
|
||||
await referenceDot.focus();await page.keyboard.press('Enter');
|
||||
await expect(page.getByRole('button', { name: 'Select negative Quality baseline', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
const reference = await historyGeometry(page, false);
|
||||
for (const [name, selector] of [['saved', '.surface'], ['branches', '.timeline-panel'], ['gallery', '.history-gallery']]) {
|
||||
await captureCanonicalState(page, testInfo, `concept-${name}-${width}-${theme}`, selector);
|
||||
}
|
||||
await page.getByRole('button', { name: 'Show repeat run for Quality baseline', exact: true }).click();
|
||||
await expect(page.getByRole('dialog', { name: 'Repeat run', exact: true })).toBeVisible();
|
||||
expect(await page.locator('#modal').evaluate(node => node.matches(':modal'))).toBe(true);
|
||||
reference.runDialog = await nativeDialogState(page.locator('#modal'));
|
||||
await captureCanonicalState(page, testInfo, `concept-run-${width}-${theme}`, '#modal');
|
||||
await page.keyboard.press('Escape');await expect(page.locator('#modal')).not.toBeVisible();
|
||||
await page.locator('.history-gallery .image-open').first().click();
|
||||
await expect(page.locator('#viewer-dialog')).toBeVisible();
|
||||
await expect(page.locator('#viewer-dialog')).toHaveAccessibleName('Alpine observatory · alpine_00001.png');
|
||||
await captureCanonicalState(page, testInfo, `concept-viewer-${width}-${theme}`, '#viewer-dialog');
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
const reset = await request.post('/__test__/reset', { data: { chronological_history: true } });await expect(reset).toBeOK();const fixture = await reset.json();
|
||||
await page.goto(`${workspace}#/library/${fixture.canonical_id}`);
|
||||
await page.getByRole('combobox', { name: 'Appearance', exact: true }).selectOption(theme);
|
||||
const details = await (await request.get(`/prompt_manager/library/${fixture.canonical_id}`)).json();
|
||||
const index = details.variants.findIndex(variant => variant.id === fixture.canonical_pair);
|
||||
const choice = page.getByRole('button', { name: `Select negative variant ${index + 1}`, exact: true });
|
||||
await choice.focus();await page.keyboard.press('Enter');await expect(choice).toHaveAttribute('aria-pressed', 'true');
|
||||
const actual = await historyGeometry(page, true);
|
||||
const roles = page.getByRole('region', { name: 'Saved prompt texts', exact: true });
|
||||
const positive = roles.getByRole('region', { name: 'Positive prompt', exact: true });
|
||||
expect(await positive.evaluate(node => node.nextElementSibling?.getAttribute('aria-label'))).toBe('Negative prompt');
|
||||
for (const name of ['Copy positive prompt', 'Send positive prompt', 'Copy negative prompt', 'Send negative prompt', 'Send prompt pair']) await expect(roles.getByRole('button', { name, exact: true })).toBeVisible();
|
||||
await expect(page.locator('.library-shared-gallery article[data-image-key]')).toHaveCount(2);
|
||||
await page.locator('.library-shared-gallery .image-open').first().click();
|
||||
await expect(page.getByRole('region', { name: 'Image details', exact: true })).toContainText('canonical-alt.png');
|
||||
await page.getByRole('button', { name: 'Open full preview', exact: true }).click();
|
||||
await expect(page.getByRole('dialog', { name: 'Image preview', exact: true })).toBeVisible();await page.keyboard.press('Escape');
|
||||
for (const [name, selector] of [['saved', '.library-surface'], ['branches', '.library-variants'], ['gallery', '.library-shared-gallery'], ['chronology', '.recorded-history']]) {
|
||||
await captureCanonicalState(page, testInfo, `production-${name}-${width}-${theme}`, selector);
|
||||
}
|
||||
const run = page.locator(`button[data-run-id="${fixture.canonical_run}"][data-anchor-id="10"]`);
|
||||
await run.focus();await page.keyboard.press('Enter');
|
||||
const runDialog = page.getByRole('dialog', { name: 'Recorded run', exact: true });
|
||||
await expect(runDialog.locator('[data-receipt]')).toHaveCount(2);
|
||||
expect(await runDialog.evaluate(node => node.matches(':modal') && node.contains(document.activeElement))).toBe(true);
|
||||
actual.runDialog = await nativeDialogState(runDialog);
|
||||
for (const name of ['Raw', 'Effective', 'Close recorded run', 'Send prompt pair']) await expect(runDialog.getByRole('button', { name, exact: true })).toBeVisible();
|
||||
await captureCanonicalState(page, testInfo, `production-run-${width}-${theme}`, '.recorded-run-dialog');
|
||||
const view = runDialog.getByRole('button', { name: 'View current file', exact: true }).first();await view.click();
|
||||
const viewer = page.getByRole('dialog', { name: 'Image preview', exact: true });await expect(viewer).toBeVisible();
|
||||
await expect(viewer.getByRole('region', { name: 'Positive prompt', exact: true })).toContainText(fixture.canonical_positive);
|
||||
await expect(viewer.getByRole('region', { name: 'Negative prompt', exact: true })).toContainText(fixture.canonical_negative);
|
||||
for (const name of ['Copy positive prompt', 'Send positive prompt', 'Copy negative prompt', 'Send negative prompt', 'Send prompt pair']) await expect(viewer.getByRole('button', { name, exact: true })).toBeVisible();
|
||||
await expect(viewer).toContainText('42001');await expect(viewer).toContainText('720');await expect(viewer).toContainText('480');
|
||||
const currentPositive = viewer.getByRole('region', { name: 'Positive prompt', exact: true });
|
||||
await currentPositive.getByRole('button', { name: 'Copy positive prompt', exact: true }).click();
|
||||
await expect(currentPositive.getByRole('status')).toContainText(/Copied\.|Copy unavailable/);
|
||||
await exerciseCurrentPairSend(page, { positive: fixture.canonical_positive, negative: fixture.canonical_negative });
|
||||
await captureCanonicalState(page, testInfo, `production-viewer-${width}-${theme}`, 'dialog.image-preview');
|
||||
await page.keyboard.press('Escape');await expect(view).toBeFocused();await page.keyboard.press('Escape');await expect(run).toBeFocused();
|
||||
await compareCanonicalHistory(reference, actual, testInfo);
|
||||
|
||||
const history = await request.get(`/prompt_manager/library/${fixture.positive_id}/history`);await expect(history).toBeOK();
|
||||
const window = await history.json();expect(window.items.map(item => item.run_id)).toEqual(fixture.aab);
|
||||
expect(window.context_summaries).toHaveLength(2);
|
||||
const contextIds = [...new Set(window.items.flatMap(item => item.matching_anchors.map(anchor => anchor.context_id)))];
|
||||
expect(contextIds).toHaveLength(2);
|
||||
await page.goto(`${workspace}#/library/${fixture.positive_id}`);
|
||||
await expect(page.locator('.history-run-marker')).toHaveCount(3);
|
||||
await page.locator('.history-timeline').evaluate(node => { node.scrollLeft = 0;node.scrollTop = 0; });
|
||||
const anchor = page.locator('.history-context-anchor').first();await anchor.focus();await expect(anchor).toBeFocused();
|
||||
const multiple = await historyGeometry(page, true);
|
||||
expect(multiple.recordedBranch.map(branch => branch.contextId)).toEqual(contextIds);
|
||||
expect(multiple.recordedMarker.map(marker => marker.runId)).toEqual(fixture.aab);
|
||||
await captureCanonicalState(page, testInfo, `production-chronology-multiple-${width}-${theme}`, '.recorded-history');
|
||||
const geometryPath = testInfo.outputPath('canonical-recorded-geometry.json');
|
||||
await writeFile(geometryPath, JSON.stringify({ reference, actual: multiple, expected: { contextIds, runIds: fixture.aab, branchCount: 2, anchorCount: 2, repeatCount: 1 }, differences: 'Two real frozen contexts / three runs versus three illustrative saved variants; chronological columns retain 64px run spacing, with the same role colors, curves and 57px row rhythm.' }, null, 2));
|
||||
await testInfo.attach('canonical-recorded-geometry', { path: geometryPath, contentType: 'application/json' });
|
||||
assertRecordedHistory(reference, multiple, { branchCount: 2, anchorCount: 2, repeatCount: 1 });
|
||||
});
|
||||
}
|
||||
|
||||
test('visual fixture exposes real stable media and library records', async ({ request }) => {
|
||||
expect((await request.post('/__test__/reset', { data: { workspace_visual: 'true' } })).status()).toBe(400);
|
||||
for (const option of ['extra_images', 'include_video', 'prompt_inspector', 'native_library', 'extra_prompts', 'extra_variants']) {
|
||||
expect((await request.post('/__test__/reset', { data: { workspace_visual: true, [option]: option.startsWith('extra') ? 1 : true } })).status()).toBe(400);
|
||||
}
|
||||
await expect(await request.post('/__test__/reset', { data: { workspace_visual: true } })).toBeOK();
|
||||
const images = await (await request.get('/prompt_manager/images/output?limit=40')).json();
|
||||
expect(images.total).toBe(24);
|
||||
expect(new Set(images.images.map(image => image.root_index))).toEqual(new Set([0, 1]));
|
||||
const image = images.images[0];
|
||||
await expect(await request.get(image.url)).toBeOK();
|
||||
const thumbnail = await request.get(image.thumbnail_url);
|
||||
await expect(thumbnail).toBeOK();
|
||||
expect(thumbnail.headers()['content-type']).toBe('image/png');
|
||||
const inspection = await (await request.get(`/prompt_manager/images/inspect/${encodeURIComponent(image.path)}?root=${image.root_index}`)).json();
|
||||
expect(inspection.success).toBe(true);
|
||||
expect(inspection.image.path).toBe(image.path);
|
||||
expect(inspection.prompts.positive.text).toContain('Amber geometry');
|
||||
expect(inspection.prompts.negative.text).toBe('blurred edges, watermark');
|
||||
expect(new Set(images.images.map(image => image.modified_time)).size).toBe(24);
|
||||
const library = await (await request.get('/prompt_manager/library')).json();
|
||||
expect(library.total).toBe(6);
|
||||
expect(library.items.reduce((sum, item) => sum + item.variant_count, 0)).toBe(10);
|
||||
const recipes = await Promise.all(library.items.map(async item => (await request.get(`/prompt_manager/library/${item.id}`)).json()));
|
||||
expect(recipes.flatMap(recipe => recipe.variants).some(variant => variant.negative_text === '')).toBe(true);
|
||||
const options = await (await request.get('/prompt_manager/library/options')).json();
|
||||
expect(options.success).toBe(true);
|
||||
await request.post('/__test__/reset', { data: { workspace_visual: true } });
|
||||
const repeated = await (await request.get('/prompt_manager/images/output?limit=40')).json();
|
||||
const identity = items => items.map(({root_index, path, modified_time}) => ({root_index, path, modified_time}));
|
||||
expect(identity(repeated.images)).toEqual(identity(images.images));
|
||||
await expect(await request.post('/__test__/reset')).toBeOK();
|
||||
expect((await (await request.get('/prompt_manager/stats')).json()).stats.total_prompts).toBe(2);
|
||||
});
|
||||
|
||||
test('shell matches approved stable geometry in both themes', async ({ page, request }, testInfo) => {
|
||||
await request.post('/__test__/reset', { data: { workspace_visual: true } });
|
||||
for (const [width, height] of [[1600, 1100], [390, 844]]) {
|
||||
await page.setViewportSize({ width, height });
|
||||
for (const route of ['images', 'library']) {
|
||||
await page.goto(`${workspace}#/${route}`);
|
||||
if (width === 1600) await expect(page.locator('.sidebar')).toHaveCSS('width', '218px');
|
||||
await expect(page.locator('.workspace-heading > .workspace-heading-copy > h1')).toBeVisible();
|
||||
await groupedNavigation(page);
|
||||
await expect(page.locator(route === 'images' ? 'article[data-image-key]' : 'article[data-prompt-id]').first()).toBeVisible();
|
||||
await expect(page.locator('.brand')).toHaveAttribute('href', '#/images');
|
||||
await expect(page.locator('#workspace-main > .eyebrow')).toHaveCount(0);
|
||||
for (const [theme, active] of [['dark', 'rgb(20, 54, 77)'], ['light', 'rgb(228, 241, 251)']]) {
|
||||
await page.getByRole('combobox', { name: 'Appearance', exact: true }).selectOption(theme);
|
||||
await expect(page.locator('nav [aria-current="page"]')).toHaveCSS('background-color', active);
|
||||
await expect(page.locator('h1')).toHaveCSS('font-weight', '620');
|
||||
await expect(page.locator('h1')).toHaveCSS('letter-spacing', '-0.7px');
|
||||
await expect(page.locator('.workspace-heading')).toHaveCSS('margin-block-end', '22px');
|
||||
await expect(page.locator('h1')).toHaveCSS('font-size', width === 1600 ? '28px' : '25px');
|
||||
await expect(page.locator('.workspace-heading-copy > .intro')).toHaveCSS('line-height', '18px');
|
||||
await expect(page.locator('#workspace-main')).toHaveCSS('padding-inline-start', width === 1600 ? '30px' : '14px');
|
||||
if (width === 1600) {
|
||||
await expect(page.locator('.sidebar')).toHaveCSS('width', '218px');
|
||||
await expect(page.locator('.topbar')).toHaveCSS('height', '68px');
|
||||
} else {
|
||||
await expect(page.locator('.sidebar')).toHaveCSS('position', 'static');
|
||||
expect(await page.locator('.workspace-shell').evaluate(node => getComputedStyle(node).gridTemplateColumns.split(' ').length)).toBe(1);
|
||||
}
|
||||
await noOverflow(page);
|
||||
await attachEvidence(page, testInfo, `${route}-${width}-${theme}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('long content and zoom keep grouped navigation reachable', async ({ page, request }) => {
|
||||
await request.post('/__test__/reset', { data: { workspace_visual: true } });
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
await page.goto(`${workspace}#/library`);
|
||||
await expect(page.locator('article[data-prompt-id]')).toHaveCount(6);
|
||||
await page.evaluate(() => { document.documentElement.style.zoom = '2'; });
|
||||
await groupedNavigation(page);
|
||||
for (const route of [...primary, ...tools]) {
|
||||
const link = page.locator(`nav [data-route="${route}"]`);
|
||||
await link.focus(); await expect(link).toBeFocused();
|
||||
}
|
||||
await page.getByRole('combobox', { name: 'Appearance', exact: true }).focus();
|
||||
await expect(page.getByRole('combobox', { name: 'Appearance', exact: true })).toBeFocused();
|
||||
const text = page.locator('.prompt-text').filter({ hasText: 'A study in very long creative context' });
|
||||
await text.scrollIntoViewIfNeeded();
|
||||
expect((await text.textContent()).length).toBeGreaterThan(1500);
|
||||
await text.focus(); await expect(text).toBeFocused();
|
||||
expect(await text.evaluate(node => {
|
||||
const scroller = node.closest('.library-card-prompts');
|
||||
return scroller.scrollHeight <= scroller.clientHeight + 1 && node.scrollHeight <= node.clientHeight + 1;
|
||||
})).toBe(true);
|
||||
await noOverflow(page);
|
||||
await page.evaluate(() => { document.documentElement.style.zoom = ''; });
|
||||
for (const width of [1251, 1250, 951, 950, 780, 700]) {
|
||||
await page.setViewportSize({ width, height: 1100 });
|
||||
await expect(page.locator('.sidebar')).toHaveCSS('position', width > 950 ? 'sticky' : 'static');
|
||||
if (width > 950) await expect(page.locator('.sidebar')).toHaveCSS('width', width > 1250 ? '218px' : '185px');
|
||||
await expect(page.locator('#workspace-main')).toHaveCSS('padding-inline-start', width > 1250 ? '30px' : width > 700 ? '24px' : '14px');
|
||||
await noOverflow(page);
|
||||
}
|
||||
});
|
||||
|
||||
test('Appearance label and control fit the narrow shell at 200 percent zoom', async ({ page, request }, testInfo) => {
|
||||
await request.post('/__test__/reset', { data: { workspace_visual: true } });
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(page.locator('article[data-image-key]')).toHaveCount(24);
|
||||
await page.evaluate(() => { document.documentElement.style.zoom = '2'; });
|
||||
const preference = page.getByRole('combobox', { name: 'Appearance', exact: true });
|
||||
const bounds = await preference.evaluate(select => {
|
||||
const field = select.closest('label');
|
||||
return { intendedWrapper: field.classList.contains('workspace-appearance'),
|
||||
boxes: [field, field.querySelector('.ca-field-label'), select].map(node => {
|
||||
const box = node.getBoundingClientRect(); return { left: box.left, right: box.right, width: box.width };
|
||||
}), width: document.documentElement.clientWidth };
|
||||
});
|
||||
for (const box of bounds.boxes) {
|
||||
expect(box.width).toBeGreaterThan(0);
|
||||
expect(box.left).toBeGreaterThanOrEqual(-1);
|
||||
expect(box.right).toBeLessThanOrEqual(bounds.width + 1);
|
||||
}
|
||||
expect(bounds.intendedWrapper).toBe(true);
|
||||
for (const theme of ['follow', 'light', 'dark']) {
|
||||
await preference.selectOption(theme); await expect(preference).toHaveValue(theme);
|
||||
}
|
||||
await preference.focus(); await expect(preference).toBeFocused();
|
||||
await noOverflow(page);
|
||||
await attachEvidence(page, testInfo, 'zoom-shell-390', false);
|
||||
});
|
||||
|
||||
async function containedImageContent(page) {
|
||||
return page.locator('article[data-image-key]').evaluateAll(nodes => {
|
||||
const failures = [];
|
||||
for (const card of nodes) {
|
||||
const outer = card.getBoundingClientRect();
|
||||
for (const selector of ['.image-card-footer', 'strong', 'small', '.image-card-footer button']) {
|
||||
const node = card.querySelector(selector), box = node.getBoundingClientRect();
|
||||
if (box.width <= 0 || box.height <= 0 || box.left < outer.left - 1 || box.right > outer.right + 1 ||
|
||||
box.top < outer.top - 1 || box.bottom > outer.bottom + 1 ||
|
||||
(selector === 'small' && node.scrollHeight > node.clientHeight + 1)) {
|
||||
failures.push({ key: card.dataset.imageKey, selector,
|
||||
card: { left: outer.left, right: outer.right, top: outer.top, bottom: outer.bottom },
|
||||
content: { left: box.left, right: box.right, top: box.top, bottom: box.bottom } });
|
||||
}
|
||||
}
|
||||
const action = card.querySelector('.image-card-footer button').getBoundingClientRect();
|
||||
const range = document.createRange(); range.selectNodeContents(card.querySelector('small'));
|
||||
for (const text of range.getClientRects()) {
|
||||
if (Math.min(text.right, action.right) - Math.max(text.left, action.left) > .5 &&
|
||||
Math.min(text.bottom, action.bottom) - Math.max(text.top, action.top) > .5) {
|
||||
failures.push({ key: card.dataset.imageKey, selector: 'facts/action overlap',
|
||||
text: { left: text.left, right: text.right, top: text.top, bottom: text.bottom },
|
||||
action: { left: action.left, right: action.right, top: action.top, bottom: action.bottom } });
|
||||
}
|
||||
}
|
||||
}
|
||||
return failures;
|
||||
});
|
||||
}
|
||||
|
||||
for (const [width, height] of [[390, 844], [1600, 1100]]) for (const layout of ['Grid', 'List', 'Masonry']) {
|
||||
test(`${layout} contains real card text and keyboard actions at ${width}px with 200 percent zoom`, async ({ page, request }, testInfo) => {
|
||||
await request.post('/__test__/reset', { data: { workspace_visual: true } });
|
||||
await page.setViewportSize({ width, height }); await page.goto(`${workspace}#/images`);
|
||||
const cards = page.locator('article[data-image-key]');
|
||||
await expect(cards).toHaveCount(24);
|
||||
await page.getByRole('button', { name: layout, exact: true }).click();
|
||||
const firstKey = await cards.first().getAttribute('data-image-key');
|
||||
for (const zoom of [1, 2]) {
|
||||
await page.evaluate(zoom => { document.documentElement.style.zoom = String(zoom); }, zoom);
|
||||
for (const density of ['comfortable', 'large', 'compact']) {
|
||||
await page.getByRole('combobox', { name: 'Thumbnail size', exact: true }).selectOption(density);
|
||||
await expect.poll(() => containedImageContent(page)).toEqual([]);
|
||||
await orderedMedia(page);
|
||||
const columns = await cards.evaluateAll(nodes => {
|
||||
const top = nodes[0].getBoundingClientRect().top;
|
||||
return nodes.filter(node => Math.abs(node.getBoundingClientRect().top - top) < 1).length;
|
||||
});
|
||||
if (zoom === 1) expect(columns).toBe(layout === 'List' ? 1 : width === 390 || density === 'large' ? 2 : density === 'compact' ? 4 : 3);
|
||||
else expect(columns).toBe(layout === 'List' || width === 390 ? 1 : density === 'large' ? 2 : density === 'compact' ? 4 : 3);
|
||||
if (zoom === 1 && layout === 'List') await expect(cards.first()).toHaveCSS('height', '97px');
|
||||
await cards.first().locator('.image-open').focus(); await page.keyboard.press('ArrowDown');
|
||||
await expect(cards.nth(columns).locator('.image-open')).toBeFocused();
|
||||
await page.keyboard.press('ArrowUp'); await expect(cards.first().locator('.image-open')).toBeFocused();
|
||||
expect(await cards.first().getAttribute('data-image-key')).toBe(firstKey);
|
||||
}
|
||||
const longCard = cards.filter({ hasText: 'a-very-long-geometric-composition-' });
|
||||
const filename = await longCard.locator('strong').textContent();
|
||||
expect(filename).toMatch(/layered-context-.*\.png/);
|
||||
await longCard.locator('.image-open').focus(); await page.keyboard.press('Enter');
|
||||
await expect(page.locator('.image-details h2')).toHaveText(filename);
|
||||
const fullPreview = page.getByRole('button', { name: 'Open full preview', exact: true });
|
||||
await fullPreview.focus(); await expect(fullPreview).toBeFocused(); await page.keyboard.press('Enter');
|
||||
await expect(page.getByRole('dialog', { name: 'Image preview', exact: true })).toBeVisible();
|
||||
await expect(page.locator('.image-preview > header h2')).toHaveText(filename);
|
||||
await page.keyboard.press('Escape');
|
||||
await longCard.locator('.image-open').focus(); await page.keyboard.press('Tab');
|
||||
await expect(longCard.getByRole('button', { name: `Preview ${filename}`, exact: true })).toBeFocused();
|
||||
await page.keyboard.press('Enter'); await expect(page.locator('dialog[open]')).toHaveCount(1);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect.poll(() => containedImageContent(page)).toEqual([]);
|
||||
await orderedMedia(page);
|
||||
await page.evaluate(() => scrollTo(0, 0));
|
||||
await attachEvidence(page, testInfo, `zoom-${layout.toLowerCase()}-${width}-${zoom}`, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('zoomed virtual Images keeps the visible real media mounted during native scrolling', async ({ page, request }) => {
|
||||
await page.addInitScript(() => { window.IntersectionObserver = undefined; });
|
||||
await request.post('/__test__/reset', { data: { extra_images: 200 } });
|
||||
await page.setViewportSize({ width: 390, height: 844 }); await page.goto(`${workspace}#/images`);
|
||||
const cards = page.locator('article[data-image-key]');
|
||||
await expect(cards).toHaveCount(40);
|
||||
await page.evaluate(() => { document.documentElement.style.zoom = '2'; });
|
||||
await expect.poll(() => cards.nth(1).evaluate(node => node.offsetLeft)).toBe(0);
|
||||
const stride = await cards.first().evaluate(node => node.getBoundingClientRect().height + 26);
|
||||
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.poll(() => cards.count()).toBeLessThan(120);
|
||||
await page.evaluate(stride => {
|
||||
const grid = document.querySelector('.image-grid');
|
||||
scrollTo(0, grid.getBoundingClientRect().top + scrollY + stride * 100 - 120);
|
||||
}, stride);
|
||||
await expect(page.locator('article[data-index="100"]')).toBeVisible();
|
||||
await expect(page.locator('article[data-index="101"]')).toHaveCount(1);
|
||||
const visible = page.locator('article[data-index="100"]');
|
||||
await visible.locator('.image-open').focus(); await page.keyboard.press('ArrowDown');
|
||||
await expect(page.locator('article[data-index="101"] .image-open')).toBeFocused();
|
||||
await page.keyboard.press('ArrowUp'); await expect(visible.locator('.image-open')).toBeFocused();
|
||||
await expect(visible).toBeInViewport();
|
||||
await expect.poll(() => containedImageContent(page)).toEqual([]);
|
||||
await orderedMedia(page);
|
||||
await expect.poll(() => cards.count()).toBeLessThan(120);
|
||||
});
|
||||
|
||||
test('supplemental extreme zoom grows real footers preserves the visible anchor and resets reservations', async ({ page, request }) => {
|
||||
await request.post('/__test__/reset', { data: { workspace_visual: true } });
|
||||
await page.setViewportSize({ width: 390, height: 844 }); await page.goto(`${workspace}#/images`);
|
||||
const cards = page.locator('article[data-image-key]'), grid = page.locator('.image-grid');
|
||||
await expect(cards).toHaveCount(24);
|
||||
await page.evaluate(() => { document.documentElement.style.zoom = '2'; });
|
||||
await expect.poll(() => cards.nth(1).evaluate(node => node.offsetLeft)).toBe(0);
|
||||
const selected = cards.nth(16), key = await selected.getAttribute('data-image-key');
|
||||
await selected.locator('.image-open').click(); await selected.locator('.image-open').focus();
|
||||
const filename = await selected.locator('strong').textContent();
|
||||
await selected.evaluate(node => scrollBy(0, node.getBoundingClientRect().top - 120));
|
||||
await page.evaluate(() => {
|
||||
const grid = document.querySelector('.image-grid'), footers = [...grid.querySelectorAll('.image-card-footer')];
|
||||
const nativeHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight').get;
|
||||
const descriptors = footers.map(node => [node, Object.getOwnPropertyDescriptor(node, 'offsetHeight')]);
|
||||
const restore = () => { for (const [node, descriptor] of descriptors) {
|
||||
if (descriptor) Object.defineProperty(node, 'offsetHeight', descriptor); else delete node.offsetHeight;
|
||||
} };
|
||||
window.footerMeasurementProbe = { before: null, restore };
|
||||
for (const footer of footers) Object.defineProperty(footer, 'offsetHeight', { configurable: true, get() {
|
||||
const height = nativeHeight.call(this);
|
||||
if (document.documentElement.style.zoom === '4' && grid.style.getPropertyValue('--image-footer-height') === '54px' &&
|
||||
Math.abs(Number.parseFloat(this.closest('.image-card').style.width) - Number.parseFloat(getComputedStyle(grid).width)) < .01) {
|
||||
const top = visualViewport?.offsetTop || 0, bottom = top + (visualViewport?.height || innerHeight);
|
||||
const first = [...grid.children].find(node => { const box = node.getBoundingClientRect(); return box.bottom > top && box.top < bottom; });
|
||||
window.footerMeasurementProbe.before = first ? { index: Number(first.dataset.index), key: first.dataset.imageKey,
|
||||
top: first.getBoundingClientRect().top - top } : null;
|
||||
restore();
|
||||
}
|
||||
return height;
|
||||
} });
|
||||
document.documentElement.style.zoom = '4';
|
||||
const box = grid.getBoundingClientRect();
|
||||
// The approved initial reservation is 54px with 4:3 media and a 13px row gap.
|
||||
const stride = (box.width / 4 - 2) * .75 + 54 + 2 + 13;
|
||||
// Place row eight inside the 52px scaled gap so the preceding row is not visible.
|
||||
const index = 8;
|
||||
scrollTo(0, box.top + scrollY + index * stride * 4 - 16);
|
||||
});
|
||||
try {
|
||||
await expect.poll(() => page.evaluate(() => window.footerMeasurementProbe.before?.index)).toBe(8);
|
||||
const anchor = await page.evaluate(() => window.footerMeasurementProbe.before);
|
||||
await expect.poll(() => grid.evaluate(node => Number.parseFloat(node.style.getPropertyValue('--image-footer-height')))).toBeGreaterThan(54);
|
||||
await expect.poll(() => containedImageContent(page)).toEqual([]);
|
||||
const visibleAnchor = page.locator(`article[data-index="${anchor.index}"]`);
|
||||
const anchorTop = await visibleAnchor.evaluate(node => node.getBoundingClientRect().top - (visualViewport?.offsetTop || 0));
|
||||
expect(Math.abs(anchorTop - anchor.top)).toBeLessThanOrEqual(2);
|
||||
await expect(page.locator(`article[data-image-key="${key}"]`)).toHaveClass(/selected/);
|
||||
await expect(selected.locator('.image-open')).toBeFocused();
|
||||
await expect(page.locator('.image-details h2')).toHaveText(filename);
|
||||
const mutations = await grid.evaluate(async node => {
|
||||
let changes = 0;
|
||||
const observer = new MutationObserver(records => { changes += records.length; });
|
||||
observer.observe(node, { subtree: true, attributes: true, attributeFilter: ['style'] });
|
||||
for (let frame = 0; frame < 6; frame++) await new Promise(requestAnimationFrame);
|
||||
observer.disconnect(); return changes;
|
||||
});
|
||||
expect(mutations).toBe(0);
|
||||
await page.getByRole('button', { name: 'List', exact: true }).click();
|
||||
await expect.poll(() => containedImageContent(page)).toEqual([]);
|
||||
expect(await cards.first().evaluate(node => node.offsetHeight)).toBeGreaterThan(97);
|
||||
await page.getByRole('searchbox', { name: 'Search filenames', exact: true }).fill('no-real-media-match');
|
||||
await expect(cards).toHaveCount(0);
|
||||
expect(await grid.evaluate(node => Number.parseFloat(node.style.getPropertyValue('--image-footer-height')))).toBe(54);
|
||||
await page.getByRole('searchbox', { name: 'Search filenames', exact: true }).fill('');
|
||||
await expect(cards).toHaveCount(24);
|
||||
await expect.poll(() => grid.evaluate(node => Number.parseFloat(node.style.getPropertyValue('--image-footer-height')))).toBeGreaterThan(54);
|
||||
const extremeFooter = await grid.evaluate(node => Number.parseFloat(node.style.getPropertyValue('--image-footer-height')));
|
||||
await page.evaluate(() => { document.documentElement.style.zoom = '2'; });
|
||||
await expect.poll(() => grid.evaluate(node => Number.parseFloat(node.style.getPropertyValue('--image-footer-height')))).toBeLessThan(extremeFooter);
|
||||
await expect(cards.first()).toHaveCSS('height', '97px');
|
||||
await expect.poll(() => containedImageContent(page)).toEqual([]);
|
||||
await page.getByRole('button', { name: 'Grid', exact: true }).click();
|
||||
await expect.poll(() => grid.evaluate(node => Number.parseFloat(node.style.getPropertyValue('--image-footer-height')))).toBe(54);
|
||||
await orderedMedia(page);
|
||||
} finally { await page.evaluate(() => {
|
||||
window.footerMeasurementProbe?.restore(); delete window.footerMeasurementProbe;
|
||||
}); }
|
||||
});
|
||||
|
||||
test('supplemental wrapped real footer reservation survives append without moving the visible card', async ({ page, request }) => {
|
||||
await page.addInitScript(() => { window.IntersectionObserver = undefined; });
|
||||
await request.post('/__test__/reset', { data: { extra_images: 200 } });
|
||||
await page.setViewportSize({ width: 390, height: 844 }); await page.goto(`${workspace}#/images`);
|
||||
const cards = page.locator('article[data-image-key]'), grid = page.locator('.image-grid');
|
||||
await expect(cards).toHaveCount(40);
|
||||
await cards.first().locator('.image-open').click();
|
||||
const selectedKey = await cards.first().getAttribute('data-image-key');
|
||||
await page.evaluate(() => { document.documentElement.style.zoom = '4'; });
|
||||
await expect.poll(() => grid.evaluate(node => Number.parseFloat(node.style.getPropertyValue('--image-footer-height')))).toBeGreaterThan(54);
|
||||
let release, pending = false;
|
||||
const held = new Promise(resolve => { release = resolve; });
|
||||
await page.route('**/images/output?**', async route => {
|
||||
if (new URL(route.request().url()).searchParams.has('cursor')) { pending = true; await held; }
|
||||
await route.continue();
|
||||
});
|
||||
try {
|
||||
const more = page.getByRole('button', { name: 'Load more images', exact: true });
|
||||
await more.click(); await expect.poll(() => pending).toBe(true);
|
||||
const anchor = await cards.evaluateAll(nodes => {
|
||||
const top = visualViewport?.offsetTop || 0, bottom = top + (visualViewport?.height || innerHeight);
|
||||
const node = nodes.find(node => { const box = node.getBoundingClientRect(); return box.bottom > top && box.top < bottom; });
|
||||
return node ? { key: node.dataset.imageKey, top: node.getBoundingClientRect().top - top } : null;
|
||||
});
|
||||
expect(anchor).not.toBeNull();
|
||||
release(); await expect(page.locator('.image-toolbar')).toContainText('80 of 202 images');
|
||||
await expect(cards).toHaveCount(80);
|
||||
await expect.poll(async () => Math.abs(await page.locator(`article[data-image-key="${anchor.key}"]`).evaluate(node => node.getBoundingClientRect().top - (visualViewport?.offsetTop || 0)) - anchor.top)).toBeLessThanOrEqual(1);
|
||||
await expect(page.locator(`article[data-image-key="${selectedKey}"]`)).toHaveClass(/selected/);
|
||||
await expect(more).toBeFocused();
|
||||
await expect.poll(() => containedImageContent(page)).toEqual([]);
|
||||
} finally { release(); }
|
||||
});
|
||||
|
||||
test('Library visual evidence keeps natural cards and returns from real history in both themes',async({browser,browserName,request},testInfo)=>{
|
||||
await request.post('/__test__/reset',{data:{workspace_visual:true}});
|
||||
for(const [width,height] of [[1600,1100],[390,844]]) for(const theme of ['dark','light']) {
|
||||
const context=await browser.newContext({viewport:{width,height},locale:'en-US',timezoneId:'UTC',reducedMotion:'reduce'});
|
||||
const page=await context.newPage();const errors=[];page.on('pageerror',error=>errors.push(error.message));await startCoverage(page,browserName);
|
||||
try {
|
||||
await page.goto(`${workspace}#/library`);await expect(page.locator('article[data-prompt-id]')).toHaveCount(6);
|
||||
await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption(theme);
|
||||
await attachEvidence(page,testInfo,`after-library-${width}-${theme}`,false);
|
||||
await noOverflow(page);
|
||||
await page.getByRole('link',{name:'Explore history',exact:true}).first().click();
|
||||
await expect(page.getByRole('region',{name:'Prompt history',exact:true})).toBeVisible();
|
||||
await expect(page.locator('.library-history > div > .intro')).toHaveCount(1);
|
||||
await page.getByRole('link',{name:'Back to library',exact:true}).click();
|
||||
await expect(page.locator('article[data-prompt-id]')).toHaveCount(6);
|
||||
await expect(page.locator('.workspace-heading-copy > .intro')).toHaveCount(1);
|
||||
expect(errors).toEqual([]);
|
||||
} finally {await saveCoverage(page,browserName);await context.close();}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test('approved icons are decorative safe SVGs and reject unknown vocabulary', async ({ page }) => {
|
||||
await page.goto(`${workspace}#/images`);
|
||||
const icons = await page.evaluate(async () => {
|
||||
const { createWorkspaceIcon } = await import('/prompt_manager/js/workspace/icons.mjs');
|
||||
const names = ['images', 'library', 'tags', 'upload', 'overview', 'settings', 'grid', 'list', 'masonry', 'search', 'info', 'copy', 'branch'];
|
||||
const otherDocument = document.implementation.createHTMLDocument();
|
||||
const rendered = names.map(name => {
|
||||
const icon = createWorkspaceIcon(name, otherDocument);
|
||||
return { name, svg: icon instanceof SVGSVGElement, owner: icon.ownerDocument === otherDocument,
|
||||
viewBox: icon.getAttribute('viewBox'), hidden: icon.getAttribute('aria-hidden'), focusable: icon.getAttribute('focusable'),
|
||||
fill: icon.getAttribute('fill'), stroke: icon.getAttribute('stroke'), width: icon.getAttribute('stroke-width'),
|
||||
path: icon.querySelector('path').getAttribute('d'), children: icon.childElementCount };
|
||||
});
|
||||
const unknown = ['missing', '__proto__', '', null, 1].map(name => {
|
||||
try { createWorkspaceIcon(name); return false; } catch (error) { return error instanceof TypeError; }
|
||||
});
|
||||
return { rendered, unknown };
|
||||
});
|
||||
expect(icons.unknown).toEqual([true, true, true, true, true]);
|
||||
for (const icon of icons.rendered) {
|
||||
expect(icon).toMatchObject({ svg: true, owner: true, viewBox: '0 0 24 24', hidden: 'true', focusable: 'false', fill: 'none', stroke: 'currentColor', width: '1.65', children: 1 });
|
||||
expect(icon.path.length).toBeGreaterThan(10);
|
||||
}
|
||||
expect(icons.rendered.find(icon => icon.name === 'overview').path).toBe('M3 12h4l3-8 4 16 3-8h4');
|
||||
});
|
||||
|
||||
|
||||
async function orderedMedia(page) {
|
||||
const boxes=await page.locator('article[data-image-key]').evaluateAll(nodes=>nodes.map(node=>{
|
||||
const box=node.getBoundingClientRect();return {x:box.x,y:box.y,right:box.right,bottom:box.bottom};
|
||||
}));
|
||||
for (let index=0;index<boxes.length;index++) for (const other of boxes.slice(index+1)) {
|
||||
const box=boxes[index];
|
||||
expect(box.right<=other.x+1||other.right<=box.x+1||box.bottom<=other.y+1||other.bottom<=box.y+1).toBe(true);
|
||||
}
|
||||
await noOverflow(page);
|
||||
}
|
||||
|
||||
test('Images visual layouts retain real delayed media long filenames and loaded scroll',async({page,request},testInfo)=>{
|
||||
await request.post('/__test__/reset',{data:{workspace_visual:true}});
|
||||
let release, mediaRequests=0;const held=new Promise(resolve=>{release=resolve;});
|
||||
await page.route('**/images/serve/**',async route=>{mediaRequests++;await held;await route.continue().catch(()=>{});});
|
||||
try {
|
||||
await page.setViewportSize({width:1600,height:1100});await page.goto(`${workspace}#/images`,{waitUntil:'domcontentloaded'});
|
||||
await expect(page.locator('article[data-image-key]')).toHaveCount(24);
|
||||
await expect.poll(()=>mediaRequests).toBeGreaterThan(0);
|
||||
expect(await page.locator('.image-card img').first().evaluate(image=>image.complete)).toBe(false);
|
||||
await expect(page.locator('.image-card').first()).toHaveCSS('border-radius','7px');
|
||||
await orderedMedia(page);
|
||||
} finally {release();}
|
||||
for (const [width,height] of [[1600,1100],[390,844]]) {
|
||||
await page.setViewportSize({width,height});
|
||||
for (const theme of ['dark','light']) {
|
||||
await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption(theme);
|
||||
await page.locator('.image-card .image-open').first().click();
|
||||
await expect(page.locator('.image-details h2')).toHaveText('study-01.png');
|
||||
for (const layout of ['Grid','List','Masonry']) {
|
||||
await page.getByRole('button',{name:layout,exact:true}).click();await page.evaluate(()=>scrollTo(0,0));
|
||||
if(layout==='List') await expect(page.locator('.image-card').first()).toHaveCSS('height','97px');
|
||||
await orderedMedia(page);
|
||||
await attachEvidence(page,testInfo,`after-images-${layout.toLowerCase()}-${width}-${theme}`,false);
|
||||
}
|
||||
await page.locator('article[data-image-key]').last().scrollIntoViewIfNeeded();
|
||||
const longCard=page.locator('article[data-image-key]').filter({hasText:'a-very-long-geometric-composition-'});
|
||||
await expect(longCard.locator('strong')).toHaveText(/layered-context-.*\.png/);
|
||||
await longCard.locator('.image-open').click();
|
||||
await expect(page.locator('.image-details h2')).toHaveText(await longCard.locator('strong').textContent());
|
||||
await orderedMedia(page);
|
||||
await attachEvidence(page,testInfo,`after-images-loaded-scroll-${width}-${theme}`,false);
|
||||
}
|
||||
}
|
||||
await page.evaluate(()=>{location.hash='#/overview';});
|
||||
await expect(page.locator('.image-card,.image-details,.viewer-container,dialog[open]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('embedded history gallery keeps the history heading and disposes its layouts',async({page,request},testInfo)=>{
|
||||
await request.post('/__test__/reset',{data:{workspace_visual:true}});
|
||||
for (const width of [1600,390]) {
|
||||
await page.setViewportSize({width,height:width===1600?1100:844});await page.goto(`${workspace}#/library`);
|
||||
await page.getByRole('link',{name:'Explore history',exact:true}).first().click();
|
||||
await expect(page.getByRole('region',{name:'Prompt history',exact:true})).toBeVisible();
|
||||
await expect(page.locator('h1')).not.toHaveText('Prompt library');
|
||||
const title=await page.locator('h1').textContent();expect(title).not.toBe('Images');
|
||||
await expect(page.locator('article[data-image-key]').first()).toBeVisible();
|
||||
for (const layout of ['List','Masonry','Grid']) {
|
||||
await page.getByRole('button',{name:layout,exact:true}).click();await orderedMedia(page);
|
||||
await expect(page.locator('h1')).toHaveText(title);
|
||||
await expect(page.locator('.workspace-heading-copy > .intro')).toHaveCount(0);
|
||||
}
|
||||
await attachEvidence(page,testInfo,`after-images-embedded-history-${width}`,false);
|
||||
await page.getByRole('link',{name:'Back to library',exact:true}).click();
|
||||
await expect(page.locator('.image-card,.image-details,dialog[open]')).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
@@ -13,6 +13,17 @@ const routes = [
|
||||
['system', 'System', '/prompt_manager/admin'],
|
||||
];
|
||||
|
||||
async function countAppearanceWrites(page) {
|
||||
await page.evaluate(() => {
|
||||
window.__appearanceChanges = 0;
|
||||
const original = Storage.prototype.setItem;
|
||||
Storage.prototype.setItem = function (...args) {
|
||||
if (args[0] === 'pm_workspace_theme') window.__appearanceChanges++;
|
||||
return original.apply(this, args);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page, request, browserName }) => {
|
||||
await startCoverage(page, browserName);
|
||||
await expect(await request.post('/__test__/reset')).toBeOK();
|
||||
@@ -36,7 +47,7 @@ test('workspace starts with real persisted statistics without mutating data', as
|
||||
page.on('request', request => {
|
||||
if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method())) mutations.push(request.url());
|
||||
});
|
||||
await page.goto(workspace);
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
await expect(page).toHaveTitle(/Prompt Manager/);
|
||||
await expect(page.getByRole('navigation', { name: 'Workspace', exact: true })).toBeVisible();
|
||||
const main = page.locator('#workspace-main');
|
||||
@@ -67,7 +78,7 @@ test('all shell destinations deep-link, reload and expose working existing appli
|
||||
});
|
||||
|
||||
test('keyboard navigation, browser history and unknown routes remain usable', async ({ page }) => {
|
||||
await page.goto(workspace);
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
const navigation = page.getByRole('navigation', { name: 'Workspace', exact: true });
|
||||
const images = navigation.getByRole('link', { name: 'Images', exact: true });
|
||||
await images.focus();
|
||||
@@ -80,8 +91,8 @@ test('keyboard navigation, browser history and unknown routes remain usable', as
|
||||
await page.goForward();
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
|
||||
await page.goto(`${workspace}#/not-a-page`);
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
|
||||
await expect(navigation.getByRole('link', { name: 'Overview', exact: true }))
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
|
||||
await expect(navigation.getByRole('link', { name: 'Images', exact: true }))
|
||||
.toHaveAttribute('aria-current', 'page');
|
||||
});
|
||||
|
||||
@@ -97,7 +108,7 @@ test('skip to content focuses the current view without changing its route', asyn
|
||||
});
|
||||
|
||||
test('returning to overview refreshes persisted counts without duplicating navigation', async ({ page, request }) => {
|
||||
await page.goto(workspace);
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
const prompts = statistic(page, 'Prompts');
|
||||
await expect(prompts).toHaveText('2');
|
||||
const navigation = page.getByRole('navigation', { name: 'Workspace', exact: true });
|
||||
@@ -111,7 +122,7 @@ test('returning to overview refreshes persisted counts without duplicating navig
|
||||
|
||||
test('appearance follows ComfyUI and persists an explicit light or dark preference', async ({ page }) => {
|
||||
await page.route('**/api/settings/Comfy.ColorPalette', route => route.fulfill({ json: 'light' }));
|
||||
await page.goto(workspace);
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
const theme = page.locator('[data-ca-theme]').first();
|
||||
await expect(theme).toHaveAttribute('data-ca-theme', 'light');
|
||||
const appearance = page.getByRole('combobox', { name: 'Appearance', exact: true });
|
||||
@@ -129,7 +140,7 @@ test('appearance follows ComfyUI and persists an explicit light or dark preferen
|
||||
test('unavailable host palette falls back to system appearance and tracks system changes', async ({ page }) => {
|
||||
await page.route('**/api/settings/Comfy.ColorPalette', route => route.fulfill({ status: 503, json: {} }));
|
||||
await page.emulateMedia({ colorScheme: 'light' });
|
||||
await page.goto(workspace);
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
const theme = page.locator('[data-ca-theme]').first();
|
||||
await expect(theme).toHaveAttribute('data-ca-theme', 'light');
|
||||
await page.emulateMedia({ colorScheme: 'dark' });
|
||||
@@ -143,7 +154,7 @@ test('overview reports loading, offers retry after failure and renders real empt
|
||||
await pending;
|
||||
await route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } });
|
||||
});
|
||||
await page.goto(workspace);
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
await expect(page.locator('#workspace-main').getByRole('status')).toBeVisible();
|
||||
release();
|
||||
await expect(page.locator('#workspace-main').getByRole('alert')).toBeVisible();
|
||||
@@ -160,7 +171,7 @@ test('overview reports loading, offers retry after failure and renders real empt
|
||||
|
||||
test('small screens keep navigation and appearance accessible without horizontal page overflow', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
await page.goto(workspace);
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
const navigation = page.getByRole('navigation', { name: 'Workspace', exact: true });
|
||||
for (const [, label] of routes) {
|
||||
await navigation.getByRole('link', { name: label, exact: true }).click();
|
||||
@@ -182,7 +193,7 @@ test('leaving a loading overview prevents its delayed result from replacing the
|
||||
} }).catch(() => {});
|
||||
completed();
|
||||
});
|
||||
await page.goto(workspace);
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
await expect(page.locator('#workspace-main').getByRole('status')).toBeVisible();
|
||||
await page.getByRole('navigation', { name: 'Workspace', exact: true })
|
||||
.getByRole('link', { name: 'Images', exact: true }).click();
|
||||
@@ -191,7 +202,13 @@ test('leaving a loading overview prevents its delayed result from replacing the
|
||||
await finished;
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
|
||||
await expect(page.locator('#workspace-main')).not.toContainText('999');
|
||||
await expect(page.locator('.workspace-nav-primary')).toHaveCount(1);
|
||||
await expect(page.locator('.workspace-nav-tools')).toHaveCount(1);
|
||||
await expect(page.getByRole('combobox', { name: 'Appearance', exact: true })).toHaveCount(1);
|
||||
await expect(page.locator('#workspace-main a[href="/prompt_manager/gallery"]')).toBeVisible();
|
||||
await countAppearanceWrites(page);
|
||||
await page.getByRole('combobox', { name: 'Appearance', exact: true }).selectOption('light');
|
||||
expect(await page.evaluate(() => window.__appearanceChanges)).toBe(1);
|
||||
});
|
||||
|
||||
test('cached-page restoration remounts one working shell and refreshes its data', async ({ page }) => {
|
||||
@@ -199,7 +216,7 @@ test('cached-page restoration remounts one working shell and refreshes its data'
|
||||
page.on('request', request => {
|
||||
if (new URL(request.url()).pathname === '/prompt_manager/stats') reads++;
|
||||
});
|
||||
await page.goto(workspace);
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
await expect(statistic(page, 'Prompts')).toHaveText('2');
|
||||
for (let restore = 0; restore < 2; restore++) {
|
||||
await page.evaluate(() => {
|
||||
@@ -211,8 +228,13 @@ test('cached-page restoration remounts one working shell and refreshes its data'
|
||||
expect(reads).toBe(3);
|
||||
const navigation = page.getByRole('navigation', { name: 'Workspace', exact: true });
|
||||
await expect(navigation.getByRole('link')).toHaveCount(routes.length);
|
||||
await expect(page.locator('.workspace-nav-primary')).toHaveCount(1);
|
||||
await expect(page.locator('.workspace-nav-tools')).toHaveCount(1);
|
||||
await expect(page.getByRole('combobox', { name: 'Appearance', exact: true })).toHaveCount(1);
|
||||
await countAppearanceWrites(page);
|
||||
await navigation.getByRole('link', { name: 'Images', exact: true }).click();
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
|
||||
await page.getByRole('combobox', { name: 'Appearance' }).selectOption('light');
|
||||
await expect(page.locator('body')).toHaveAttribute('data-ca-theme', 'light');
|
||||
expect(await page.evaluate(() => window.__appearanceChanges)).toBe(1);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,538 @@
|
||||
"""Real disposable ledger commands and physical files for history workflows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID, uuid5
|
||||
|
||||
from aiohttp import web
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
from tests.browser.workspace_visual_fixture import _write_art, seed_workspace_visual
|
||||
from utils.execution_contexts import (
|
||||
ContextBundle,
|
||||
PromptSnapshot,
|
||||
RecipeContext,
|
||||
TextEvidence,
|
||||
)
|
||||
from utils.execution_history_events import make_finish, make_start
|
||||
from utils.execution_outputs import OutputBundle, OutputReference, snapshot_output_roots
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from py.api import PromptManagerAPI
|
||||
|
||||
|
||||
HISTORY_POSITIVE = (
|
||||
"Chronology A,A,B\nExact positive 猫 😀 " + "fine light " * 35
|
||||
).rstrip()
|
||||
DETAIL_POSITIVE = "Frozen evidence\n" + "unbroken-" * 90
|
||||
LONG_NEGATIVE = "same visible negative\n" + "exact detail 猫 😀 " * 70
|
||||
SESSION = "69f04366-6384-4182-b84b-117597479127"
|
||||
|
||||
|
||||
class _HistoryCommands:
|
||||
"""One disposable fixture lifetime and one bounded publication barrier."""
|
||||
|
||||
def __init__(self, api: PromptManagerAPI, root: Path) -> None:
|
||||
self.api = api
|
||||
self.root = root
|
||||
self.manifest = None
|
||||
self.entered = threading.Event()
|
||||
self.release = threading.Event()
|
||||
self.restore_seam = None
|
||||
import folder_paths
|
||||
|
||||
self.original_output = folder_paths.get_output_directory
|
||||
|
||||
def reset(self, manifest: dict | None = None) -> None:
|
||||
self.disarm()
|
||||
import folder_paths
|
||||
|
||||
folder_paths.get_output_directory = self.original_output
|
||||
self.manifest = manifest
|
||||
|
||||
def disarm(self) -> None:
|
||||
self.release.set()
|
||||
if self.restore_seam:
|
||||
module, name, original = self.restore_seam
|
||||
setattr(module, name, original)
|
||||
self.restore_seam = None
|
||||
|
||||
def arm(self, boundary: str) -> None:
|
||||
from py.api import history
|
||||
from py.api import inspection_service
|
||||
|
||||
self.disarm()
|
||||
self.entered = threading.Event()
|
||||
self.release = threading.Event()
|
||||
name = {
|
||||
"window": "_serialize_history",
|
||||
"detail": "_serialize_history",
|
||||
"candidate": "resolve_current_candidate",
|
||||
"inspection": "read_inspection_file",
|
||||
"media": "_prepare_current_media",
|
||||
}[boundary]
|
||||
module = inspection_service if boundary == "inspection" else history
|
||||
original = getattr(module, name)
|
||||
entered, release = self.entered, self.release
|
||||
|
||||
def held(*args: object, **kwargs: object) -> object:
|
||||
value = original(*args, **kwargs)
|
||||
setattr(module, name, original)
|
||||
entered.set()
|
||||
try:
|
||||
if not release.wait(10):
|
||||
raise TimeoutError("Owned history publication barrier timed out")
|
||||
return value
|
||||
except BaseException:
|
||||
if boundary == "media":
|
||||
value.close()
|
||||
raise
|
||||
|
||||
self.restore_seam = module, name, original
|
||||
setattr(module, name, held)
|
||||
|
||||
|
||||
def register_workspace_history_commands(
|
||||
routes: web.RouteTableDef, api: PromptManagerAPI, root: Path
|
||||
) -> _HistoryCommands:
|
||||
"""Register fixed commands; all files and namespaces belong to this server."""
|
||||
state = _HistoryCommands(api, root)
|
||||
api._workspace_history_commands = state
|
||||
|
||||
@routes.post("/__test__/history/commands")
|
||||
async def command(request: web.Request) -> web.Response:
|
||||
data = await request.json() if request.can_read_body else None
|
||||
actions = {
|
||||
"release",
|
||||
"replace-file",
|
||||
"reconfigure-root",
|
||||
"make-missing-available",
|
||||
"append-history-runs",
|
||||
}
|
||||
if (
|
||||
not isinstance(data, dict)
|
||||
or type(data.get("action")) is not str
|
||||
or ("boundary" in data and type(data["boundary"]) is not str)
|
||||
or not (
|
||||
(set(data) == {"action"} and data["action"] in actions)
|
||||
or (
|
||||
set(data) == {"action", "boundary"}
|
||||
and data["action"] == "arm"
|
||||
and data["boundary"]
|
||||
in {"window", "detail", "candidate", "inspection", "media"}
|
||||
)
|
||||
)
|
||||
):
|
||||
raise web.HTTPBadRequest()
|
||||
if state.manifest is None:
|
||||
raise web.HTTPConflict()
|
||||
action = data["action"]
|
||||
if action == "arm":
|
||||
state.arm(data["boundary"])
|
||||
elif action == "release":
|
||||
state.disarm()
|
||||
elif action == "replace-file":
|
||||
replacement = root / "output" / "history" / "replacement.png"
|
||||
with Image.new("RGB", (160, 100), (225, 30, 40)) as image:
|
||||
image.save(replacement)
|
||||
replacement.replace(root / "output" / "history" / "current.png")
|
||||
elif action == "make-missing-available":
|
||||
target = root / "output" / "history" / "missing.png"
|
||||
with Image.new("RGB", (160, 100), (30, 150, 70)) as image:
|
||||
image.save(target)
|
||||
elif action == "append-history-runs":
|
||||
session = str(uuid5(UUID(SESSION), "fixed-late-history"))
|
||||
context = RecipeContext(
|
||||
"10", _snapshot(HISTORY_POSITIVE), _snapshot("A negative"), "inferred"
|
||||
)
|
||||
for number in range(61):
|
||||
job = str(uuid5(UUID(session), str(number)))
|
||||
start = make_start(
|
||||
session,
|
||||
((0, job, {}, {}), number),
|
||||
api.db.get_execution_epoch(),
|
||||
1_790_000_000_000 + number,
|
||||
)
|
||||
api.db.write_execution_start(start)
|
||||
api.db.write_execution_finish(
|
||||
make_finish(
|
||||
session,
|
||||
number,
|
||||
start.storage_epoch,
|
||||
SimpleNamespace(
|
||||
messages=[("execution_success", {"prompt_id": job})]
|
||||
),
|
||||
1_790_000_000_010 + number,
|
||||
)
|
||||
)
|
||||
api.db.write_execution_contexts(
|
||||
start.run_id,
|
||||
start.storage_epoch,
|
||||
ContextBundle((context,), "complete", "captured"),
|
||||
)
|
||||
elif action == "reconfigure-root":
|
||||
import folder_paths
|
||||
|
||||
moved = root / "owned-reconfigured-output"
|
||||
moved.mkdir(exist_ok=True)
|
||||
folder_paths.get_output_directory = lambda: str(moved)
|
||||
return web.json_response({"success": True})
|
||||
|
||||
@routes.get("/__test__/history/checkpoint")
|
||||
async def checkpoint(request: web.Request) -> web.Response:
|
||||
if state.manifest is None:
|
||||
raise web.HTTPConflict()
|
||||
return web.json_response({"entered": state.entered.is_set()})
|
||||
|
||||
@routes.get("/__test__/history/snapshot")
|
||||
async def snapshot(request: web.Request) -> web.Response:
|
||||
if state.manifest is None or request.query:
|
||||
raise web.HTTPConflict()
|
||||
path = root / "owned-history-snapshot.db"
|
||||
created = await api._run_in_executor(api.db.model.backup_database, str(path))
|
||||
if not created:
|
||||
raise web.HTTPInternalServerError()
|
||||
return web.Response(
|
||||
body=path.read_bytes(), content_type="application/octet-stream"
|
||||
)
|
||||
|
||||
@routes.get("/__test__/history/reused-snapshot")
|
||||
async def reused_snapshot(request: web.Request) -> web.Response:
|
||||
if state.manifest is None or request.query:
|
||||
raise web.HTTPConflict()
|
||||
from database.operations import PromptDatabase
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
path = root / "owned-history-reused.db"
|
||||
|
||||
def create() -> bytes:
|
||||
for suffix in ("", "-wal", "-shm"):
|
||||
Path(str(path) + suffix).unlink(missing_ok=True)
|
||||
database = PromptDatabase(str(path))
|
||||
try:
|
||||
target = state.manifest["detail_id"]
|
||||
with database.model.get_connection() as connection:
|
||||
text = "Restored owner with reused numeric ID"
|
||||
connection.execute(
|
||||
"INSERT INTO prompts(id,text,hash) VALUES(?,?,?)",
|
||||
(target, text, generate_prompt_hash(text)),
|
||||
)
|
||||
database.save_prompt_pair(text, "Restored saved negative")
|
||||
finally:
|
||||
database.close_all()
|
||||
return path.read_bytes()
|
||||
|
||||
return web.Response(
|
||||
body=await api._run_in_executor(create),
|
||||
content_type="application/octet-stream",
|
||||
)
|
||||
|
||||
async def cleanup(application: web.Application) -> None:
|
||||
state.reset()
|
||||
|
||||
api_app = __import__("server").PromptServer.instance.app
|
||||
api_app.on_cleanup.append(cleanup)
|
||||
return state
|
||||
|
||||
|
||||
def _known(value: str, source: str = "runtime_return") -> TextEvidence:
|
||||
provenance = "inferred" if source.startswith("graph_") else "recorded"
|
||||
return TextEvidence(value, provenance, source, None)
|
||||
|
||||
|
||||
def _snapshot(raw: str, suffix: str = "") -> PromptSnapshot:
|
||||
return PromptSnapshot(_known(raw), _known(raw + suffix), _known(""), _known(suffix))
|
||||
|
||||
|
||||
def capture_workspace_history_zero_outputs(api: PromptManagerAPI, run_id: str) -> bool:
|
||||
"""Apply one late host capture through the normal immutable storage command."""
|
||||
run = api.db.get_execution_run(run_id)
|
||||
finish = make_finish(
|
||||
run["session_id"],
|
||||
run["queue_item_id"],
|
||||
run["storage_epoch"],
|
||||
SimpleNamespace(
|
||||
messages=[
|
||||
(
|
||||
"execution_success",
|
||||
{"prompt_id": run["job_id"], "timestamp": run["terminal_event_at"]},
|
||||
)
|
||||
]
|
||||
),
|
||||
run["observed_callback_at"],
|
||||
)
|
||||
return api.db.write_execution_outputs_for_finish(
|
||||
finish, OutputBundle((), "complete", "none_reported")
|
||||
)
|
||||
|
||||
|
||||
def seed_workspace_history(
|
||||
api: PromptManagerAPI, root: Path, *, history_runs: int = 0
|
||||
) -> dict:
|
||||
"""Write actual captures; reset removes only this server's disposable rows."""
|
||||
api._workspace_history_commands.reset()
|
||||
seed_workspace_visual(api, root)
|
||||
with api.db.model.get_connection() as conn:
|
||||
for table in (
|
||||
"execution_run_outputs",
|
||||
"execution_output_capture",
|
||||
"execution_run_contexts",
|
||||
"execution_context_capture",
|
||||
"execution_runs",
|
||||
"execution_contexts",
|
||||
"execution_prompt_snapshots",
|
||||
"execution_context_texts",
|
||||
):
|
||||
conn.execute(f"DELETE FROM {table}")
|
||||
epoch = api.db.get_execution_epoch()
|
||||
roots = snapshot_output_roots("posix", str(root / "output"), str(root / "temp"))
|
||||
folder = root / "output" / "history"
|
||||
folder.mkdir(exist_ok=True)
|
||||
for index, name in enumerate(("current.png", "other.png")):
|
||||
with Image.new("RGB", (160, 100), (50, 80 + index * 50, 110)) as image:
|
||||
image.save(folder / name)
|
||||
|
||||
def reference(
|
||||
node: str, ordinal: int, name: str, kind: str = "output"
|
||||
) -> OutputReference:
|
||||
return OutputReference(
|
||||
node,
|
||||
ordinal,
|
||||
kind,
|
||||
"history",
|
||||
name,
|
||||
"posix",
|
||||
roots.output_key if kind == "output" else roots.temp_key,
|
||||
)
|
||||
|
||||
item = 0
|
||||
|
||||
def run(
|
||||
contexts: tuple[RecipeContext, ...],
|
||||
outputs: OutputBundle | None = None,
|
||||
*,
|
||||
observed: int | None = None,
|
||||
conflicting: bool = False,
|
||||
) -> str:
|
||||
nonlocal item
|
||||
job = str(uuid5(UUID(SESSION), f"job-{item}"))
|
||||
start = make_start(
|
||||
SESSION,
|
||||
((item, job, {}, {}), item),
|
||||
epoch,
|
||||
1_780_000_000_000 + item if observed is None else observed,
|
||||
)
|
||||
api.db.write_execution_start(start)
|
||||
messages = [("execution_success", {"prompt_id": job, "timestamp": item + 1})]
|
||||
finish = make_finish(
|
||||
SESSION, item, epoch, SimpleNamespace(messages=messages), item + 10
|
||||
)
|
||||
api.db.write_execution_finish(finish)
|
||||
partial = any(
|
||||
getattr(side, field).value is None
|
||||
for context in contexts
|
||||
for side in (context.positive, context.negative)
|
||||
if side is not None
|
||||
for field in ("raw", "effective", "prepend", "append")
|
||||
)
|
||||
api.db.write_execution_contexts(
|
||||
start.run_id,
|
||||
epoch,
|
||||
ContextBundle(
|
||||
contexts,
|
||||
"partial" if partial else "complete",
|
||||
"partial" if partial else "captured",
|
||||
),
|
||||
)
|
||||
if outputs is not None:
|
||||
api.db.write_execution_outputs_for_finish(finish, outputs)
|
||||
if conflicting:
|
||||
api.db.write_execution_contexts(
|
||||
start.run_id,
|
||||
epoch,
|
||||
ContextBundle(
|
||||
(replace(contexts[0], negative=_snapshot("later")),),
|
||||
"complete",
|
||||
"captured",
|
||||
),
|
||||
)
|
||||
api.db.write_execution_outputs_for_finish(
|
||||
finish, OutputBundle((), "complete", "none_reported")
|
||||
)
|
||||
item += 1
|
||||
return start.run_id
|
||||
|
||||
pair_a = api.db.save_prompt_pair(
|
||||
HISTORY_POSITIVE, "A negative", title="Saved A", category="History"
|
||||
)
|
||||
api.db.save_prompt_pair(HISTORY_POSITIVE, "B negative", title="Saved B")
|
||||
positive_id = api.db.get_prompt_pair(pair_a)["positive_prompt_id"]
|
||||
image = api.db.link_image_to_prompt(positive_id, str(folder / "current.png"))
|
||||
api.db.link_image_to_pair(pair_a, image, {"source": "history browser fixture"})
|
||||
a = RecipeContext(
|
||||
"10", _snapshot(HISTORY_POSITIVE), _snapshot("A negative"), "inferred"
|
||||
)
|
||||
b = replace(a, negative=_snapshot("B negative"))
|
||||
aab = [run((context,)) for context in (a, a, b)]
|
||||
for number in range(history_runs):
|
||||
context = (
|
||||
a
|
||||
if number % 3 == 0
|
||||
else (
|
||||
b
|
||||
if number % 3 == 1
|
||||
else replace(b, negative=_snapshot(f"Distinct context {number:04}"))
|
||||
)
|
||||
)
|
||||
run((context,))
|
||||
|
||||
saved_pair = api.db.save_prompt_pair(
|
||||
DETAIL_POSITIVE, LONG_NEGATIVE, title="Saved exact negative"
|
||||
)
|
||||
detail_id = api.db.get_prompt_pair(saved_pair)["positive_prompt_id"]
|
||||
api.db.save_prompt_pair(DETAIL_POSITIVE, "", title="Saved empty")
|
||||
current = api.db.link_image_to_prompt(detail_id, str(folder / "current.png"))
|
||||
api.db.link_image_to_pair(saved_pair, current, {"source": "fixture"})
|
||||
x = RecipeContext(
|
||||
"10",
|
||||
_snapshot(DETAIL_POSITIVE, " + composition A"),
|
||||
_snapshot(LONG_NEGATIVE, " A"),
|
||||
"inferred",
|
||||
)
|
||||
y = replace(x, negative=_snapshot(LONG_NEGATIVE, " B"))
|
||||
receipts = OutputBundle(
|
||||
(
|
||||
reference("10", 0, "current.png"),
|
||||
reference("10", 1, "current.png"),
|
||||
reference("2", 0, "other.png"),
|
||||
reference("2", 1, "missing.png"),
|
||||
reference("z", 0, "transient.png", "temp"),
|
||||
),
|
||||
"complete",
|
||||
"reported",
|
||||
)
|
||||
detail_runs = {
|
||||
"shared": run(
|
||||
(x, replace(x, anchor_id="2"), replace(y, anchor_id="z")), receipts
|
||||
),
|
||||
"equal_looking": run((y,)),
|
||||
"empty": run(
|
||||
(replace(x, negative=_snapshot("")),),
|
||||
OutputBundle((), "complete", "none_reported"),
|
||||
),
|
||||
"unknown": run(
|
||||
(
|
||||
replace(
|
||||
x,
|
||||
negative=PromptSnapshot(
|
||||
TextEvidence(None, "unknown", "unknown", "missing"),
|
||||
_known(" \n\t ", "host_cache_output"),
|
||||
_known("", "graph_literal"),
|
||||
_known("", "graph_semantics"),
|
||||
),
|
||||
),
|
||||
)
|
||||
),
|
||||
"whitespace": run(
|
||||
(
|
||||
replace(
|
||||
x,
|
||||
negative=PromptSnapshot(
|
||||
_known(" \n\t ", "graph_literal"),
|
||||
_known(" \n\t ", "host_cache_output"),
|
||||
_known("", "graph_literal"),
|
||||
_known("", "graph_semantics"),
|
||||
),
|
||||
),
|
||||
),
|
||||
OutputBundle(
|
||||
(reference("1", 0, "current.png"),), "partial", "limit_reached"
|
||||
),
|
||||
),
|
||||
"conflicting": run(
|
||||
(x,), receipts, conflicting=True, observed=9_223_372_036_854_775_807
|
||||
),
|
||||
"paged": run(
|
||||
(x,),
|
||||
OutputBundle(
|
||||
tuple(
|
||||
reference("paged", ordinal, "current.png") for ordinal in range(41)
|
||||
),
|
||||
"complete",
|
||||
"reported",
|
||||
),
|
||||
),
|
||||
}
|
||||
canonical_positive = (
|
||||
"Layered sandstone arches, warm afternoon light, precise silhouettes."
|
||||
)
|
||||
canonical_negative = "blurred edges, watermark"
|
||||
canonical_pair = api.db.save_prompt_pair(
|
||||
canonical_positive, canonical_negative, title="Quality baseline"
|
||||
)
|
||||
canonical_id = api.db.get_prompt_pair(canonical_pair)["positive_prompt_id"]
|
||||
api.db.save_prompt_pair(canonical_positive, "flat light", title="Clean geometry")
|
||||
api.db.save_prompt_pair(canonical_positive, "", title="No negative")
|
||||
for number, name in enumerate(("canonical.png", "canonical-alt.png")):
|
||||
path = folder / name
|
||||
_write_art(path, number, canonical_positive, canonical_negative)
|
||||
with Image.open(path) as source:
|
||||
graph = json.loads(source.info["prompt"])
|
||||
graph["3"]["inputs"].update(
|
||||
seed=42001 + number,
|
||||
steps=28,
|
||||
cfg=6.5,
|
||||
sampler_name="euler",
|
||||
scheduler="normal",
|
||||
)
|
||||
metadata = PngInfo()
|
||||
metadata.add_text("prompt", json.dumps(graph))
|
||||
source.save(path, pnginfo=metadata)
|
||||
stamp = 1_760_000_000 - number * 60
|
||||
os.utime(path, (stamp, stamp))
|
||||
image_id = api.db.link_image_to_prompt(canonical_id, str(path))
|
||||
api.db.link_image_to_pair(canonical_pair, image_id, {"source": "fixture"})
|
||||
canonical_run = run(
|
||||
(
|
||||
RecipeContext(
|
||||
"10",
|
||||
_snapshot(canonical_positive, " + frozen composition"),
|
||||
_snapshot(canonical_negative),
|
||||
"inferred",
|
||||
),
|
||||
),
|
||||
OutputBundle(
|
||||
(
|
||||
reference("10", 0, "canonical.png"),
|
||||
reference("10", 1, "canonical-alt.png"),
|
||||
),
|
||||
"complete",
|
||||
"reported",
|
||||
),
|
||||
)
|
||||
merge_duplicate_id = api.db.save_prompt(DETAIL_POSITIVE)
|
||||
api.invalidate_gallery_cache()
|
||||
manifest = {
|
||||
"positive_id": positive_id,
|
||||
"detail_id": detail_id,
|
||||
"aab": aab,
|
||||
"runs": detail_runs,
|
||||
"saved_pair": saved_pair,
|
||||
"detail_image_id": current,
|
||||
"canonical_id": canonical_id,
|
||||
"canonical_pair": canonical_pair,
|
||||
"canonical_run": canonical_run,
|
||||
"canonical_positive": canonical_positive,
|
||||
"canonical_negative": canonical_negative,
|
||||
"merge_duplicate_id": merge_duplicate_id,
|
||||
}
|
||||
api._workspace_history_commands.reset(manifest)
|
||||
return manifest
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Owned, deterministic physical-file evidence for the full inspector workflows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
from tests.browser.workspace_visual_fixture import seed_workspace_visual
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from py.api import PromptManagerAPI
|
||||
|
||||
|
||||
INSPECTOR_POSITIVE = (
|
||||
"Inspector portrait 猫 😀\n" + "Full prompt context, fine geometry and light. " * 40
|
||||
)
|
||||
INSPECTOR_NEGATIVE = "blurred edges, watermark"
|
||||
|
||||
|
||||
def _graph(positive: str, negative: str) -> dict:
|
||||
return {
|
||||
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": positive}},
|
||||
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": negative}},
|
||||
"3": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"positive": ["1", 0],
|
||||
"negative": ["2", 0],
|
||||
"model": ["4", 0],
|
||||
"seed": 9007199254740993,
|
||||
"steps": 28,
|
||||
"cfg": 6.5,
|
||||
"sampler_name": "dpmpp_2m",
|
||||
"scheduler": "karras",
|
||||
},
|
||||
},
|
||||
"4": {
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"inputs": {"ckpt_name": "fixture-model.safetensors"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _image(path: Path, index: int, metadata: dict) -> None:
|
||||
info = PngInfo()
|
||||
for key, value in metadata.items():
|
||||
text = (
|
||||
value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
|
||||
)
|
||||
info.add_itxt(key, text, zip=True)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with Image.new("RGB", (720, 480), (30 + index * 9, 74, 98)) as image:
|
||||
image.save(path, pnginfo=info)
|
||||
stamp = 1_780_000_000 + index
|
||||
os.utime(path, (stamp, stamp))
|
||||
|
||||
|
||||
def seed_workspace_inspector(
|
||||
api: PromptManagerAPI,
|
||||
root: Path,
|
||||
*,
|
||||
extra_images: int = 0,
|
||||
include_video: bool = False,
|
||||
) -> None:
|
||||
"""Seed only disposable harness media and exact saved associations."""
|
||||
seed_workspace_visual(api, root)
|
||||
complete = _graph(INSPECTOR_POSITIVE, INSPECTOR_NEGATIVE)
|
||||
workflow = {
|
||||
"nodes": [
|
||||
{
|
||||
"id": 3,
|
||||
"type": "KSampler",
|
||||
"widgets_values": [
|
||||
9007199254740993,
|
||||
"fixed",
|
||||
28,
|
||||
6.5,
|
||||
"dpmpp_2m",
|
||||
"karras",
|
||||
1,
|
||||
],
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
}
|
||||
ambiguous = {**complete, "5": complete["3"]}
|
||||
examples = (
|
||||
("complete.png", {"prompt": complete, "workflow": workflow}),
|
||||
("empty.png", {}),
|
||||
("explicit-empty.png", {"prompt": _graph("Explicit empty negative", "")}),
|
||||
("ambiguous.png", {"prompt": ambiguous}),
|
||||
("workflow-only.png", {"workflow": workflow}),
|
||||
("malformed.png", {"prompt": "not-json", "workflow": "{broken"}),
|
||||
("control-heavy.png", {"parameters": "\x01" * 1_450_000}),
|
||||
)
|
||||
for index, (name, metadata) in enumerate(examples):
|
||||
_image(root / "output" / "inspector" / name, index, metadata)
|
||||
_image(
|
||||
root / "input" / "inspector" / "complete.png",
|
||||
8,
|
||||
{"prompt": _graph("Input collision", "")},
|
||||
)
|
||||
pair = api.db.save_prompt_pair(
|
||||
INSPECTOR_POSITIVE,
|
||||
INSPECTOR_NEGATIVE,
|
||||
title="Inspector recipe",
|
||||
category="Inspection",
|
||||
tags=["fixture", "geometric"],
|
||||
)
|
||||
positive = api.db.get_prompt_pair(pair)["positive_prompt_id"]
|
||||
image = api.db.link_image_to_prompt(
|
||||
positive, str(root / "output" / "inspector" / "complete.png")
|
||||
)
|
||||
api.db.link_image_to_pair(pair, image, {"source": "fixture"})
|
||||
api.db.link_image_to_prompt(
|
||||
positive, str(root / "output" / "inspector" / "workflow-only.png")
|
||||
)
|
||||
for number in range(extra_images):
|
||||
_image(
|
||||
root / "output" / "inspector" / f"extra-{number:03}.png",
|
||||
9 + number % 12,
|
||||
{"prompt": complete},
|
||||
)
|
||||
if include_video:
|
||||
shutil.copyfile(
|
||||
Path(__file__).resolve().parents[1] / "fixtures" / "tiny.webm",
|
||||
root / "output" / "inspector" / "clip.webm",
|
||||
)
|
||||
os.utime(
|
||||
root / "output" / "inspector" / "clip.webm", (1_780_000_100, 1_780_000_100)
|
||||
)
|
||||
api.invalidate_gallery_cache()
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Deterministic original artwork and saved prompts for workspace visual checks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from py.api import PromptManagerAPI
|
||||
|
||||
|
||||
_POSITIVES = (
|
||||
(
|
||||
"Amber geometry\nLayered sandstone arches, warm afternoon light, "
|
||||
"precise silhouettes."
|
||||
),
|
||||
(
|
||||
"Tidal observatory\nCobalt circles over a quiet sea, "
|
||||
"a pale horizon and measured reflections."
|
||||
),
|
||||
(
|
||||
"Forest atlas\nOverlapping emerald triangles, paper textures "
|
||||
"and a soft misty background."
|
||||
),
|
||||
(
|
||||
"Rose pavilion\nCoral blocks and floating windows, "
|
||||
"balanced shadows on a terracotta plaza."
|
||||
),
|
||||
"Violet orbit\nConcentric rings, lavender haze and a tiny silver moon.",
|
||||
"A study in very long creative context\n"
|
||||
+ "Preserve every geometric detail, layered color relationship and quiet edge. "
|
||||
* 18
|
||||
+ "unbroken-"
|
||||
+ "context" * 40,
|
||||
)
|
||||
_NEGATIVES = (
|
||||
("blurred edges, watermark", "flat lighting, clipped highlights", ""),
|
||||
("low contrast, noise", "distorted horizon"),
|
||||
("oversaturated foliage", "soft geometry"),
|
||||
("unbalanced composition",),
|
||||
("banding, harsh gradients",),
|
||||
("cropped details, illegible marks",),
|
||||
)
|
||||
|
||||
|
||||
def _write_art(path: Path, index: int, positive: str, negative: str) -> None:
|
||||
sizes = ((720, 480), (480, 720), (560, 560), (900, 400))
|
||||
palettes = (
|
||||
("#252c46", "#e0a75e", "#b76445"),
|
||||
("#153b57", "#64bfc6", "#dfdba8"),
|
||||
("#1d4947", "#71ac83", "#d5d8aa"),
|
||||
("#523547", "#ef9c87", "#bc6764"),
|
||||
("#342b59", "#a79bd6", "#e5c0bf"),
|
||||
("#303d4d", "#b1c4c5", "#d6a05f"),
|
||||
)
|
||||
width, height = sizes[index % len(sizes)]
|
||||
background, accent, highlight = palettes[index // 4]
|
||||
graph = {
|
||||
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": positive}},
|
||||
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": negative}},
|
||||
"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))
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with Image.new("RGB", (width, height), background) as image:
|
||||
draw = ImageDraw.Draw(image)
|
||||
draw.rectangle((0, height * 3 // 4, width, height), fill=highlight)
|
||||
radius = min(width, height) // 3
|
||||
center_x, center_y = width // 2 + (index % 3 - 1) * 25, height // 2
|
||||
draw.ellipse(
|
||||
(
|
||||
center_x - radius,
|
||||
center_y - radius,
|
||||
center_x + radius,
|
||||
center_y + radius,
|
||||
),
|
||||
fill=accent,
|
||||
)
|
||||
draw.polygon(
|
||||
(
|
||||
(width // 8, height * 7 // 8),
|
||||
(width // 2, height // 4),
|
||||
(width * 7 // 8, height * 7 // 8),
|
||||
),
|
||||
outline=highlight,
|
||||
width=8,
|
||||
)
|
||||
draw.line(
|
||||
(width // 6, height // 3, width * 5 // 6, height // 3),
|
||||
fill=highlight,
|
||||
width=4,
|
||||
)
|
||||
image.save(path, pnginfo=info)
|
||||
stamp = 1_760_000_000 - index * 60
|
||||
os.utime(path, (stamp, stamp))
|
||||
|
||||
|
||||
def seed_workspace_visual(api: PromptManagerAPI, root: Path) -> None:
|
||||
"""Replace only the owned browser harness records and media roots."""
|
||||
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",
|
||||
):
|
||||
conn.execute(f"DELETE FROM {table}")
|
||||
for name in ("output", "input"):
|
||||
shutil.rmtree(root / name)
|
||||
(root / name).mkdir()
|
||||
for parent_index, (positive, negatives) in enumerate(zip(_POSITIVES, _NEGATIVES)):
|
||||
category = ("Architecture", "Landscape", "Abstract")[parent_index % 3]
|
||||
tags = ["geometric", ("warm", "cool")[parent_index % 2]]
|
||||
if parent_index == 5:
|
||||
tags.append("long-context-" + "detail" * 28)
|
||||
prompt_id = api.db.save_prompt(
|
||||
positive,
|
||||
category=category,
|
||||
tags=tags,
|
||||
prompt_hash=generate_prompt_hash(positive),
|
||||
rating=parent_index if parent_index else None,
|
||||
)
|
||||
for variant_index, negative in enumerate(negatives):
|
||||
api.db.save_prompt_pair(
|
||||
positive,
|
||||
negative,
|
||||
title=f"Composition {variant_index + 1}",
|
||||
category=category,
|
||||
tags=tags,
|
||||
)
|
||||
for offset in range(4):
|
||||
index = parent_index * 4 + offset
|
||||
directory = ("output", "input")[offset % 2]
|
||||
filename = f"study-{index + 1:02}.png"
|
||||
if index == 23:
|
||||
filename = (
|
||||
"a-very-long-geometric-composition-"
|
||||
+ "layered-context-" * 9
|
||||
+ ".png"
|
||||
)
|
||||
path = root / directory / category.lower() / filename
|
||||
_write_art(path, index, positive, negatives[offset % len(negatives)])
|
||||
api.db.link_image_to_prompt(prompt_id, str(path))
|
||||
api._generate_gallery_thumbnails_sync(api._get_all_output_dirs(), (150, 150))
|
||||
api.invalidate_gallery_cache()
|
||||
@@ -0,0 +1,47 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { access } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import assert from 'node:assert/strict';
|
||||
import { test as base, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import { launchExecutionHost } from './execution-host.mjs';
|
||||
import runtime from '../../tools/vendor/comfyui-runtime.json' with { type: 'json' };
|
||||
|
||||
export { launchExecutionHost };
|
||||
const run = promisify(execFile);
|
||||
const reader = fileURLToPath(new URL('./read_execution_history.py', import.meta.url));
|
||||
|
||||
export async function readExecutionRuns(databasePath, jobId, sessionId) {
|
||||
const args = [reader, databasePath, jobId];
|
||||
if (sessionId !== undefined) args.push('--session-id', sessionId);
|
||||
const { stdout } = await run(process.env.PM_TEST_PYTHON || 'python', args, { timeout: 5000, maxBuffer: 1024 * 1024 });
|
||||
return JSON.parse(stdout);
|
||||
}
|
||||
|
||||
export const test = base.extend({
|
||||
execution: [async ({ playwright }, use, workerInfo) => {
|
||||
const execution = await launchExecutionHost({
|
||||
logPath: fileURLToPath(new URL(`../../tools/vendor/test-results/executions/${workerInfo.project.name}-host.log`, import.meta.url)),
|
||||
});
|
||||
let request;
|
||||
try {
|
||||
request = await playwright.request.newContext({ baseURL: 'http://127.0.0.1:8196/' });
|
||||
await expect.poll(async () => {
|
||||
if (execution.host.child.exitCode !== null) throw new Error('Owned execution host exited before readiness');
|
||||
try { return (await request.get('/system_stats')).ok(); } catch { return false; }
|
||||
}, { timeout: 90000 }).toBe(true);
|
||||
const info = (await (await request.get('/system_stats')).json()).system;
|
||||
expect(info.comfyui_version).toBe(runtime.serverVersion);
|
||||
expect(info.pytorch_version).toBe(`${runtime.torchVersion}+cpu`);
|
||||
expect(info.required_frontend_version).toBe(runtime.frontendVersion);
|
||||
expect((await request.get('/prompt_manager/stats')).ok()).toBe(true);
|
||||
await access(execution.databasePath);
|
||||
await assert.rejects(access(join(execution.host.stateDirectory, 'custom_nodes/promptmanager/prompts.db')), { code: 'ENOENT' });
|
||||
await use({ ...execution, request });
|
||||
} finally {
|
||||
try { if (request) await request.post('/pm_execution_fixture/gate/release', { timeout: 5000 }); }
|
||||
finally { try { await request?.dispose(); } finally { await execution.dispose(); } }
|
||||
}
|
||||
}, { scope: 'worker', timeout: 120000 }],
|
||||
});
|
||||
@@ -0,0 +1,427 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import { test, readExecutionRuns } from './execution-fixture.mjs';
|
||||
import { installHostHooks } from './host-fixture.mjs';
|
||||
|
||||
const sensitive = ['PM_SECRET_PROMPT_209', 'PM_SECRET_TOKEN_209', 'PM_SECRET_TRACE_209'];
|
||||
const cached = value => ({ '1': { class_type: 'PMHistoryCached', inputs: { value } } });
|
||||
const gate = value => ({ '1': { class_type: 'PMHistoryGate', inputs: { value } } });
|
||||
|
||||
async function submit(request, prompt, promptId = randomUUID()) {
|
||||
const response = await request.post('/prompt', { data: { prompt, prompt_id: promptId,
|
||||
extra_data: { token: sensitive[1], extra_pnginfo: { private: sensitive[2] } } } });
|
||||
expect(response.ok()).toBe(true);
|
||||
expect((await response.json()).prompt_id).toBe(promptId);
|
||||
return promptId;
|
||||
}
|
||||
|
||||
async function history(request, jobId, terminal = 'execution_success') {
|
||||
let item;
|
||||
await expect.poll(async () => {
|
||||
const response = await request.get(`/history/${jobId}`);
|
||||
expect(response.ok()).toBe(true);
|
||||
item = (await response.json())[jobId];
|
||||
return item?.status?.messages?.some(([kind]) => kind === terminal) || false;
|
||||
}, { timeout: 15000 }).toBe(true);
|
||||
expect(item.prompt[1]).toBe(jobId);
|
||||
return item;
|
||||
}
|
||||
|
||||
async function rows(execution, jobId, outcomes) {
|
||||
let result;
|
||||
await expect.poll(async () => {
|
||||
result = await readExecutionRuns(execution.databasePath, jobId);
|
||||
return { schemaAvailable: result.schemaAvailable, outcomes: result.rows.map(row => row.outcome) };
|
||||
}, { timeout: 10000, message: `Persisted exact-job outcomes for ${jobId}` }).toEqual({ schemaAvailable: true, outcomes });
|
||||
for (const sentinel of sensitive) expect(JSON.stringify(result.rows)).not.toContain(sentinel);
|
||||
for (const row of result.rows) {
|
||||
expect(row.observed_dequeue_at).toBeGreaterThan(0);
|
||||
expect(row.observed_callback_at).toBeGreaterThanOrEqual(row.observed_dequeue_at);
|
||||
expect(row.executor_started_at).toBeGreaterThan(0);
|
||||
expect(row.terminal_event_at).toBeGreaterThanOrEqual(row.executor_started_at);
|
||||
}
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async function control(request, action) {
|
||||
expect((await request.post(`/pm_execution_fixture/gate/${action}`)).ok()).toBe(true);
|
||||
}
|
||||
|
||||
async function entered(request) {
|
||||
await expect.poll(async () => (await (await request.get('/pm_execution_fixture/gate')).json()).entered,
|
||||
{ timeout: 10000 }).toBe(true);
|
||||
}
|
||||
|
||||
test('headless actual queue execution persists without a browser or WebSocket client', async ({ execution }) => {
|
||||
const id = await submit(execution.request, cached(`headless ${sensitive[0]}`));
|
||||
const item = await history(execution.request, id);
|
||||
expect(item.status.status_str).toBe('success');
|
||||
expect((await rows(execution, id, ['succeeded']))[0].outcome).toBe('succeeded');
|
||||
});
|
||||
|
||||
test('cached reruns after failure survive renderer switches both ways and workflow reload', async ({ execution, page, context }, testInfo) => {
|
||||
const request = execution.request;
|
||||
await context.route('**/*', async route => {
|
||||
const path = new URL(route.request().url()).pathname.replace(/^\/api\//, '/');
|
||||
if (path === '/settings' && route.request().method() === 'GET') {
|
||||
const response = await route.fetch();
|
||||
return route.fulfill({ json: { ...await response.json(),
|
||||
'Comfy.VueNodes.Enabled': testInfo.project.metadata.initialRenderer,
|
||||
'Comfy.TutorialCompleted': true, 'Comfy.Workflow.Persist': false,
|
||||
'Comfy.Notification.ShowVersionUpdates': false } });
|
||||
}
|
||||
return route.continue();
|
||||
});
|
||||
const errors = [];
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.getByRole('status', { name: 'Loading ComfyUI' })).toBeHidden({ timeout: 30000 });
|
||||
await page.waitForFunction(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
return !!app.graph && !!globalThis.LiteGraph?.registered_node_types?.PMHistoryCached;
|
||||
});
|
||||
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(testInfo.project.metadata.initialRenderer);
|
||||
await page.evaluate(async value => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
app.graph.clear();
|
||||
const node = LiteGraph.createNode('PMHistoryCached');
|
||||
app.graph.add(node);
|
||||
node.widgets.find(widget => widget.name === 'value').value = value;
|
||||
}, `cache ${sensitive[0]}`);
|
||||
const graph = await page.evaluate(async () => (await (await import('/scripts/app.js')).app.graphToPrompt()).output);
|
||||
expect(Object.values(graph).map(node => node.class_type)).toEqual(['PMHistoryCached']);
|
||||
const cachedNode = Object.keys(graph)[0];
|
||||
await history(request, await submit(request, graph));
|
||||
const failureGraph = { ...graph, '999': { class_type: 'PMHistoryFailure', inputs: { value: sensitive[2] } } };
|
||||
const failed = await submit(request, failureGraph);
|
||||
const failureHistory = await history(request, failed, 'execution_error');
|
||||
expect(failureHistory.status.messages.find(([kind]) => kind === 'execution_error')[1].exception_message).toContain(sensitive[2]);
|
||||
const failedRows = await rows(execution, failed, ['failed']);
|
||||
expect(failedRows[0]).toMatchObject({ outcome: 'failed', node_id: '999', node_type: 'PMHistoryFailure', exception_type: 'ValueError' });
|
||||
for (const renderer of [!testInfo.project.metadata.initialRenderer, testInfo.project.metadata.initialRenderer]) {
|
||||
await page.evaluate(async renderer => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);
|
||||
await app.loadGraphData(app.graph.serialize());
|
||||
}, renderer);
|
||||
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
|
||||
const reloaded = await page.evaluate(async () => (await (await import('/scripts/app.js')).app.graphToPrompt()).output);
|
||||
const id = await submit(request, reloaded);
|
||||
const item = await history(request, id);
|
||||
expect(item.status.messages.find(([kind]) => kind === 'execution_cached')[1].nodes).toContain(cachedNode);
|
||||
expect((await rows(execution, id, ['succeeded']))[0].outcome).toBe('succeeded');
|
||||
}
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test('reusing a public UUID creates two distinct persisted attempts', async ({ execution }) => {
|
||||
const id = randomUUID();
|
||||
await history(execution.request, await submit(execution.request, cached('first reused UUID'), id));
|
||||
await rows(execution, id, ['succeeded']);
|
||||
const graph = cached('second reused UUID');
|
||||
await submit(execution.request, graph, id);
|
||||
await expect.poll(async () => {
|
||||
const response = await execution.request.get(`/history/${id}`);
|
||||
return (await response.json())[id]?.prompt[2]?.['1']?.inputs?.value;
|
||||
}, { timeout: 15000 }).toBe('second reused UUID');
|
||||
const attempts = await rows(execution, id, ['succeeded', 'succeeded']);
|
||||
expect(attempts).toHaveLength(2);
|
||||
expect(new Set(attempts.map(row => row.run_id)).size).toBe(2);
|
||||
expect(new Set(attempts.map(row => row.queue_item_id)).size).toBe(2);
|
||||
expect(new Set(attempts.map(row => row.session_id)).size).toBe(1);
|
||||
});
|
||||
|
||||
test('real worker interruption persists an interrupted outcome', async ({ execution }) => {
|
||||
const request = execution.request;
|
||||
await control(request, 'hold');
|
||||
try {
|
||||
const id = await submit(request, gate(`interrupt ${sensitive[0]}`));
|
||||
await entered(request);
|
||||
expect((await request.post('/interrupt', { data: {} })).ok()).toBe(true);
|
||||
await history(request, id, 'execution_interrupted');
|
||||
expect((await rows(execution, id, ['interrupted']))[0].outcome).toBe('interrupted');
|
||||
} finally { await control(request, 'release'); }
|
||||
});
|
||||
|
||||
test('rejected and removed-before-dequeue jobs create no persisted attempts', async ({ execution }) => {
|
||||
const request = execution.request;
|
||||
const rejected = randomUUID();
|
||||
const response = await request.post('/prompt', { data: { prompt_id: rejected,
|
||||
prompt: { '1': { class_type: 'PMHistoryMissing', inputs: {} } } } });
|
||||
expect(response.status()).toBe(400);
|
||||
await control(request, 'hold');
|
||||
try {
|
||||
const blocking = await submit(request, gate('hold worker until queued item is deleted'));
|
||||
await entered(request);
|
||||
const removed = await submit(request, cached('must never dequeue'));
|
||||
const queued = (await (await request.get('/queue')).json()).queue_pending;
|
||||
expect(queued.some(item => item[1] === removed)).toBe(true);
|
||||
expect((await request.post('/queue', { data: { delete: [removed] } })).ok()).toBe(true);
|
||||
expect((await (await request.get('/queue')).json()).queue_pending.some(item => item[1] === removed)).toBe(false);
|
||||
await control(request, 'release');
|
||||
await history(request, blocking);
|
||||
await rows(execution, blocking, ['succeeded']);
|
||||
for (const id of [rejected, removed]) {
|
||||
expect((await (await request.get(`/history/${id}`)).json())[id]).toBeUndefined();
|
||||
expect((await rows(execution, id, [])).length).toBe(0);
|
||||
}
|
||||
} finally { await control(request, 'release'); }
|
||||
});
|
||||
|
||||
function recipeGraph({ text = 'context positive', negative = '', kind = 'PromptManagerPair', prepend = '', append = '', dynamic = false } = {}) {
|
||||
const graph = {
|
||||
'10': { class_type: 'PMHistoryInputs', inputs: {} },
|
||||
'20': { class_type: kind, inputs: { text: dynamic ? ['11', 0] : text,
|
||||
prepend_text: prepend, append_text: append, ...(kind === 'PromptManagerText' ? {} : { clip: ['10', 0] }),
|
||||
...(kind === 'PromptManagerPair' ? { negative_text: negative } : {}) } },
|
||||
'30': { class_type: 'CLIPTextEncode', inputs: { text: negative, clip: ['10', 0] } },
|
||||
'40': { class_type: 'KSampler', inputs: { model: ['10', 1], latent_image: ['10', 2],
|
||||
positive: ['20', 0], negative: kind === 'PromptManagerPair' ? ['20', 1] : ['30', 0],
|
||||
seed: 1, steps: 1, cfg: 1, sampler_name: 'euler', scheduler: 'normal', denoise: 1 } },
|
||||
'50': { class_type: 'PMHistoryOutput', inputs: { value: ['40', 0] } },
|
||||
};
|
||||
if (kind === 'PromptManagerText') {
|
||||
graph['31'] = { class_type: 'CLIPTextEncode', inputs: { text: ['20', 0], clip: ['10', 0] } };
|
||||
graph['40'].inputs.positive = ['31', 0];
|
||||
}
|
||||
if (dynamic) graph['11'] = { class_type: 'PMHistoryCached', inputs: { value: text } };
|
||||
return graph;
|
||||
}
|
||||
|
||||
async function capture(execution, id, outcome = 'succeeded') {
|
||||
const [run] = await rows(execution, id, [outcome]);
|
||||
let result;
|
||||
await expect.poll(async () => {
|
||||
result = await readExecutionRuns(execution.databasePath, id);
|
||||
return result.captures[run.run_id].state;
|
||||
}, { timeout: 10000, message: 'Background writer persisted context finalization' }).not.toBe('not_captured');
|
||||
const response = await execution.request.get(`/prompt_manager/history/runs/${run.run_id}`);
|
||||
expect(response.ok()).toBe(true);expect(response.headers()['cache-control']).toBe('no-store');
|
||||
const detail = await response.json();
|
||||
const fields = contexts => contexts.map(({ anchor_id, context_id, positive, negative, evidence }) => ({ anchor_id, context_id, positive, negative, evidence }));
|
||||
expect(fields(detail.contexts)).toEqual(fields(result.captures[run.run_id].contexts));
|
||||
expect(detail.run).toMatchObject({ run_id: run.run_id, outcome, queue_item_id: String(run.queue_item_id) });
|
||||
return result.captures[run.run_id];
|
||||
}
|
||||
|
||||
test.describe('public chronological history with the actual acknowledged host bridge', () => {
|
||||
test.beforeEach(async ({ execution }) => { expect(execution.databasePath).toBeTruthy(); });
|
||||
installHostHooks(test, ['PromptManagerPair']);
|
||||
|
||||
test('three actual attempts expose two contexts and send exact frozen roles through both renderer switches and reload', async ({ execution, page, context }, testInfo) => {
|
||||
test.setTimeout(120000);
|
||||
const positive = `public chronology ${testInfo.project.name} ${randomUUID()}\n猫`, attempts = [];
|
||||
for (const negative of ['negative A', 'negative A', 'negative B']) {
|
||||
const graph = recipeGraph({ text: positive, negative, prepend: ' pre ', append: ' post ' });
|
||||
graph['60'] = { class_type: 'PMHistoryImages', inputs: { value: 128, batch: 2, latent: ['40', 0] } };
|
||||
graph['70'] = { class_type: 'SaveImage', inputs: { images: ['60', 0], filename_prefix: 'chronology/public' } };
|
||||
graph['71'] = { class_type: 'PreviewImage', inputs: { images: ['60', 0] } };
|
||||
const id = await submit(execution.request, graph);
|
||||
await history(execution.request, id);const frozen = await capture(execution, id);
|
||||
const persisted = await readExecutionRuns(execution.databasePath, id);attempts.push({ row: persisted.rows[0], context: frozen.contexts[0] });
|
||||
}
|
||||
const positiveId = attempts[0].context.positive_prompt_id;
|
||||
const response = await execution.request.get(`/prompt_manager/library/${positiveId}/history`);expect(response.ok()).toBe(true);const window = await response.json();
|
||||
expect(window.recorded_run_count).toBe(3);expect(window.items.map(item => item.run_id)).toEqual(attempts.map(item => item.row.run_id));
|
||||
expect(window.context_summaries).toHaveLength(2);expect(new Set(attempts.map(item => item.context.positive.raw))).toEqual(new Set([positive]));
|
||||
expect(attempts.map(item => item.context.evidence.positive.effective.source)).toEqual(['runtime_return', 'host_cache_output', 'runtime_return']);
|
||||
const workspace = await context.newPage();const errors = [];workspace.on('pageerror', error => errors.push(error.message));
|
||||
try {
|
||||
const destination = await page.evaluate(async () => {const { app } = await import('/scripts/app.js');const node = LiteGraph.createNode('PromptManagerPair');app.graph.add(node);return node.id;});
|
||||
for (const renderer of [testInfo.project.metadata.initialRenderer, !testInfo.project.metadata.initialRenderer, testInfo.project.metadata.initialRenderer]) {
|
||||
await page.evaluate(async renderer => {const { app } = await import('/scripts/app.js');await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);await app.loadGraphData(app.graph.serialize());}, renderer);
|
||||
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
|
||||
await workspace.goto(`/prompt_manager/workspace#/library/${positiveId}`);
|
||||
const recorded = workspace.getByRole('region', { name: 'Recorded executions', exact: true });await expect(recorded.locator('[data-context-branch]')).toHaveCount(2);
|
||||
await recorded.locator(`button[data-run-id="${attempts[0].row.run_id}"]`).click();
|
||||
const run = workspace.getByRole('dialog', { name: 'Recorded run', exact: true });await expect(run).toBeVisible();
|
||||
await expect(run.locator('[data-receipt]')).toHaveCount(4);
|
||||
await run.getByRole('button', { name: 'View current file', exact: true }).first().click();
|
||||
const viewer = workspace.getByRole('dialog', { name: 'Image preview', exact: true });await expect(viewer).toBeVisible();
|
||||
await expect(viewer.locator('.viewer-canvas img')).toBeVisible();
|
||||
await workspace.keyboard.press('Escape');await expect(run).toBeVisible();
|
||||
const frozen = attempts[0].context;
|
||||
for (const mode of ['Raw', 'Effective']) {
|
||||
await run.getByRole('button', { name: mode, exact: true }).click();
|
||||
for (const [role, value] of [['Positive', frozen.positive[mode.toLowerCase()]], ['Negative', frozen.negative[mode.toLowerCase()]]]) expect(await run.getByRole('region', { name: `${role} prompt`, exact: true }).locator('pre').evaluate(node => node.textContent)).toBe(value);
|
||||
await page.evaluate(async id => {const { app } = await import('/scripts/app.js');const node = app.graph.getNodeById(id);node.widgets.find(w => w.name === 'text').value = 'Before send';window.chronologyEffects = { queue: 0, load: 0 };window.chronologyOriginals = {};for (const [method, key] of [['queuePrompt', 'queue'], ['loadGraphData', 'load']]) {const original = app[method];window.chronologyOriginals[method] = original;app[method] = function(...args) {window.chronologyEffects[key]++;return original.apply(this, args);};}}, destination);
|
||||
try {
|
||||
await run.getByRole('button', { name: 'Send prompt pair', exact: true }).click();const sender = workspace.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({ index: 1 });
|
||||
await sender.getByRole('combobox', { name: 'Destination node', exact: true }).selectOption({ index: 1 });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.');
|
||||
const applied = await page.evaluate(async id => {const { app } = await import('/scripts/app.js');const node = app.graph.getNodeById(id);return { positive: node.widgets.find(w => w.name === 'text').value, negative: node.widgets.find(w => w.name === 'negative_text').value, effects: window.chronologyEffects };}, destination);
|
||||
expect(applied).toEqual({ positive: frozen.positive[mode.toLowerCase()], negative: frozen.negative[mode.toLowerCase()], effects: { queue: 0, load: 0 } });
|
||||
await workspace.keyboard.press('Escape');await expect(run).toBeVisible();
|
||||
} finally {await page.evaluate(async () => {const { app } = await import('/scripts/app.js');Object.assign(app, window.chronologyOriginals);});}
|
||||
}
|
||||
await workspace.keyboard.press('Escape');await expect(recorded).toBeVisible();
|
||||
}
|
||||
const versions = await (await execution.request.get('/system_stats')).json();await testInfo.attach('chronology-runtime.json', { body: JSON.stringify(versions.system, null, 2), contentType: 'application/json' });
|
||||
expect(errors).toEqual([]);
|
||||
} finally {await workspace.close();}
|
||||
});
|
||||
});
|
||||
|
||||
test('a cached dynamic unknown positive stays readable by run UUID and outside the known raw Library scope', async ({ execution }) => {
|
||||
const text = `unknown positive ${randomUUID()}`, graph = recipeGraph({ text, dynamic: true });
|
||||
const first = await submit(execution.request, graph);await history(execution.request, first);const known = (await capture(execution, first)).contexts[0];
|
||||
const second = await submit(execution.request, graph);await history(execution.request, second);const unknown = (await capture(execution, second)).contexts[0];
|
||||
expect(unknown.positive.raw).toBeNull();expect(unknown.positive.effective).toBe(known.positive.effective);
|
||||
const result = await readExecutionRuns(execution.databasePath, second), runId = result.rows[0].run_id;
|
||||
const detail = await execution.request.get(`/prompt_manager/history/runs/${runId}`);expect(detail.ok()).toBe(true);expect((await detail.json()).contexts[0].positive.raw).toBeNull();
|
||||
const response = await execution.request.get(`/prompt_manager/library/${known.positive_prompt_id}/history`);expect(response.ok()).toBe(true);const window = await response.json();
|
||||
expect(window.recorded_run_count).toBe(1);expect(window.items.map(item => item.run_id)).not.toContain(runId);
|
||||
});
|
||||
|
||||
test('actual PM A,A,B jobs persist two combinations and one identical positive snapshot', async ({ execution }) => {
|
||||
const captures = [];
|
||||
const positiveSnapshotIds = [];
|
||||
for (const negative of ['negative A', 'negative A', 'negative B']) {
|
||||
const id = await submit(execution.request, recipeGraph({ negative }));
|
||||
await history(execution.request, id);
|
||||
captures.push(await capture(execution, id));
|
||||
const persisted = await readExecutionRuns(execution.databasePath, id);
|
||||
positiveSnapshotIds.push(persisted.snapshotIds[persisted.rows[0].run_id][0].positive_snapshot_id);
|
||||
}
|
||||
expect(new Set(positiveSnapshotIds).size).toBe(1);
|
||||
expect(captures.map(value => value.state)).toEqual(['complete', 'complete', 'complete']);
|
||||
const contexts = captures.map(value => value.contexts[0]);
|
||||
expect(new Set(contexts.map(value => value.context_id)).size).toBe(2);
|
||||
expect(new Set(contexts.map(value => JSON.stringify(value.positive))).size).toBe(1);
|
||||
expect(contexts[0].evidence.positive.effective.source).toBe('runtime_return');
|
||||
expect(contexts[1].evidence.positive.effective.source).toBe('host_cache_output');
|
||||
expect(contexts[1].evidence.positive.raw.source).toBe('graph_literal');
|
||||
expect(contexts[1].evidence.role_provenance).toBe('inferred');
|
||||
expect(contexts[2].negative.raw).toBe('negative B');
|
||||
});
|
||||
|
||||
test('fresh registered PM variants preserve exact raw whitespace and known empty negative', async ({ execution }) => {
|
||||
for (const kind of ['PromptManager', 'PromptManagerFocused', 'PromptManagerSplit', 'PromptManagerPair', 'PromptManagerText']) {
|
||||
const graph = recipeGraph({ kind, text: ` ${kind} e\u0301\n`, prepend: ' pre \t', append: '\n post ' });
|
||||
const id = await submit(execution.request, graph);
|
||||
await history(execution.request, id);
|
||||
const value = (await capture(execution, id)).contexts[0];
|
||||
expect(value.positive).toEqual({ raw: ` ${kind} e\u0301\n`, prepend: ' pre \t', append: '\n post ', effective: `pre ${kind} e\u0301\n post` });
|
||||
expect(value.negative.raw).toBe('');
|
||||
expect(value.negative.effective).toBe('');
|
||||
expect(value.evidence.positive.raw).toMatchObject({ provenance: 'recorded', source: 'runtime_return' });
|
||||
}
|
||||
});
|
||||
|
||||
test('fully cached text uses old effective output after external trigger configuration changes', async ({ execution }) => {
|
||||
const request = execution.request;
|
||||
const configure = suffix => request.post('/pm_execution_fixture/injection', { data: { suffix } });
|
||||
try {
|
||||
expect((await configure(' trigger-A')).ok()).toBe(true);
|
||||
const graph = recipeGraph({ text: 'cached trigger recipe', negative: 'trigger negative' });
|
||||
const first = await submit(request, graph);
|
||||
await history(request, first);
|
||||
const original = (await capture(execution, first)).contexts[0];
|
||||
expect(original.positive.effective).toBe('cached trigger recipe trigger-A');
|
||||
expect((await configure(' trigger-B')).ok()).toBe(true);
|
||||
const second = await submit(request, graph);
|
||||
const item = await history(request, second);
|
||||
expect(item.status.messages.find(([kind]) => kind === 'execution_cached')[1].nodes).toEqual(expect.arrayContaining(['20', '40', '50']));
|
||||
const repeated = (await capture(execution, second)).contexts[0];
|
||||
expect(repeated.context_id).toBe(original.context_id);
|
||||
expect(repeated.positive.effective).toBe(original.positive.effective);
|
||||
expect(repeated.evidence.positive.effective.source).toBe('host_cache_output');
|
||||
} finally { expect((await configure('')).ok()).toBe(true); }
|
||||
});
|
||||
|
||||
test('crossed Pair and Text negative roles remain independent declared contexts', async ({ execution }) => {
|
||||
const graph = recipeGraph({ text: 'pair one positive', negative: 'pair one negative' });
|
||||
graph['21'] = { class_type: 'PromptManagerPair', inputs: { text: 'pair two positive', negative_text: 'pair two negative', clip: ['10', 0] } };
|
||||
graph['22'] = { class_type: 'PromptManagerText', inputs: { text: 'text negative role' } };
|
||||
graph['32'] = { class_type: 'CLIPTextEncode', inputs: { text: ['22', 0], clip: ['10', 0] } };
|
||||
graph['40'].inputs.negative = ['21', 1];
|
||||
graph['41'] = structuredClone(graph['40']);
|
||||
graph['41'].inputs.positive = ['21', 0];
|
||||
graph['41'].inputs.negative = ['32', 0];
|
||||
graph['51'] = { class_type: 'PMHistoryOutput', inputs: { value: ['41', 0] } };
|
||||
const id = await submit(execution.request, graph);
|
||||
await history(execution.request, id);
|
||||
const result = await capture(execution, id);
|
||||
expect(result.contexts.map(value => [value.anchor_id, value.positive.raw, value.negative.raw])).toEqual([
|
||||
['40', 'pair one positive', 'pair two negative'], ['41', 'pair two positive', 'text negative role'],
|
||||
]);
|
||||
for (const value of result.contexts) expect(value.evidence.negative.effective.source).toBe('runtime_return');
|
||||
});
|
||||
|
||||
test('successful source capture survives downstream failure and real interruption', async ({ execution }) => {
|
||||
const request = execution.request;
|
||||
for (const mode of ['failure', 'gate']) {
|
||||
const graph = recipeGraph({ text: `source before ${mode}` });
|
||||
graph['50'].inputs.mode = mode;
|
||||
if (mode === 'gate') await control(request, 'hold');
|
||||
try {
|
||||
const id = await submit(request, graph);
|
||||
if (mode === 'gate') {
|
||||
await entered(request);
|
||||
expect((await request.post('/interrupt', { data: {} })).ok()).toBe(true);
|
||||
}
|
||||
await history(request, id, mode === 'gate' ? 'execution_interrupted' : 'execution_error');
|
||||
const result = await capture(execution, id, mode === 'gate' ? 'interrupted' : 'failed');
|
||||
expect(result.contexts[0].positive.effective).toBe(`source before ${mode}`);
|
||||
expect(result.contexts[0].evidence.positive.effective.source).toBe('runtime_return');
|
||||
} finally { await control(request, 'release'); }
|
||||
}
|
||||
});
|
||||
|
||||
test('cached dynamic raw stays unknown while literal affixes and effective cache text survive', async ({ execution }) => {
|
||||
const graph = recipeGraph({ dynamic: true, text: 'runtime dynamic recipe', prepend: ' pre ', append: ' post ' });
|
||||
const first = await submit(execution.request, graph);
|
||||
await history(execution.request, first);
|
||||
expect((await capture(execution, first)).contexts[0].positive.raw).toBe('runtime dynamic recipe');
|
||||
const second = await submit(execution.request, graph);
|
||||
await history(execution.request, second);
|
||||
const value = (await capture(execution, second)).contexts[0];
|
||||
expect(value.positive).toEqual({ raw: null, prepend: ' pre ', append: ' post ', effective: 'pre runtime dynamic recipe post' });
|
||||
expect(value.evidence.positive.raw.provenance).toBe('unknown');
|
||||
expect(value.evidence.positive.effective.source).toBe('host_cache_output');
|
||||
});
|
||||
|
||||
test('unsupported conditioning transforms and duplicate list invocations preserve uncertainty', async ({ execution }) => {
|
||||
const transformed = recipeGraph({ text: 'unsupported transformed recipe' });
|
||||
transformed['35'] = { class_type: 'PMHistoryTransform', inputs: { conditioning: ['20', 0] } };
|
||||
transformed['40'].inputs.positive = ['35', 0];
|
||||
const unsupported = await submit(execution.request, transformed);
|
||||
await history(execution.request, unsupported);
|
||||
const unknown = await capture(execution, unsupported);
|
||||
expect(unknown.state).toBe('partial');
|
||||
expect(unknown.contexts[0].positive.effective).toBeNull();
|
||||
const graph = recipeGraph({ text: 'duplicate runtime list' });
|
||||
graph['11'] = { class_type: 'PMHistoryList', inputs: { value: 'duplicate runtime list' } };
|
||||
graph['20'].inputs.text = ['11', 0];
|
||||
const id = await submit(execution.request, graph);
|
||||
await history(execution.request, id);
|
||||
const result = await capture(execution, id);
|
||||
expect(result.state).toBe('ambiguous');
|
||||
expect(result.reason).toBe('multiple_samples');
|
||||
expect(result.contexts[0].positive.effective).toBeNull();
|
||||
});
|
||||
|
||||
test('real executor Classic LRU RAM and disabled caches preserve honest evidence and usage', async ({ execution }) => {
|
||||
const request = execution.request;
|
||||
for (const mode of ['CLASSIC', 'LRU', 'RAM_PRESSURE', 'NONE']) {
|
||||
expect((await request.post('/pm_execution_fixture/cache', { data: { mode } })).ok()).toBe(true);
|
||||
const graph = recipeGraph({ text: `cache mode ${mode}`, negative: `cache mode negative ${mode}` });
|
||||
const results = [];
|
||||
for (let index = 0; index < 2; index++) {
|
||||
const id = await submit(request, graph);
|
||||
await history(request, id);
|
||||
results.push(await capture(execution, id));
|
||||
const persisted = await readExecutionRuns(execution.databasePath, id);
|
||||
const [run] = persisted.rows;
|
||||
expect(persisted.usage[run.run_id]).toEqual([
|
||||
{ role: 'positive', run_count: index + 1 },
|
||||
{ role: 'negative', run_count: 0 },
|
||||
{ role: 'pair', run_count: index + 1 },
|
||||
]);
|
||||
}
|
||||
expect(results[0].state).toBe('complete');
|
||||
expect(results[1].contexts[0].context_id).toBe(results[0].contexts[0].context_id);
|
||||
expect(results[1].contexts[0].evidence.positive.effective.source).toBe(mode === 'NONE' ? 'runtime_return' : 'host_cache_output');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { copyFile, mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createServer } from 'node:net';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { launchSharedHost as launchComfyHost, preparePromptManagerPlugin } from '../../tools/vendor/comfyui-host.mjs';
|
||||
import runtime from '../../tools/vendor/comfyui-runtime.json' with { type: 'json' };
|
||||
|
||||
const repositoryRoot = fileURLToPath(new URL('../../', import.meta.url));
|
||||
|
||||
export async function launchExecutionHost({ checkout = process.env.PM_COMFY_ROOT,
|
||||
repository = process.env.PM_EXECUTION_REPOSITORY || repositoryRoot,
|
||||
python = process.env.PM_TEST_PYTHON || 'python', port = 8196, pins = runtime,
|
||||
logPath = join(repositoryRoot, 'tools/vendor/test-results/executions/host.log'), forwardOutput = true } = {}) {
|
||||
if (port !== 8196) throw new Error('Execution tests reserve managed loopback port 8196');
|
||||
await new Promise((resolve, reject) => {
|
||||
const probe = createServer();
|
||||
probe.once('error', error => reject(new Error('Execution loopback port 8196 is already in use', { cause: error })));
|
||||
probe.listen({ host: '127.0.0.1', port, exclusive: true }, () => probe.close(error => error ? reject(error) : resolve()));
|
||||
});
|
||||
const fixtureDirectory = await mkdtemp(join(tmpdir(), 'pm-execution-fixture-'));
|
||||
let host;
|
||||
try {
|
||||
await copyFile(new URL('./fixtures/ledger_nodes.py', import.meta.url), join(fixtureDirectory, '__init__.py'));
|
||||
execFileSync('git', ['init', '--quiet', fixtureDirectory]);
|
||||
execFileSync('git', ['-C', fixtureDirectory, 'add', '__init__.py']);
|
||||
host = await launchComfyHost({ checkout, repository, python, port, pins, logPath, forwardOutput,
|
||||
pluginName: 'promptmanager', preparePlugin: preparePromptManagerPlugin,
|
||||
additionalPlugins: [{ pluginName: 'pm_execution_fixture', repository: fixtureDirectory }],
|
||||
});
|
||||
} catch (error) {
|
||||
await rm(fixtureDirectory, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
host.finished.finally(() => rm(fixtureDirectory, { recursive: true, force: true })).catch(() => {});
|
||||
return { host, fixtureDirectory, databasePath: join(host.stateDirectory, 'prompts.db'), async dispose() {
|
||||
try { await host.stop(); }
|
||||
finally { await rm(fixtureDirectory, { recursive: true, force: true }); }
|
||||
} };
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import { test, readExecutionRuns } from './execution-fixture.mjs';
|
||||
|
||||
async function resetCache({ execution }) {
|
||||
expect((await execution.request.post('/pm_execution_fixture/cache', { data: { mode: 'RAM_PRESSURE' } })).ok()).toBe(true);
|
||||
}
|
||||
|
||||
test.beforeEach(resetCache);
|
||||
test.afterAll(resetCache);
|
||||
|
||||
function images(value, batch = 1) {
|
||||
return {
|
||||
'1': { class_type: 'PMHistoryImages', inputs: { value, batch } },
|
||||
'2': { class_type: 'SaveImage', inputs: { images: ['1', 0], filename_prefix: 'output-evidence/nested/image' } },
|
||||
'3': { class_type: 'PreviewImage', inputs: { images: ['1', 0] } },
|
||||
};
|
||||
}
|
||||
|
||||
async function submit(execution, prompt, id = randomUUID()) {
|
||||
const response = await execution.request.post('/prompt', { data: { prompt, prompt_id: id } });
|
||||
expect(response.ok()).toBe(true);
|
||||
expect((await response.json()).prompt_id).toBe(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
async function persisted(execution, id, outcomes) {
|
||||
let result;
|
||||
await expect.poll(async () => {
|
||||
result = await readExecutionRuns(execution.databasePath, id);
|
||||
return { outcomes: result.rows.map(row => row.outcome),
|
||||
states: result.rows.map(row => result.outputs[row.run_id].state) };
|
||||
}, { timeout: 15000, message: 'Exact attempts and final output evidence reach SQLite' }).toEqual({
|
||||
outcomes, states: outcomes.map(() => 'complete'),
|
||||
});
|
||||
for (const row of result.rows) {
|
||||
const detailResponse = await execution.request.get(`/prompt_manager/history/runs/${row.run_id}`);
|
||||
expect(detailResponse.ok()).toBe(true);
|
||||
const detail = await detailResponse.json();
|
||||
expect(detail.run).toMatchObject({ run_id: row.run_id, outcome: row.outcome, queue_item_id: String(row.queue_item_id) });
|
||||
const response = await execution.request.get(`/prompt_manager/history/runs/${row.run_id}/outputs?epoch=${detail.history_epoch}`);
|
||||
expect(response.ok()).toBe(true);expect(response.headers()['cache-control']).toBe('no-store');
|
||||
const publicOutputs = await response.json(), captured = result.outputs[row.run_id];
|
||||
expect(publicOutputs.capture).toMatchObject({ state: captured.state, reason: captured.reason, source: captured.source });
|
||||
expect(publicOutputs.retained_occurrence_count).toBe(captured.references.length);
|
||||
expect(publicOutputs.items.map(item => ({ ...item.occurrence, ...item.reference })).map(({ run_id, ...reference }) => reference)).toEqual(
|
||||
captured.references.map(({ output_node_id, image_ordinal, folder_type, subfolder, filename, path_style, root_key }) =>
|
||||
({ output_node_id, image_ordinal, folder_type, subfolder, filename, path_style, root_key })));
|
||||
for (const item of publicOutputs.items) {
|
||||
expect(item).toMatchObject({ file_identity: 'unverified', newly_generated: 'unknown', context_membership: 'unknown' });
|
||||
expect(item.current_candidate.evidence_class).toBe('locator_candidate');
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function finalHistory(execution, id) {
|
||||
const response = await execution.request.get(`/history/${id}`);
|
||||
expect(response.ok()).toBe(true);
|
||||
const item = (await response.json())[id];
|
||||
expect(item.prompt[1]).toBe(id);
|
||||
return item;
|
||||
}
|
||||
|
||||
function expectHistory(capture, history) {
|
||||
const reported = Object.entries(history.outputs).flatMap(([node, output]) =>
|
||||
(output.images || []).map((image, ordinal) => ({ output_node_id: node, image_ordinal: ordinal,
|
||||
folder_type: image.type, subfolder: image.subfolder, filename: image.filename })));
|
||||
reported.sort((a, b) => a.output_node_id.localeCompare(b.output_node_id) || a.image_ordinal - b.image_ordinal);
|
||||
expect(capture).toMatchObject({ state: 'complete', reason: reported.length ? 'reported' : 'none_reported',
|
||||
source: 'host_final_history', format_version: 1 });
|
||||
expect(capture.references.map(({ output_node_id, image_ordinal, folder_type, subfolder, filename }) =>
|
||||
({ output_node_id, image_ordinal, folder_type, subfolder, filename }))).toEqual(reported);
|
||||
for (const reference of capture.references) {
|
||||
expect(reference).toMatchObject({ path_style: 'posix', file_identity: 'unverified',
|
||||
newly_generated: 'unknown', context_membership: 'unknown', transient: reference.folder_type === 'temp' });
|
||||
expect(reference.root_key).toMatch(/^[a-f0-9]{64}$/);
|
||||
}
|
||||
}
|
||||
|
||||
async function gate(execution, action) {
|
||||
expect((await execution.request.post(`/pm_execution_fixture/gate/${action}`)).ok()).toBe(true);
|
||||
}
|
||||
|
||||
async function entered(execution) {
|
||||
await expect.poll(async () => (await (await execution.request.get('/pm_execution_fixture/gate')).json()).entered,
|
||||
{ timeout: 10000 }).toBe(true);
|
||||
}
|
||||
|
||||
function downstream(value, mode) {
|
||||
const graph = images(value);
|
||||
delete graph['3'];
|
||||
graph['4'] = { class_type: 'PMHistoryImageOutput', inputs: { images: ['2', 0], mode } };
|
||||
return graph;
|
||||
}
|
||||
|
||||
test('headless stock SaveImage and PreviewImage retain A,A,B batches under a reused public UUID', async ({ execution }) => {
|
||||
const id = randomUUID();
|
||||
const graph = images(31, 2);
|
||||
let result;
|
||||
for (const [index, value] of [31, 31, 73].entries()) {
|
||||
graph['1'].inputs.value = value;
|
||||
await submit(execution, graph, id);
|
||||
result = await persisted(execution, id, Array(index + 1).fill('succeeded'));
|
||||
const item = await finalHistory(execution, id);
|
||||
const row = result.rows[index];
|
||||
expectHistory(result.outputs[row.run_id], item);
|
||||
expect(result.outputs[row.run_id].references).toHaveLength(4);
|
||||
expect(result.captures[row.run_id].contexts).toEqual([]);
|
||||
expect(result.usage[row.run_id]).toEqual([]);
|
||||
if (index === 1) expect(item.status.messages.find(([kind]) => kind === 'execution_cached')[1].nodes).toEqual(expect.arrayContaining(['1', '2', '3']));
|
||||
}
|
||||
const attempts = result.rows;
|
||||
expect(new Set(attempts.map(row => row.run_id)).size).toBe(3);
|
||||
expect(new Set(attempts.map(row => row.queue_item_id)).size).toBe(3);
|
||||
const captures = attempts.map(row => result.outputs[row.run_id]);
|
||||
expect(captures.map(capture => capture.state)).toEqual(['complete', 'complete', 'complete']);
|
||||
expect(captures[1].references).toEqual(captures[0].references);
|
||||
expect(captures[2].references).not.toEqual(captures[0].references);
|
||||
expect(captures[0].references[0].context_membership).toBe('unknown');
|
||||
expect(captures[0].references.filter(reference => reference.transient)).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('stock image evidence survives disabled PNG metadata', async ({ execution }) => {
|
||||
const enabled = images(44);
|
||||
delete enabled['3'];
|
||||
const enabledId = await submit(execution, enabled);
|
||||
const enabledResult = await persisted(execution, enabledId, ['succeeded']);
|
||||
const enabledReference = enabledResult.outputs[enabledResult.rows[0].run_id].references[0];
|
||||
const withMetadata = await execution.request.get('/view', { params: {
|
||||
filename: enabledReference.filename, subfolder: enabledReference.subfolder, type: enabledReference.folder_type,
|
||||
} });
|
||||
expect(withMetadata.ok()).toBe(true);
|
||||
expect((await withMetadata.body()).includes(Buffer.from('tEXtprompt'))).toBe(true);
|
||||
expect((await execution.request.post('/pm_execution_fixture/metadata', { data: { disabled: true } })).ok()).toBe(true);
|
||||
try {
|
||||
const graph = images(45);
|
||||
delete graph['3'];
|
||||
const id = await submit(execution, graph);
|
||||
const result = await persisted(execution, id, ['succeeded']);
|
||||
const capture = result.outputs[result.rows[0].run_id];
|
||||
expectHistory(capture, await finalHistory(execution, id));
|
||||
expect(capture.references).toHaveLength(1);
|
||||
const { filename, subfolder, folder_type: type } = capture.references[0];
|
||||
const response = await execution.request.get('/view', { params: { filename, subfolder, type } });
|
||||
expect(response.ok()).toBe(true);
|
||||
const png = await response.body();
|
||||
expect(png.subarray(1, 4).toString()).toBe('PNG');
|
||||
expect(png.includes(Buffer.from('tEXtprompt'))).toBe(false);
|
||||
} finally {
|
||||
expect((await execution.request.post('/pm_execution_fixture/metadata', { data: { disabled: false } })).ok()).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('text-only output reports a complete empty image set', async ({ execution }) => {
|
||||
const id = await submit(execution, { '1': { class_type: 'PMHistoryCached', inputs: { value: 'text has no image locator' } } });
|
||||
const result = await persisted(execution, id, ['succeeded']);
|
||||
const capture = result.outputs[result.rows[0].run_id];
|
||||
expectHistory(capture, await finalHistory(execution, id));
|
||||
expect(capture.references).toEqual([]);
|
||||
expect(capture.reason).toBe('none_reported');
|
||||
});
|
||||
|
||||
test('batched outputs preserve PM usage while context membership stays unknown', async ({ execution }) => {
|
||||
const graph = images(57, 2);
|
||||
graph['10'] = { class_type: 'PMHistoryInputs', inputs: {} };
|
||||
graph['20'] = { class_type: 'PromptManagerPair', inputs: { clip: ['10', 0], text: `output usage ${randomUUID()}`,
|
||||
negative_text: `negative ${randomUUID()}`, prepend_text: '', append_text: '' } };
|
||||
graph['30'] = { class_type: 'KSampler', inputs: { model: ['10', 1], latent_image: ['10', 2],
|
||||
positive: ['20', 0], negative: ['20', 1], seed: 1, steps: 1, cfg: 1, sampler_name: 'euler', scheduler: 'normal', denoise: 1 } };
|
||||
graph['1'].inputs.latent = ['30', 0];
|
||||
const id = await submit(execution, graph);
|
||||
const first = await persisted(execution, id, ['succeeded']);
|
||||
const runId = first.rows[0].run_id;
|
||||
expectHistory(first.outputs[runId], await finalHistory(execution, id));
|
||||
expect(first.outputs[runId].references).toHaveLength(4);
|
||||
expect(first.captures[runId].contexts).toHaveLength(1);
|
||||
expect(first.usage[runId]).toEqual([{ role: 'positive', run_count: 1 }, { role: 'negative', run_count: 0 }, { role: 'pair', run_count: 1 }]);
|
||||
await submit(execution, graph, id);
|
||||
const second = await persisted(execution, id, ['succeeded', 'succeeded']);
|
||||
expect(second.outputs[second.rows[1].run_id].references).toEqual(first.outputs[runId].references);
|
||||
expect(second.usage[runId]).toEqual([{ role: 'positive', run_count: 2 }, { role: 'negative', run_count: 0 }, { role: 'pair', run_count: 2 }]);
|
||||
});
|
||||
|
||||
test('failed work after stock SaveImage preserves exactly the final reported partial-run outputs', async ({ execution }) => {
|
||||
const id = await submit(execution, downstream(81, 'failure'));
|
||||
const result = await persisted(execution, id, ['failed']);
|
||||
const item = await finalHistory(execution, id);
|
||||
expect(item.status.messages.some(([kind]) => kind === 'execution_error')).toBe(true);
|
||||
expectHistory(result.outputs[result.rows[0].run_id], item);
|
||||
expect(result.outputs[result.rows[0].run_id].references).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('interruption after stock SaveImage preserves exactly the final reported outputs', async ({ execution }) => {
|
||||
await gate(execution, 'hold');
|
||||
try {
|
||||
const id = await submit(execution, downstream(93, 'gate'));
|
||||
await entered(execution);
|
||||
expect((await execution.request.post('/interrupt', { data: {} })).ok()).toBe(true);
|
||||
const result = await persisted(execution, id, ['interrupted']);
|
||||
const item = await finalHistory(execution, id);
|
||||
expect(item.status.messages.some(([kind]) => kind === 'execution_interrupted')).toBe(true);
|
||||
expectHistory(result.outputs[result.rows[0].run_id], item);
|
||||
expect(result.outputs[result.rows[0].run_id].references).toHaveLength(1);
|
||||
} finally { await gate(execution, 'release'); }
|
||||
});
|
||||
|
||||
test('completed image before pending gated work is not finalized early', async ({ execution }) => {
|
||||
await gate(execution, 'hold');
|
||||
try {
|
||||
const id = await submit(execution, downstream(107, 'gate'));
|
||||
await entered(execution);
|
||||
let pending;
|
||||
await expect.poll(async () => {
|
||||
pending = await readExecutionRuns(execution.databasePath, id);
|
||||
return pending.rows.map(row => row.outcome);
|
||||
}, { timeout: 10000 }).toEqual(['observed']);
|
||||
expect(pending.outputs[pending.rows[0].run_id]).toMatchObject({ state: 'not_captured', references: [] });
|
||||
expect((await (await execution.request.get(`/history/${id}`)).json())[id]).toBeUndefined();
|
||||
await gate(execution, 'release');
|
||||
const result = await persisted(execution, id, ['succeeded']);
|
||||
expectHistory(result.outputs[result.rows[0].run_id], await finalHistory(execution, id));
|
||||
expect(result.outputs[result.rows[0].run_id].references).toHaveLength(1);
|
||||
} finally { await gate(execution, 'release'); }
|
||||
});
|
||||
|
||||
test('stock output capture survives both renderer switches and workflow reload', async ({ execution, page, context }, testInfo) => {
|
||||
await context.route('**/*', async route => {
|
||||
const path = new URL(route.request().url()).pathname.replace(/^\/api\//, '/');
|
||||
if (path === '/settings' && route.request().method() === 'GET') {
|
||||
const response = await route.fetch();
|
||||
return route.fulfill({ json: { ...await response.json(),
|
||||
'Comfy.VueNodes.Enabled': testInfo.project.metadata.initialRenderer,
|
||||
'Comfy.TutorialCompleted': true, 'Comfy.Workflow.Persist': false,
|
||||
'Comfy.Notification.ShowVersionUpdates': false } });
|
||||
}
|
||||
return route.continue();
|
||||
});
|
||||
const errors = [];
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.getByRole('status', { name: 'Loading ComfyUI' })).toBeHidden({ timeout: 30000 });
|
||||
await page.waitForFunction(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
return !!app.graph && !!globalThis.LiteGraph?.registered_node_types?.PMHistoryImages;
|
||||
});
|
||||
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(testInfo.project.metadata.initialRenderer);
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
app.graph.clear();
|
||||
const source = LiteGraph.createNode('PMHistoryImages');
|
||||
const saved = LiteGraph.createNode('SaveImage');
|
||||
const preview = LiteGraph.createNode('PreviewImage');
|
||||
for (const node of [source, saved, preview]) app.graph.add(node);
|
||||
source.widgets.find(widget => widget.name === 'value').value = 119;
|
||||
source.widgets.find(widget => widget.name === 'batch').value = 1;
|
||||
saved.widgets.find(widget => widget.name === 'filename_prefix').value = 'renderer-evidence/image';
|
||||
source.connect(0, saved, 0);
|
||||
source.connect(0, preview, 0);
|
||||
});
|
||||
const captures = [];
|
||||
for (const renderer of [testInfo.project.metadata.initialRenderer, !testInfo.project.metadata.initialRenderer, testInfo.project.metadata.initialRenderer]) {
|
||||
await page.evaluate(async renderer => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);
|
||||
await app.loadGraphData(app.graph.serialize());
|
||||
}, renderer);
|
||||
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
|
||||
const graph = await page.evaluate(async () => (await (await import('/scripts/app.js')).app.graphToPrompt()).output);
|
||||
expect(Object.values(graph).map(node => node.class_type).sort()).toEqual(['PMHistoryImages', 'PreviewImage', 'SaveImage']);
|
||||
const id = await submit(execution, graph);
|
||||
const result = await persisted(execution, id, ['succeeded']);
|
||||
const capture = result.outputs[result.rows[0].run_id];
|
||||
expectHistory(capture, await finalHistory(execution, id));
|
||||
expect(capture.references).toHaveLength(2);
|
||||
captures.push(capture);
|
||||
}
|
||||
expect(captures[1].references).toEqual(captures[0].references);
|
||||
expect(captures[2].references).toEqual(captures[0].references);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
"""Deterministic nodes and queue controls installed only in an owned test host."""
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
import nodes
|
||||
import execution
|
||||
import torch
|
||||
|
||||
from aiohttp import web
|
||||
import comfy.model_management
|
||||
from server import PromptServer
|
||||
|
||||
_GATE = threading.Event()
|
||||
_GATE.set()
|
||||
_ENTERED = threading.Event()
|
||||
|
||||
|
||||
@PromptServer.instance.routes.post("/pm_execution_fixture/gate/{action}")
|
||||
async def control_gate(request: web.Request) -> web.Response:
|
||||
action = request.match_info["action"]
|
||||
if action == "hold":
|
||||
_ENTERED.clear()
|
||||
_GATE.clear()
|
||||
elif action == "release":
|
||||
_GATE.set()
|
||||
else:
|
||||
raise web.HTTPBadRequest()
|
||||
return web.json_response({"held": not _GATE.is_set()})
|
||||
|
||||
|
||||
@PromptServer.instance.routes.get("/pm_execution_fixture/gate")
|
||||
async def gate_status(request: web.Request) -> web.Response:
|
||||
return web.json_response({"entered": _ENTERED.is_set(), "held": not _GATE.is_set()})
|
||||
|
||||
|
||||
class PMHistoryCached:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> dict:
|
||||
return {"required": {"value": ("STRING", {"default": "deterministic"})}}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
FUNCTION = "execute"
|
||||
CATEGORY = "testing/execution-history"
|
||||
OUTPUT_NODE = True
|
||||
|
||||
def execute(self, value: str) -> dict:
|
||||
return {"ui": {"text": [value]}, "result": (value,)}
|
||||
|
||||
|
||||
class PMHistoryFailure(PMHistoryCached):
|
||||
def execute(self, value: str) -> dict:
|
||||
raise ValueError(value)
|
||||
|
||||
|
||||
class PMHistoryGate(PMHistoryCached):
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs: object) -> float:
|
||||
return float("nan")
|
||||
|
||||
def execute(self, value: str) -> dict:
|
||||
_ENTERED.set()
|
||||
deadline = time.monotonic() + 30
|
||||
while not _GATE.wait(0.05):
|
||||
comfy.model_management.throw_exception_if_processing_interrupted()
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError("Owned execution fixture gate timed out")
|
||||
return super().execute(value)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"PMHistoryCached": PMHistoryCached,
|
||||
"PMHistoryFailure": PMHistoryFailure,
|
||||
"PMHistoryGate": PMHistoryGate,
|
||||
}
|
||||
|
||||
# Only external inference is replaced; PM classes and host execution remain real.
|
||||
|
||||
|
||||
class _Clip:
|
||||
def tokenize(self, text: str) -> str:
|
||||
return text
|
||||
|
||||
def encode_from_tokens_scheduled(self, tokens: str) -> list:
|
||||
return [[tokens, {}]]
|
||||
|
||||
|
||||
class PMHistoryInputs:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> dict:
|
||||
return {"required": {}}
|
||||
|
||||
RETURN_TYPES = ("CLIP", "MODEL", "LATENT")
|
||||
FUNCTION = "execute"
|
||||
CATEGORY = "testing/execution-history"
|
||||
|
||||
def execute(self) -> tuple:
|
||||
return (_Clip(), object(), {"samples": "owned deterministic latent"})
|
||||
|
||||
|
||||
class PMHistoryOutput:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> dict:
|
||||
return {
|
||||
"required": {"value": ("LATENT",)},
|
||||
"optional": {"mode": (["normal", "failure", "gate"],)},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
FUNCTION = "execute"
|
||||
CATEGORY = "testing/execution-history"
|
||||
OUTPUT_NODE = True
|
||||
|
||||
def execute(self, value: dict, mode: str = "normal") -> tuple:
|
||||
if mode == "failure":
|
||||
raise ValueError("owned downstream failure")
|
||||
if mode == "gate":
|
||||
PMHistoryGate().execute("owned downstream gate")
|
||||
return ()
|
||||
|
||||
|
||||
def _sample(self: object, **kwargs: object) -> tuple:
|
||||
return (kwargs["latent_image"],)
|
||||
|
||||
|
||||
nodes.KSampler.sample = _sample
|
||||
NODE_CLASS_MAPPINGS.update(
|
||||
{"PMHistoryInputs": PMHistoryInputs, "PMHistoryOutput": PMHistoryOutput}
|
||||
)
|
||||
|
||||
|
||||
class PMHistoryList(PMHistoryCached):
|
||||
OUTPUT_IS_LIST = (True,)
|
||||
|
||||
def execute(self, value: str) -> tuple:
|
||||
return ([value, value],)
|
||||
|
||||
|
||||
class PMHistoryTransform:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> dict:
|
||||
return {"required": {"conditioning": ("CONDITIONING",)}}
|
||||
|
||||
RETURN_TYPES = ("CONDITIONING",)
|
||||
FUNCTION = "execute"
|
||||
CATEGORY = "testing/execution-history"
|
||||
|
||||
def execute(self, conditioning: list) -> tuple:
|
||||
return (conditioning,)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS.update(
|
||||
{"PMHistoryList": PMHistoryList, "PMHistoryTransform": PMHistoryTransform}
|
||||
)
|
||||
|
||||
|
||||
class PMHistoryImages:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> dict:
|
||||
return {
|
||||
"required": {
|
||||
"value": ("INT", {"default": 0, "min": 0, "max": 255}),
|
||||
"batch": ("INT", {"default": 1, "min": 1, "max": 2}),
|
||||
},
|
||||
"optional": {"latent": ("LATENT",)},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "execute"
|
||||
CATEGORY = "testing/execution-history"
|
||||
|
||||
def execute(self, value: int, batch: int, latent: dict | None = None) -> tuple:
|
||||
return (torch.full((batch, 8, 8, 3), value / 255.0, device="cpu"),)
|
||||
|
||||
|
||||
class PMHistoryImageOutput:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> dict:
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
"mode": (["failure", "gate"],),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
FUNCTION = "execute"
|
||||
CATEGORY = "testing/execution-history"
|
||||
OUTPUT_NODE = True
|
||||
|
||||
def execute(self, images: torch.Tensor, mode: str) -> tuple:
|
||||
if mode == "failure":
|
||||
raise ValueError("owned failure after real image output")
|
||||
PMHistoryGate().execute("owned gate after real image output")
|
||||
return ()
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS.update(
|
||||
{"PMHistoryImages": PMHistoryImages, "PMHistoryImageOutput": PMHistoryImageOutput}
|
||||
)
|
||||
|
||||
|
||||
@PromptServer.instance.routes.post("/pm_execution_fixture/metadata")
|
||||
async def metadata(request: web.Request) -> web.Response:
|
||||
disabled = (await request.json())["disabled"]
|
||||
if type(disabled) is not bool:
|
||||
raise web.HTTPBadRequest()
|
||||
nodes.args.disable_metadata = disabled
|
||||
return web.json_response({"disabled": disabled})
|
||||
|
||||
|
||||
_INJECTION_ORIGINALS = {}
|
||||
|
||||
|
||||
@PromptServer.instance.routes.post("/pm_execution_fixture/injection")
|
||||
async def injection(request: web.Request) -> web.Response:
|
||||
suffix = (await request.json())["suffix"]
|
||||
if not isinstance(suffix, str) or len(suffix) > 128:
|
||||
raise web.HTTPBadRequest()
|
||||
for name in ("PromptManager", "PromptManagerText"):
|
||||
cls = nodes.NODE_CLASS_MAPPINGS[name]
|
||||
if name not in _INJECTION_ORIGINALS:
|
||||
_INJECTION_ORIGINALS[name] = cls._inject_lora_trigger_words
|
||||
cls._inject_lora_trigger_words = (
|
||||
(lambda self, text: text + suffix) if suffix else _INJECTION_ORIGINALS[name]
|
||||
)
|
||||
return web.json_response({"configured": True})
|
||||
|
||||
|
||||
# Apply a requested host cache configuration on its own worker before execution.
|
||||
# Retain only the mode scalar; never retain the worker/executor/cache objects.
|
||||
|
||||
_CACHE_MODE = None
|
||||
_EXECUTE = execution.PromptExecutor.execute
|
||||
|
||||
|
||||
def _execute_with_cache_mode(self: object, *args: object, **kwargs: object) -> object:
|
||||
global _CACHE_MODE
|
||||
if _CACHE_MODE is not None:
|
||||
self.cache_type = execution.CacheType[_CACHE_MODE]
|
||||
self.cache_args = {**self.cache_args, "lru": 100, "ram": 0}
|
||||
self.reset()
|
||||
_CACHE_MODE = None
|
||||
return _EXECUTE(self, *args, **kwargs)
|
||||
|
||||
|
||||
execution.PromptExecutor.execute = _execute_with_cache_mode
|
||||
|
||||
|
||||
@PromptServer.instance.routes.post("/pm_execution_fixture/cache")
|
||||
async def cache_mode(request: web.Request) -> web.Response:
|
||||
global _CACHE_MODE
|
||||
mode = (await request.json())["mode"]
|
||||
if mode not in ("CLASSIC", "LRU", "RAM_PRESSURE", "NONE"):
|
||||
raise web.HTTPBadRequest()
|
||||
_CACHE_MODE = mode
|
||||
return web.json_response({"configured": True})
|
||||
@@ -0,0 +1,46 @@
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {installHostHooks} from './host-fixture.mjs';
|
||||
|
||||
installHostHooks(test,['PromptManagerPair']);
|
||||
|
||||
test('native library sends the selected saved pair into a real ComfyUI destination',async({page,context,request},testInfo)=>{
|
||||
const positive=`Native library host ${testInfo.project.name}`;const negative=`Saved negative ${testInfo.project.name}`;
|
||||
const response=await request.post('/prompt_manager/pairs',{data:{positive_text:positive,negative_text:negative,title:'Host saved pair',category:'Native host',tags:['library-host']}});
|
||||
expect(response.ok()).toBe(true);
|
||||
const id=await page.evaluate(async()=>{
|
||||
const {app}=await import('/scripts/app.js');const node=LiteGraph.createNode('PromptManagerPair');app.graph.add(node);node.setSize([700,1000]);
|
||||
app.canvas.ds.scale=.8;app.canvas.centerOnNode(node);return node.id;
|
||||
});
|
||||
const editor=page.locator('.pm-pair');await editor.getByRole('textbox',{name:'Positive prompt',exact:true}).fill('Original positive');
|
||||
await editor.getByRole('textbox',{name:'Negative prompt',exact:true}).fill('Original negative');
|
||||
await editor.getByText('Composition · prepend / append',{exact:true}).click();
|
||||
await editor.getByRole('textbox',{name:'Prepend',exact:true}).fill('Keep trigger');await editor.getByRole('textbox',{name:'Append',exact:true}).fill('Keep detail');
|
||||
await page.evaluate(async()=>{
|
||||
const {app}=await import('/scripts/app.js');window.libraryEffects={queue:0,load:0};
|
||||
for(const [method,key]of [['queuePrompt','queue'],['loadGraphData','load']]){const original=app[method];app[method]=function(...args){window.libraryEffects[key]++;return original.apply(this,args);};}
|
||||
});
|
||||
const workspace=await context.newPage();const errors=[];workspace.on('pageerror',error=>errors.push(error.message));
|
||||
try {
|
||||
await workspace.goto('/prompt_manager/workspace#/library');
|
||||
await workspace.getByRole('searchbox',{name:'Search prompts',exact:true}).fill(positive);
|
||||
await workspace.locator('article[data-prompt-id]').filter({has:workspace.getByText(positive,{exact:true})}).getByRole('link',{name:'Explore history',exact:true}).click();
|
||||
const history=workspace.getByRole('region',{name:'Prompt history',exact:true});
|
||||
await history.getByRole('button',{name:'Select negative variant 1',exact:true}).click();
|
||||
await expect(history.getByRole('region',{name:'Saved prompt texts',exact:true}).getByRole('region',{name:'Negative prompt',exact:true})).toContainText(negative);
|
||||
await history.getByRole('region',{name:'Saved prompt texts',exact:true}).getByRole('button',{name:'Send prompt pair',exact:true}).click();
|
||||
const sender=workspace.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({index:1});
|
||||
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({index:1});
|
||||
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
|
||||
await expect(sender).toContainText(positive);await expect(sender).toContainText(negative);await expect(sender).toContainText(`Keep trigger ${positive} Keep detail`);
|
||||
await expect(editor.getByRole('textbox',{name:'Positive prompt',exact:true})).toHaveValue('Original positive');
|
||||
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
|
||||
await expect(editor.getByRole('textbox',{name:'Positive prompt',exact:true})).toHaveValue(positive);await expect(editor.getByRole('textbox',{name:'Negative prompt',exact:true})).toHaveValue(negative);
|
||||
const state=await page.evaluate(async id=>{
|
||||
const {app}=await import('/scripts/app.js');const node=app.graph.getNodeById(id);const serialized=node.serialize();
|
||||
return{inputs:(await app.graphToPrompt()).output[String(id)].inputs,serialized:Object.fromEntries(node.widgets.map((widget,index)=>[widget.name,serialized.widgets_values[index]])),effects:window.libraryEffects};
|
||||
},id);
|
||||
for(const field of ['inputs','serialized'])expect(state[field]).toMatchObject({text:positive,negative_text:negative,prepend_text:'Keep trigger',append_text:'Keep detail'});
|
||||
expect(state.effects).toEqual({queue:0,load:0});expect(errors).toEqual([]);
|
||||
} finally {await workspace.close();}
|
||||
});
|
||||
@@ -70,7 +70,11 @@ test('paired library filters search both texts, persist on reload and keep edito
|
||||
await page.screenshot({path:testInfo.outputPath('pair-library-preview.png')});
|
||||
await page.evaluate(async()=>{const {app}=await import('/scripts/app.js');await app.loadGraphData(app.graph.serialize());});await expect(root.getByLabel('Filter category',{exact:true})).toHaveValue('Paired host tests');await expect(root.getByLabel('Filter tags',{exact:true})).toHaveValue('pair-host, ');
|
||||
await page.evaluate(async id=>{const {app}=await import('/scripts/app.js');const node=app.graph.getNodeById(id);node.setSize([560,1000]);app.canvas.centerOnNode(node);},id);
|
||||
const [p,n]=await Promise.all(['Positive prompt','Negative prompt'].map(name=>root.getByRole('textbox',{name,exact:true}).boundingBox()));expect(n.y).toBeGreaterThan(p.y+p.height-1);expect(Math.abs(p.width-n.width)).toBeLessThan(1);await page.screenshot({path:testInfo.outputPath('pair-narrow.png')});
|
||||
await expect.poll(async()=>{
|
||||
const [p,n]=await Promise.all(['Positive prompt','Negative prompt'].map(name=>root.getByRole('textbox',{name,exact:true}).boundingBox()));
|
||||
return Boolean(p&&n&&p.width>0&&p.height>0&&n.height>0&&n.y>p.y+p.height-1&&Math.abs(p.width-n.width)<1);
|
||||
},{message:'Narrow prompt editors stack with equal widths after canvas resize'}).toBe(true);
|
||||
await page.screenshot({path:testInfo.outputPath('pair-narrow.png')});
|
||||
});
|
||||
|
||||
for(const field of ['category','tags'])test(`manual Save pair guards real connected ${field} and resumes after disconnect`,async({page},testInfo)=>{
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {installHostHooks} from './host-fixture.mjs';
|
||||
|
||||
const TYPES=['PromptManager','PromptManagerText','PromptManagerFocused','PromptManagerSplit','PromptManagerPair'];
|
||||
installHostHooks(test,TYPES);
|
||||
|
||||
async function addNode(page,type,values={}) {
|
||||
return page.evaluate(async({type,values})=>{
|
||||
const {app}=await import('/scripts/app.js');
|
||||
app.graph.clear();
|
||||
const node=LiteGraph.createNode(type);app.graph.add(node);
|
||||
node.setSize([type==='PromptManagerSplit'?1000:700,1050]);
|
||||
app.canvas.ds.scale=.8;app.canvas.centerOnNode(node);
|
||||
for(const [name,value]of Object.entries(values)) {
|
||||
const widget=node.widgets.find(widget=>widget.name===name);
|
||||
widget.value=value;if(widget.inputEl)widget.inputEl.value=value;
|
||||
widget.callback?.(value,app.canvas,node);
|
||||
}
|
||||
return node.id;
|
||||
},{type,values});
|
||||
}
|
||||
|
||||
async function withClient(page,context,run) {
|
||||
const clientPage=await context.newPage();const errors=[];
|
||||
clientPage.on('pageerror',error=>errors.push(error.message));
|
||||
try {
|
||||
await clientPage.goto(new URL('/prompt_manager/workspace#/overview',page.url()).href,{waitUntil:'domcontentloaded'});
|
||||
await clientPage.evaluate(async()=>{
|
||||
const {createBridgeClient}=await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');
|
||||
window.bridgeClient=createBridgeClient({origin:location.origin,namespace:'/'});
|
||||
});
|
||||
await run(clientPage);
|
||||
expect(errors).toEqual([]);
|
||||
} finally {
|
||||
await clientPage.evaluate(()=>window.bridgeClient?.dispose()).catch(()=>{});
|
||||
await clientPage.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function destination(clientPage,id) {
|
||||
const hosts=await clientPage.evaluate(()=>window.bridgeClient.discover({waitMs:200}));
|
||||
const host=hosts.find(host=>host.targets.some(target=>String(target.id)===String(id)));
|
||||
expect(host,'independently opened workspace discovers the real host').toBeDefined();
|
||||
const target=host.targets.find(target=>String(target.id)===String(id));
|
||||
return {hostId:host.hostId,target};
|
||||
}
|
||||
|
||||
async function preview(clientPage,hostId,target,payload) {
|
||||
return clientPage.evaluate(async({hostId,target,payload})=>{
|
||||
window.bridgePreview=await window.bridgeClient.preview(hostId,target.id,payload);
|
||||
return window.bridgePreview;
|
||||
},{hostId,target,payload});
|
||||
}
|
||||
|
||||
async function apply(clientPage,hostId) {
|
||||
return clientPage.evaluate(hostId=>window.bridgeClient.apply(hostId,window.bridgePreview.ticket),hostId);
|
||||
}
|
||||
|
||||
async function rejection(clientPage,action) {
|
||||
return clientPage.evaluate(async action=>{
|
||||
try {
|
||||
if(action.kind==='preview')await window.bridgeClient.preview(action.hostId,action.targetId,action.payload);
|
||||
else await window.bridgeClient.apply(action.hostId,window.bridgePreview.ticket);
|
||||
return null;
|
||||
} catch(error) {return String(error.message);}
|
||||
},action);
|
||||
}
|
||||
|
||||
async function snapshot(page,id) {
|
||||
return page.evaluate(async id=>{
|
||||
const {app}=await import('/scripts/app.js');const node=app.graph.getNodeById(id);
|
||||
const serialized=node.serialize();
|
||||
return {
|
||||
widgets:Object.fromEntries(node.widgets.map(widget=>[widget.name,widget.value])),
|
||||
inputs:Object.fromEntries(node.widgets.filter(widget=>widget.inputEl).map(widget=>[widget.name,widget.inputEl.value])),
|
||||
serialized:Object.fromEntries(node.widgets.map((widget,index)=>[widget.name,serialized.widgets_values[index]])),
|
||||
prompt:(await app.graphToPrompt()).output[String(id)].inputs,
|
||||
};
|
||||
},id);
|
||||
}
|
||||
|
||||
async function connect(page,id,field) {
|
||||
return page.evaluate(async({id,field})=>{
|
||||
const {app}=await import('/scripts/app.js');const {convertToInput}=await import('/extensions/core/widgetInputs.js');
|
||||
const node=app.graph.getNodeById(id);const widget=node.widgets.find(widget=>widget.name===field);
|
||||
if(!node.inputs.some(input=>input.name===field))convertToInput(node,widget);
|
||||
const source=LiteGraph.createNode('PrimitiveString');app.graph.add(source);
|
||||
source.widgets.find(widget=>widget.name==='value').value=`Upstream ${field}`;
|
||||
source.connect(0,node,node.inputs.findIndex(input=>input.name===field));
|
||||
return [String(source.id),0];
|
||||
},{id,field});
|
||||
}
|
||||
|
||||
test('independent workspace discovers all Prompt Manager destinations and writes real widgets without executing or loading workflows',async({page,context})=>{
|
||||
await withClient(page,context,async clientPage=>{
|
||||
for(const type of TYPES) {
|
||||
const id=await addNode(page,type,{text:'Before bridge'});
|
||||
const draft=`Visible draft ${type}`;
|
||||
await page.locator('textarea:visible').first().fill(draft);
|
||||
const {hostId,target}=await destination(clientPage,id);
|
||||
expect(target.type).toBe(type);expect(target.label).toBeTruthy();expect(target.roles).toContain('positive');
|
||||
expect(target.roles.includes('negative')).toBe(type==='PromptManagerPair');
|
||||
await page.evaluate(async()=>{
|
||||
const {app}=await import('/scripts/app.js');window.bridgeSideEffects={queue:0,load:0};
|
||||
for(const [method,key]of [['queuePrompt','queue'],['loadGraphData','load']]) {
|
||||
const original=app[method];app[method]=function(...args){window.bridgeSideEffects[key]++;return original.apply(this,args);};
|
||||
}
|
||||
});
|
||||
const text=`Bridge sent to ${type}`;const proposed=await preview(clientPage,hostId,target,{positive:text});
|
||||
expect(proposed.ticket).toBeTruthy();expect(proposed.fields).toContainEqual({role:'positive',before:draft,after:text});
|
||||
expect((await snapshot(page,id)).widgets.text).toBe(draft);
|
||||
expect(await apply(clientPage,hostId)).toMatchObject({targetId:target.id,values:{positive:text}});
|
||||
await expect.poll(()=>page.locator('textarea:visible').evaluateAll(inputs=>inputs.map(input=>input.value))).toContain(text);
|
||||
const state=await snapshot(page,id);
|
||||
for(const name of ['widgets','inputs','serialized','prompt'])expect(state[name].text).toBe(text);
|
||||
expect(await page.evaluate(()=>window.bridgeSideEffects)).toEqual({queue:0,load:0});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('pair sends preserve omitted negatives, accept an empty negative and synchronize visible editors and composition',async({page,context})=>{
|
||||
const id=await addNode(page,'PromptManagerPair',{text:'Old positive',negative_text:'Keep negative',prepend_text:'LoRA trigger',append_text:'fine detail'});
|
||||
await withClient(page,context,async clientPage=>{
|
||||
const {hostId,target}=await destination(clientPage,id);
|
||||
const proposed=await preview(clientPage,hostId,target,{positive:'New positive'});
|
||||
expect(proposed.composition).toMatchObject({prepend:'LoRA trigger',append:'fine detail',prependConnected:false,appendConnected:false,before:'LoRA trigger Old positive fine detail',after:'LoRA trigger New positive fine detail'});
|
||||
await apply(clientPage,hostId);
|
||||
expect((await snapshot(page,id)).widgets).toMatchObject({text:'New positive',negative_text:'Keep negative',prepend_text:'LoRA trigger',append_text:'fine detail'});
|
||||
const root=page.locator('.pm-pair');
|
||||
await expect(root.getByRole('textbox',{name:'Positive prompt',exact:true})).toHaveValue('New positive');
|
||||
await expect(root.getByRole('textbox',{name:'Negative prompt',exact:true})).toHaveValue('Keep negative');
|
||||
await root.getByText('Composed text preview',{exact:true}).click();
|
||||
await expect(root.locator('[data-composed-preview]')).toHaveText('LoRA trigger New positive fine detail');
|
||||
await preview(clientPage,hostId,target,{positive:'Paired positive',negative:''});
|
||||
await apply(clientPage,hostId);
|
||||
await expect(root.getByRole('textbox',{name:'Negative prompt',exact:true})).toHaveValue('');
|
||||
const state=await snapshot(page,id);
|
||||
for(const name of ['widgets','inputs','serialized','prompt'])expect(state[name]).toMatchObject({text:'Paired positive',negative_text:''});
|
||||
});
|
||||
});
|
||||
|
||||
test('connected requested fields reject atomically while connected composition remains linked',async({page,context})=>{
|
||||
const id=await addNode(page,'PromptManagerPair',{text:'Keep positive',negative_text:'Keep negative',prepend_text:'Local prepend'});
|
||||
const negativeLink=await connect(page,id,'negative_text');const prependLink=await connect(page,id,'prepend_text');
|
||||
await withClient(page,context,async clientPage=>{
|
||||
const {hostId,target}=await destination(clientPage,id);
|
||||
expect(await rejection(clientPage,{kind:'preview',hostId,targetId:target.id,payload:{positive:'Must not apply',negative:'Blocked'}})).toMatch(/connected/i);
|
||||
expect((await snapshot(page,id)).widgets).toMatchObject({text:'Keep positive',negative_text:'Keep negative'});
|
||||
const proposed=await preview(clientPage,hostId,target,{positive:'Allowed positive'});
|
||||
expect(proposed.composition.prependConnected).toBe(true);
|
||||
await apply(clientPage,hostId);
|
||||
expect((await snapshot(page,id)).prompt).toMatchObject({text:'Allowed positive',negative_text:negativeLink,prepend_text:prependLink});
|
||||
const positiveLink=await connect(page,id,'text');
|
||||
expect(await rejection(clientPage,{kind:'preview',hostId,targetId:target.id,payload:{positive:'Blocked positive'}})).toMatch(/connected/i);
|
||||
expect((await snapshot(page,id)).prompt.text).toEqual(positiveLink);
|
||||
});
|
||||
});
|
||||
|
||||
test('user edits and workflow replacement invalidate acknowledged previews without overwriting changes',async({page,context})=>{
|
||||
const id=await addNode(page,'PromptManagerFocused',{text:'Original draft'});
|
||||
await withClient(page,context,async clientPage=>{
|
||||
let {hostId,target}=await destination(clientPage,id);
|
||||
await preview(clientPage,hostId,target,{positive:'Pending change'});
|
||||
await page.locator('.pm-focused').getByRole('textbox',{name:'Main prompt',exact:true}).fill('User edited draft');
|
||||
expect(await rejection(clientPage,{kind:'apply',hostId})).toMatch(/changed|stale|workflow/i);
|
||||
expect((await snapshot(page,id)).widgets.text).toBe('User edited draft');
|
||||
({hostId,target}=await destination(clientPage,id));
|
||||
await preview(clientPage,hostId,target,{positive:'Pending after replacement'});
|
||||
await page.evaluate(async()=>{const {app}=await import('/scripts/app.js');await app.loadGraphData(app.graph.serialize());});
|
||||
expect(await rejection(clientPage,{kind:'apply',hostId})).toMatch(/changed|stale|workflow/i);
|
||||
expect((await snapshot(page,id)).widgets.text).toBe('User edited draft');
|
||||
});
|
||||
});
|
||||
|
||||
test('bridge remains live through both renderer switches and workflow reload with synchronized Focused controls',async({page,context},testInfo)=>{
|
||||
const id=await addNode(page,'PromptManagerFocused',{text:'Initial',prepend_text:'trigger',append_text:'detail'});
|
||||
await withClient(page,context,async clientPage=>{
|
||||
for(const renderer of [!testInfo.project.metadata.initialRenderer,testInfo.project.metadata.initialRenderer]) {
|
||||
await page.evaluate(async renderer=>{const {app}=await import('/scripts/app.js');await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled',renderer);app.canvas.centerOnNode(app.graph._nodes[0]);},renderer);
|
||||
await expect.poll(()=>page.evaluate(()=>LiteGraph.vueNodesMode)).toBe(renderer);
|
||||
const {hostId,target}=await destination(clientPage,id);const text=`Renderer ${renderer}`;
|
||||
await preview(clientPage,hostId,target,{positive:text});await apply(clientPage,hostId);
|
||||
await expect(page.locator('.pm-focused').getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue(text);
|
||||
await page.evaluate(async()=>{const {app}=await import('/scripts/app.js');await app.loadGraphData(app.graph.serialize());});
|
||||
const state=await snapshot(page,id);
|
||||
for(const name of ['widgets','inputs','serialized','prompt'])expect(state[name].text).toBe(text);
|
||||
}
|
||||
const {hostId,target}=await destination(clientPage,id);
|
||||
await preview(clientPage,hostId,target,{positive:'After reload'});await apply(clientPage,hostId);
|
||||
await expect(page.locator('.pm-focused').getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('After reload');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import {execFileSync} from 'node:child_process';
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {installHostHooks} from './host-fixture.mjs';
|
||||
|
||||
installHostHooks(test,['PromptManagerPair']);
|
||||
|
||||
test('native image inspector previews and sends a pair into real visible widgets without loading or queueing',async({page,context,request},testInfo)=>{
|
||||
const positive=`Inspector host positive ${testInfo.project.name}`;const negative='host negative, watermark';
|
||||
const image=execFileSync(process.env.PM_TEST_PYTHON||'python',['-c',[
|
||||
'import io,json,sys',
|
||||
'from PIL import Image',
|
||||
'from PIL.PngImagePlugin import PngInfo',
|
||||
'info=PngInfo()',
|
||||
'graph={"1":{"class_type":"CLIPTextEncode","inputs":{"text":sys.argv[1]}},"2":{"class_type":"CLIPTextEncode","inputs":{"text":sys.argv[2]}},"3":{"class_type":"KSampler","inputs":{"positive":["1",0],"negative":["2",0],"model":["4",0],"seed":9007199254740993,"steps":28,"cfg":6.5,"sampler_name":"dpmpp_2m","scheduler":"karras"}},"4":{"class_type":"CheckpointLoaderSimple","inputs":{"ckpt_name":"host-fixture.safetensors"}}}',
|
||||
'info.add_text("prompt",json.dumps(graph))',
|
||||
'info.add_text("workflow",json.dumps({"nodes":[],"links":[]}))',
|
||||
'buffer=io.BytesIO()',
|
||||
'Image.new("RGB",(64,64),(30,80,140)).save(buffer,format="PNG",pnginfo=info)',
|
||||
'sys.stdout.buffer.write(buffer.getvalue())',
|
||||
].join('\n'),positive,negative]);
|
||||
const filename=`sender-${testInfo.project.name}.png`;
|
||||
const upload=await request.post('/upload/image',{multipart:{image:{name:filename,mimeType:'image/png',buffer:image},type:'output',subfolder:'prompt-sender',overwrite:'true'}});
|
||||
expect(upload.ok()).toBe(true);
|
||||
const id=await page.evaluate(async()=>{
|
||||
const {app}=await import('/scripts/app.js');const node=LiteGraph.createNode('PromptManagerPair');app.graph.add(node);
|
||||
node.setSize([700,1000]);app.canvas.ds.scale=.8;app.canvas.centerOnNode(node);
|
||||
return node.id;
|
||||
});
|
||||
const nodeRoot=page.locator('.pm-pair');
|
||||
await nodeRoot.getByRole('textbox',{name:'Positive prompt',exact:true}).fill('Before positive');
|
||||
await nodeRoot.getByRole('textbox',{name:'Negative prompt',exact:true}).fill('Before negative');
|
||||
await nodeRoot.getByText('Composition · prepend / append',{exact:true}).click();
|
||||
await nodeRoot.getByRole('textbox',{name:'Prepend',exact:true}).fill('LoRA trigger');
|
||||
await nodeRoot.getByRole('textbox',{name:'Append',exact:true}).fill('fine detail');
|
||||
await page.evaluate(async()=>{
|
||||
const {app}=await import('/scripts/app.js');window.senderEffects={queue:0,load:0};
|
||||
for(const [method,key]of [['queuePrompt','queue'],['loadGraphData','load']]){
|
||||
const original=app[method];app[method]=function(...args){window.senderEffects[key]++;return original.apply(this,args);};
|
||||
}
|
||||
});
|
||||
const workspace=await context.newPage();const errors=[];workspace.on('pageerror',error=>errors.push(error.message));
|
||||
try {
|
||||
await workspace.goto('/prompt_manager/workspace#/images');
|
||||
await workspace.getByRole('button',{name:'Refresh',exact:true}).click();
|
||||
const card=workspace.locator('article[data-image-key]').filter({has:workspace.getByRole('button',{name:`Open ${filename}`,exact:true})});
|
||||
await card.getByRole('button',{name:`Open ${filename}`,exact:true}).click();
|
||||
const inspector=workspace.getByRole('region',{name:'Image details',exact:true});
|
||||
await expect(inspector.getByRole('region',{name:'Positive prompt',exact:true})).toContainText(positive);
|
||||
await inspector.getByRole('button',{name:'Open full preview',exact:true}).click();
|
||||
const viewer=workspace.getByRole('dialog',{name:'Image preview',exact:true});
|
||||
await expect(viewer.locator('.viewer-canvas img')).toBeVisible();
|
||||
await expect(viewer.locator('.generation-facts dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','host-fixture.safetensors']);
|
||||
await viewer.getByText('Workflow & raw metadata',{exact:true}).click();
|
||||
await expect(viewer.locator('pre code').first()).toContainText('9007199254740993');
|
||||
await expect(viewer.getByRole('button',{name:'Download workflow JSON',exact:true})).not.toHaveAttribute('aria-disabled','true');
|
||||
expect(await page.evaluate(()=>window.senderEffects)).toEqual({queue:0,load:0});
|
||||
for(const renderer of [!testInfo.project.metadata.initialRenderer,testInfo.project.metadata.initialRenderer]) {
|
||||
await page.evaluate(async renderer=>{const {app}=await import('/scripts/app.js');await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled',renderer);await app.loadGraphData(app.graph.serialize());app.canvas.centerOnNode(app.graph._nodes[0]);},renderer);
|
||||
await expect.poll(()=>page.evaluate(()=>LiteGraph.vueNodesMode)).toBe(renderer);
|
||||
await expect(viewer).toBeVisible();await expect(viewer.locator('.generation-facts dd').first()).toHaveText('9007199254740993');
|
||||
await expect(nodeRoot.getByRole('textbox',{name:'Positive prompt',exact:true})).toHaveValue('Before positive');
|
||||
}
|
||||
expect(await page.evaluate(()=>window.senderEffects.queue)).toBe(0);
|
||||
await page.evaluate(()=>{window.senderSendBaseline={...window.senderEffects};});
|
||||
await viewer.getByRole('button',{name:'Send prompt pair',exact:true}).click();
|
||||
const sender=workspace.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({index:1});
|
||||
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({index:1});
|
||||
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
|
||||
for(const text of ['Before positive','Before negative',positive,negative,`LoRA trigger ${positive} fine detail`])await expect(sender).toContainText(text);
|
||||
await expect(nodeRoot.getByRole('textbox',{name:'Positive prompt',exact:true})).toHaveValue('Before positive');
|
||||
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();
|
||||
await expect(sender).toContainText('Sent to ComfyUI.');
|
||||
await expect(nodeRoot.getByRole('textbox',{name:'Positive prompt',exact:true})).toHaveValue(positive);
|
||||
await expect(nodeRoot.getByRole('textbox',{name:'Negative prompt',exact:true})).toHaveValue(negative);
|
||||
const state=await page.evaluate(async id=>{
|
||||
const {app}=await import('/scripts/app.js');const node=app.graph.getNodeById(id);const serialized=node.serialize();
|
||||
return{inputs:(await app.graphToPrompt()).output[String(id)].inputs,
|
||||
serialized:Object.fromEntries(node.widgets.map((widget,index)=>[widget.name,serialized.widgets_values[index]])),
|
||||
effects:{queue:window.senderEffects.queue-window.senderSendBaseline.queue,load:window.senderEffects.load-window.senderSendBaseline.load}};
|
||||
},id);
|
||||
for(const name of ['inputs','serialized'])expect(state[name]).toMatchObject({text:positive,negative_text:negative,prepend_text:'LoRA trigger',append_text:'fine detail'});
|
||||
expect(state.effects).toEqual({queue:0,load:0});expect(errors).toEqual([]);
|
||||
await workspace.screenshot({path:testInfo.outputPath('full-inspector-host-send.png')});
|
||||
} finally {await workspace.close();}
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Bounded read-only SQLite inspection for the owned execution fixture."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from uuid import UUID
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
from database.execution_contexts import ExecutionContextOperations # noqa: E402
|
||||
from database.execution_history import ExecutionHistoryOperations # noqa: E402
|
||||
from database.execution_outputs import ExecutionOutputOperations # noqa: E402
|
||||
|
||||
|
||||
class _ReadOnlyHistory(
|
||||
ExecutionContextOperations, ExecutionHistoryOperations, ExecutionOutputOperations
|
||||
):
|
||||
def __init__(self, connection: sqlite3.Connection) -> None:
|
||||
self.model = SimpleNamespace(get_connection=lambda: connection)
|
||||
|
||||
|
||||
def read_runs(database: str, job_id: str, session_id: str | None = None) -> dict:
|
||||
job_id = str(UUID(job_id))
|
||||
if session_id is not None:
|
||||
session_id = str(UUID(session_id))
|
||||
uri = Path(database).resolve().as_uri() + "?mode=ro"
|
||||
with sqlite3.connect(uri, uri=True, timeout=0.25) as connection:
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA query_only=ON")
|
||||
table = connection.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='execution_runs'"
|
||||
).fetchone()
|
||||
if table is None:
|
||||
return {"schemaAvailable": False, "rows": []}
|
||||
sql = "SELECT * FROM execution_runs WHERE job_id=?"
|
||||
parameters = [job_id]
|
||||
if session_id is not None:
|
||||
sql += " AND session_id=?"
|
||||
parameters.append(session_id)
|
||||
rows = connection.execute(
|
||||
sql + " ORDER BY rowid LIMIT 257", parameters
|
||||
).fetchall()
|
||||
if len(rows) > 256:
|
||||
raise ValueError("Execution query exceeds the 256-row bound")
|
||||
reader = _ReadOnlyHistory(connection)
|
||||
captures = {
|
||||
row["run_id"]: reader.get_execution_contexts(row["run_id"]) for row in rows
|
||||
}
|
||||
usage = {}
|
||||
snapshot_ids = {}
|
||||
for run_id, capture in captures.items():
|
||||
usage[run_id] = []
|
||||
snapshot_ids[run_id] = []
|
||||
for context in capture["contexts"]:
|
||||
for role in ("positive", "negative"):
|
||||
prompt_id = context[f"{role}_prompt_id"]
|
||||
if prompt_id is not None:
|
||||
count = connection.execute(
|
||||
"SELECT run_count FROM prompts WHERE id=?", (prompt_id,)
|
||||
).fetchone()[0]
|
||||
usage[run_id].append({"role": role, "run_count": count})
|
||||
pair_id = context["pair_id"]
|
||||
if pair_id is not None:
|
||||
count = connection.execute(
|
||||
"SELECT run_count FROM prompt_pairs WHERE id=?", (pair_id,)
|
||||
).fetchone()[0]
|
||||
usage[run_id].append({"role": "pair", "run_count": count})
|
||||
snapshot_ids[run_id].append(
|
||||
dict(
|
||||
connection.execute(
|
||||
"SELECT positive_snapshot_id,negative_snapshot_id "
|
||||
"FROM execution_contexts WHERE context_id=?",
|
||||
(context["context_id"],),
|
||||
).fetchone()
|
||||
)
|
||||
)
|
||||
return {
|
||||
"schemaAvailable": True,
|
||||
"rows": [dict(row) for row in rows],
|
||||
"captures": captures,
|
||||
"usage": usage,
|
||||
"snapshotIds": snapshot_ids,
|
||||
"outputs": {
|
||||
row["run_id"]: reader.get_execution_outputs(row["run_id"])
|
||||
for row in rows
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("database")
|
||||
parser.add_argument("job_id")
|
||||
parser.add_argument("--session-id")
|
||||
arguments = parser.parse_args()
|
||||
print(
|
||||
json.dumps(
|
||||
read_runs(arguments.database, arguments.job_id, arguments.session_id)
|
||||
)
|
||||
)
|
||||
@@ -13,16 +13,30 @@ test('workspace serves real library data beside either ComfyUI renderer', async
|
||||
const errors = [];
|
||||
workspace.on('pageerror', error => errors.push(error.message));
|
||||
try {
|
||||
await workspace.goto('/prompt_manager/workspace');
|
||||
await workspace.goto('/prompt_manager/workspace#/overview');
|
||||
await expect(workspace.locator('#workspace-main h1')).toHaveText('Overview');
|
||||
await expect(workspace.locator('dt').filter({ hasText: /^Prompts$/ }).locator('+ dd'))
|
||||
.toHaveText(stats.total_prompts.toLocaleString());
|
||||
await workspace.getByRole('combobox', { name: 'Appearance' }).selectOption('light');
|
||||
await expect(workspace.locator('body')).toHaveAttribute('data-ca-theme', 'light');
|
||||
const imageResponse = workspace.waitForResponse(response => {
|
||||
const url = new URL(response.url());
|
||||
return url.pathname === '/prompt_manager/images/output' && url.searchParams.get('mode') === 'workspace';
|
||||
});
|
||||
await workspace.getByRole('navigation', { name: 'Workspace', exact: true })
|
||||
.getByRole('link', { name: 'Images', exact: true }).click();
|
||||
await expect(workspace.locator('#workspace-main h1')).toHaveText('Images');
|
||||
await expect(workspace.locator('#workspace-main a')).toHaveAttribute('href', '/prompt_manager/gallery');
|
||||
const images = await imageResponse;
|
||||
expect(images.ok()).toBe(true);
|
||||
const collection = await images.json();
|
||||
expect(collection.success).toBe(true);
|
||||
await expect(workspace.getByRole('list', { name: 'Image collection', exact: true })).toBeAttached();
|
||||
if (collection.images.length) {
|
||||
await expect(workspace.locator('article[data-image-key]').first()).toBeVisible();
|
||||
} else {
|
||||
await expect(workspace.getByText('No matching images or media. Try changing the filters.', { exact: true })).toBeVisible();
|
||||
}
|
||||
await expect(workspace.locator('#workspace-main a[href="/prompt_manager/gallery"]')).toHaveAttribute('href', '/prompt_manager/gallery');
|
||||
await workspace.reload();
|
||||
await expect(workspace.locator('#workspace-main h1')).toHaveText('Images');
|
||||
await expect(workspace.locator('body')).toHaveAttribute('data-ca-theme', 'light');
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import runtime from '../../tools/vendor/comfyui-runtime.json' with {type:'json'};
|
||||
|
||||
const bridgeSource=await readFile(new URL('../../web/js/shared/toolkit/prompt-bridge.js',import.meta.url),'utf8');
|
||||
|
||||
test('independent and combined packs preserve real controls, bridge isolation and workflow state',async({page,context,request},testInfo)=>{
|
||||
const {mode,initialRenderer}=testInfo.project.metadata;
|
||||
const hasPM=mode!=='kiko',hasKiko=mode!=='promptmanager';const errors=[];
|
||||
page.on('pageerror',error=>errors.push(error.message));
|
||||
const response=await request.get('/system_stats');expect(response.ok()).toBe(true);
|
||||
expect((await response.json()).system.comfyui_version).toBe(runtime.serverVersion);
|
||||
await context.route('**/*',async route=>{
|
||||
const request=route.request();const path=new URL(request.url()).pathname.replace(/^\/api\//,'/');
|
||||
if(path==='/settings'&&request.method()==='GET') {
|
||||
const response=await route.fetch();return route.fulfill({json:{...await response.json(),
|
||||
'Comfy.VueNodes.Enabled':initialRenderer,'Comfy.TutorialCompleted':true,
|
||||
'Comfy.Workflow.Persist':false,'Comfy.Notification.ShowVersionUpdates':false}});
|
||||
}
|
||||
if(!['GET','HEAD','OPTIONS'].includes(request.method())&&!path.startsWith('/prompt_manager/'))return route.fulfill({json:{}});
|
||||
return route.continue();
|
||||
});
|
||||
await page.goto('/',{waitUntil:'domcontentloaded'});
|
||||
await expect(page.getByRole('status',{name:'Loading ComfyUI'})).toBeHidden({timeout:30000});
|
||||
await page.waitForFunction(async()=>Boolean((await import('/scripts/app.js')).app.graph));
|
||||
await expect.poll(()=>page.evaluate(()=>LiteGraph.vueNodesMode)).toBe(initialRenderer);
|
||||
const definitions=await(await request.get('/object_info')).json();
|
||||
expect(Boolean(definitions.PromptManagerFocused)).toBe(hasPM);expect(Boolean(definitions.TextInput)).toBe(hasKiko);
|
||||
await page.waitForFunction(({hasPM,hasKiko})=>
|
||||
(!hasPM||Boolean(LiteGraph.registered_node_types.PromptManagerFocused))&&
|
||||
(!hasKiko||Boolean(LiteGraph.registered_node_types.TextInput)),{hasPM,hasKiko});
|
||||
const ids=await page.evaluate(async({hasPM,hasKiko})=>{
|
||||
const {app}=await import('/scripts/app.js');app.graph.clear();const ids={};
|
||||
for(const [key,type]of [...(hasPM?[['pm','PromptManagerFocused']]:[]),...(hasKiko?[['kiko','TextInput']]:[])]) {
|
||||
const node=LiteGraph.createNode(type);app.graph.add(node);node.setSize([680,1000]);
|
||||
node.pos=[key==='kiko'?800:0,0];ids[key]=node.id;
|
||||
}
|
||||
app.canvas.ds.scale=.8;app.canvas.centerOnNode(app.graph.getNodeById(ids.kiko??ids.pm));return ids;
|
||||
},{hasPM,hasKiko});
|
||||
const clientPage=await context.newPage();clientPage.on('pageerror',error=>errors.push(error.message));
|
||||
try {
|
||||
await clientPage.goto(new URL(hasPM?'/prompt_manager/workspace#/overview':'/system_stats',page.url()).href,{waitUntil:'domcontentloaded'});
|
||||
await clientPage.evaluate(async source=>{
|
||||
const url=URL.createObjectURL(new Blob([source],{type:'text/javascript'}));
|
||||
try {const {createBridgeClient}=await import(url);window.packClient=createBridgeClient({origin:location.origin,namespace:'/'});}
|
||||
finally {URL.revokeObjectURL(url);}
|
||||
},bridgeSource);
|
||||
for(const [index,renderer]of [initialRenderer,!initialRenderer,initialRenderer].entries()) {
|
||||
await page.evaluate(async({renderer,ids})=>{
|
||||
const {app}=await import('/scripts/app.js');await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled',renderer);
|
||||
app.canvas.centerOnNode(app.graph.getNodeById(ids.kiko??ids.pm));
|
||||
},{renderer,ids});
|
||||
await expect.poll(()=>page.evaluate(()=>LiteGraph.vueNodesMode)).toBe(renderer);
|
||||
const kikoText=`Kiko independent edit ${index}`;
|
||||
if(hasKiko) {
|
||||
const control=page.locator('textarea:visible:not(.pm-focused textarea)');
|
||||
await expect(control).toHaveCount(1);await expect(control).toBeVisible();await control.fill(kikoText);
|
||||
expect(await control.evaluate(async(_element,id)=>{
|
||||
const {app}=await import('/scripts/app.js');return(await app.graphToPrompt()).output[String(id)].inputs.text;
|
||||
},ids.kiko)).toBe(kikoText);
|
||||
}
|
||||
const hosts=await clientPage.evaluate(()=>window.packClient.discover({waitMs:200}));
|
||||
expect(hosts).toHaveLength(hasPM?1:0);
|
||||
if(hasPM) {
|
||||
expect(hosts[0].targets).toHaveLength(1);
|
||||
const target=hosts[0].targets[0];expect(String(target.id)).toBe(String(ids.pm));
|
||||
expect(target.type).toBe('PromptManagerFocused');
|
||||
const text=`PM isolated send ${index}`;
|
||||
const result=await clientPage.evaluate(async({hostId,target,text})=>{
|
||||
const preview=await window.packClient.preview(hostId,target.id,{positive:text});
|
||||
return window.packClient.apply(hostId,preview.ticket);
|
||||
},{hostId:hosts[0].hostId,target,text});
|
||||
expect(result.values.positive).toBe(text);
|
||||
const state=await page.evaluate(async ids=>{
|
||||
const {app}=await import('/scripts/app.js');const prompt=(await app.graphToPrompt()).output;
|
||||
return {pm:prompt[String(ids.pm)].inputs.text,kiko:ids.kiko?prompt[String(ids.kiko)].inputs.text:null};
|
||||
},ids);
|
||||
expect(state.pm).toBe(text);if(hasKiko)expect(state.kiko).toBe(kikoText);
|
||||
}
|
||||
await page.evaluate(async ids=>{
|
||||
const {app}=await import('/scripts/app.js');await app.loadGraphData(app.graph.serialize());
|
||||
app.canvas.centerOnNode(app.graph.getNodeById(ids.kiko??ids.pm));
|
||||
},ids);
|
||||
if(hasKiko)expect(await page.evaluate(async id=>{
|
||||
const {app}=await import('/scripts/app.js');return(await app.graphToPrompt()).output[String(id)].inputs.text;
|
||||
},ids.kiko)).toBe(kikoText);
|
||||
}
|
||||
expect(errors).toEqual([]);
|
||||
} finally {
|
||||
await clientPage.evaluate(()=>window.packClient?.dispose()).catch(()=>{});await clientPage.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
# Browser media fixtures
|
||||
|
||||
`tiny.webm` is an original, silent, one-second VP8 color test pattern generated
|
||||
for this repository. It contains no user media and is dedicated to the public
|
||||
domain under CC0-1.0.
|
||||
|
||||
Reproduce with FFmpeg:
|
||||
|
||||
```sh
|
||||
ffmpeg -f lavfi -i testsrc=size=32x32:rate=4:duration=1 \
|
||||
-c:v libvpx -pix_fmt yuv420p -an tiny.webm
|
||||
```
|
||||
|
||||
Browser tests use the committed file; FFmpeg is not required to run them.
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Finite test waits for real SQLite batches without a throughput requirement."""
|
||||
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable
|
||||
|
||||
from utils.execution_history_writer import HistoryWriter
|
||||
|
||||
|
||||
def drain_writer(
|
||||
writer: HistoryWriter, *, clock: Callable[[], float] = time.monotonic
|
||||
) -> None:
|
||||
initial = current = writer.status()
|
||||
started = last_progress = clock()
|
||||
lowest_pending = initial.pending
|
||||
while current.pending:
|
||||
now = clock()
|
||||
if current.pending < lowest_pending:
|
||||
lowest_pending = current.pending
|
||||
last_progress = now
|
||||
reason = None
|
||||
if not current.alive:
|
||||
reason = "worker stopped"
|
||||
elif now - started >= 30:
|
||||
reason = "batch deadline"
|
||||
elif now - last_progress >= 3:
|
||||
reason = "worker stalled"
|
||||
if reason is not None:
|
||||
raise AssertionError(
|
||||
f"history drain {reason}: elapsed={now - started:.3f}s, "
|
||||
f"stalled={now - last_progress:.3f}s, "
|
||||
f"initial={initial}, current={current}, "
|
||||
f"charged_bytes={writer.context_budget.charged_bytes}"
|
||||
)
|
||||
threading.Event().wait(0.005)
|
||||
current = writer.status()
|
||||
@@ -113,6 +113,31 @@ test("a 2xx body with success:false throws ApiError", async () => {
|
||||
await assert.rejects(pending, (err) => err instanceof ApiError && err.status === 200 && err.message === "Nope");
|
||||
});
|
||||
|
||||
test("ApiError preserves only a bounded machine code without changing status or message", async () => {
|
||||
assert.equal(new ApiError(409, "unchanged").code, null);
|
||||
for (const [code, expected] of [
|
||||
["source-changed", "source-changed"], ["a".repeat(64), "a".repeat(64)],
|
||||
[undefined, null], [null, null], [4, null], [{ code: "x" }, null],
|
||||
["", null], ["Source-changed", null], ["source_changed", null],
|
||||
["source-changed\n", null], ["a".repeat(65), null],
|
||||
]) {
|
||||
for (const status of [409, 200]) {
|
||||
const api = createApiClient(async () => jsonResponse(status, {
|
||||
success: false, error: "unchanged", code, private: "never expose",
|
||||
}));
|
||||
await assert.rejects(api.get("/x"), error => {
|
||||
assert.ok(error instanceof ApiError);
|
||||
assert.equal(error.status, status);
|
||||
assert.equal(error.message, "unchanged");
|
||||
assert.equal(error.code, expected);
|
||||
assert.equal(error.private, undefined);
|
||||
assert.equal(error.body, undefined);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("a 2xx body that is not JSON throws ApiError", async () => {
|
||||
const fetch = fakeFetch();
|
||||
const api = createApiClient(fetch);
|
||||
@@ -197,3 +222,34 @@ test("latestOnly: errors from the current call propagate, errors from superseded
|
||||
rejectFirst(new Error("old failure"));
|
||||
assert.equal(await first, STALE);
|
||||
});
|
||||
|
||||
test("ApiError exposes only validated history recovery actions on all error paths", async () => {
|
||||
const permitted = [null, { action: "refresh-history" }, { action: "reload-outputs" },
|
||||
{ action: "retry-read" }, { action: "smaller-limit", limit: 1 },
|
||||
{ action: "smaller-limit", limit: 100 }];
|
||||
const denied = [undefined, false, "refresh-history", [], {}, { action: "unknown" },
|
||||
{ action: "refresh-history", private: "secret" }, { action: "retry-read", limit: 1 },
|
||||
{ action: "smaller-limit" }, { action: "smaller-limit", limit: 0 },
|
||||
{ action: "smaller-limit", limit: 101 }, { action: "smaller-limit", limit: true },
|
||||
{ action: "smaller-limit", limit: "1" }, { action: "smaller-limit", limit: 1.5 },
|
||||
{ action: "smaller-limit", limit: NaN }, { action: "smaller-limit", limit: Infinity },
|
||||
{ action: "smaller-limit", limit: 10, url: "/private" },
|
||||
Object.assign(Object.create({ action: "retry-read" }), { private: "secret" })];
|
||||
assert.equal(new ApiError(409, "old caller").recovery, null);
|
||||
for (const recovery of permitted.concat(denied)) {
|
||||
for (const status of [409, 200]) {
|
||||
const api = createApiClient(async () => jsonResponse(status, {
|
||||
success: false, error: "bounded", code: "history-changed", recovery,
|
||||
}));
|
||||
await assert.rejects(api.get("/x"), error => {
|
||||
assert.ok(error instanceof ApiError);
|
||||
assert.equal(error.code, "history-changed");
|
||||
assert.deepEqual(error.recovery, permitted.includes(recovery) ? recovery : null);
|
||||
if (recovery && permitted.includes(recovery)) {
|
||||
assert.notEqual(error.recovery, recovery, "server objects must be copied");
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,391 @@
|
||||
// Run with: node --test tests/js/
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { extractGenerationParams } = require("../../web/js/comfy-metadata.js");
|
||||
const { extractGenerationParams, extractGenerationFacts } = require("../../web/js/comfy-metadata.js");
|
||||
|
||||
const parsedFactGraph = value => ({ status: "parsed", value });
|
||||
const absentFactGraph = () => ({ status: "absent", value: null });
|
||||
const factFields = ["seed", "steps", "cfg", "sampler", "scheduler", "model"];
|
||||
function factsFor(graph, extra = {}) {
|
||||
assert.equal(typeof extractGenerationFacts, "function", "the conservative facts contract is exported");
|
||||
return extractGenerationFacts({ prompt_graph: parsedFactGraph(graph), workflow_graph: absentFactGraph(), metadataStatus: "available", ...extra });
|
||||
}
|
||||
function factGraph(inputs = {}) {
|
||||
return {
|
||||
s: { class_type: "KSampler", inputs: { seed: "9007199254740993", steps: 28, cfg: "6.5000000000000000001", sampler_name: "dpmpp_2m", scheduler: "karras", model: ["model", 0], ...inputs } },
|
||||
model: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "model.safetensors" } },
|
||||
};
|
||||
}
|
||||
|
||||
test("facts keep unsafe seed and decimal strings exact", () => {
|
||||
const facts = factsFor(factGraph());
|
||||
assert.deepEqual(Object.keys(facts), factFields);
|
||||
assert.deepEqual(factFields.map(field => facts[field].value), ["9007199254740993", "28", "6.5000000000000000001", "dpmpp_2m", "karras", "model.safetensors"]);
|
||||
for (const fact of Object.values(facts)) assert.deepEqual(fact, {
|
||||
status: "known", value: fact.value, source: "api-graph", evidence: "configuration", reason: null, sampler_id: "s",
|
||||
});
|
||||
const zero = factsFor(factGraph({ seed: 0, cfg: "0", steps: "1" }));
|
||||
assert.equal(zero.seed.value, "0"); assert.equal(zero.cfg.value, "0");
|
||||
for (const inputs of [{ seed: 9007199254740992 }, { seed: "1.5" }, { steps: "0" }, { cfg: "NaN" }, { cfg: {} }, { cfg: true }]) {
|
||||
const key = Object.keys(inputs)[0];
|
||||
assert.equal(factsFor(factGraph(inputs))[key].status, "unresolved");
|
||||
}
|
||||
});
|
||||
|
||||
test("facts do not choose equal sampler branches or unrelated model", () => {
|
||||
const graph = factGraph(); graph.other = structuredClone(graph.s);
|
||||
const ambiguous = factsFor(graph);
|
||||
assert.ok(Object.values(ambiguous).every(fact => fact.status === "ambiguous" && fact.value === null));
|
||||
const selected = factsFor(graph, { resolution: { state: "resolved", sampler_id: "other" } });
|
||||
assert.equal(selected.seed.sampler_id, "other");
|
||||
const unrelated = factsFor(factGraph({ model: ["missing", 0] }));
|
||||
assert.equal(unrelated.model.status, "unresolved");
|
||||
assert.equal(unrelated.model.value, null);
|
||||
assert.equal(factsFor({ only: graph.model }).seed.status, "unresolved");
|
||||
});
|
||||
|
||||
test("facts respect named scalar outputs and custom scheduler paths", () => {
|
||||
const graph = factGraph({ seed: ["primitive", 0], steps: ["steps", 0], cfg: ["cfg", 0], sampler_name: ["sampler", 0], scheduler: ["scheduler", 0], model: ["lora", 0] });
|
||||
graph.primitive = { class_type: "PrimitiveInt", inputs: { value: "0" } };
|
||||
graph.steps = { class_type: "PrimitiveInt", inputs: { value: "50" } };
|
||||
graph.cfg = { class_type: "PrimitiveFloat", inputs: { value: "0" } };
|
||||
graph.sampler = { class_type: "PrimitiveString", inputs: { value: "euler" } };
|
||||
graph.scheduler = { class_type: "PrimitiveStringMultiline", inputs: { value: "normal" } };
|
||||
graph.lora = { class_type: "LoraLoader", inputs: { lora_name: "style", model: ["model", 0] } };
|
||||
assert.deepEqual(factFields.map(field => factsFor(graph)[field].value), ["0", "50", "0", "euler", "normal", "model.safetensors"]);
|
||||
graph.settings = { class_type: "UnknownSettings", inputs: { first: 20, second: 30 } };
|
||||
graph.s.inputs.steps = ["settings", 0];
|
||||
assert.equal(factsFor(graph).steps.status, "unresolved");
|
||||
const custom = {
|
||||
s: { class_type: "SamplerCustomAdvanced", inputs: { guider: ["g", 0], noise: ["n", 0], sampler: ["k", 0], sigmas: ["sigmas", 0] } },
|
||||
g: { class_type: "CFGGuider", inputs: { model: ["m", 0], cfg: "4.0000000000000001" } },
|
||||
n: { class_type: "RandomNoise", inputs: { noise_seed: "9007199254740993" } },
|
||||
k: { class_type: "KSamplerSelect", inputs: { sampler_name: "euler" } },
|
||||
sigmas: { class_type: "BasicScheduler", inputs: { steps: 22, scheduler: "karras" } },
|
||||
m: { class_type: "UNETLoader", inputs: { unet_name: "flux.safetensors" } },
|
||||
};
|
||||
assert.deepEqual(factFields.map(field => factsFor(custom)[field].value), ["9007199254740993", "22", "4.0000000000000001", "euler", "karras", "flux.safetensors"]);
|
||||
custom.s = { class_type: "SamplerCustom", inputs: { model: ["m", 0], cfg: 0, noise_seed: 0, sampler: ["k", 0], sigmas: ["sigmas", 0] } };
|
||||
assert.deepEqual([factsFor(custom).seed.value, factsFor(custom).steps.value, factsFor(custom).cfg.value], ["0", "22", "0"]);
|
||||
custom.sigmas.inputs = { steps: 22 };
|
||||
assert.equal(factsFor(custom).scheduler.status, "not-recorded");
|
||||
assert.equal(factsFor(custom).sampler.value, "euler");
|
||||
});
|
||||
|
||||
test("facts require supported scalar producer types and output slots", () => {
|
||||
const cases = [
|
||||
["PrimitiveInt", "seed", "42", true], ["PrimitiveFloat", "cfg", "6.5000000000000000001", true],
|
||||
["PrimitiveString", "sampler_name", "euler", true], ["PrimitiveStringMultiline", "scheduler", "normal", true],
|
||||
["PrimitiveString", "seed", "42", false], ["PrimitiveStringMultiline", "steps", "42", false],
|
||||
["PrimitiveInt", "sampler_name", "euler", false], ["PrimitiveFloat", "scheduler", "normal", false],
|
||||
["PrimitiveBoolean", "seed", "42", false], ["PrimitiveNode", "seed", "42", false],
|
||||
["UnknownSettings", "seed", "42", false], ["SamplerCombo", "steps", "42", false],
|
||||
];
|
||||
for (const [class_type, input, value, supported] of cases) {
|
||||
for (const slot of [0, 1, 999, -1, 0.5]) {
|
||||
const graph = factGraph({ [input]: ["settings", slot] });
|
||||
graph.settings = { class_type, inputs: { value, [input]: value } };
|
||||
const fact = factsFor(graph)[input === "sampler_name" ? "sampler" : input];
|
||||
assert.equal(fact.status, supported && slot === 0 ? "known" : "unresolved", `${class_type} output${slot} as ${input}`);
|
||||
assert.equal(fact.value, supported && slot === 0 ? value : null);
|
||||
}
|
||||
}
|
||||
const graph = factGraph({ seed: ["primitive", 0] });
|
||||
graph.primitive = { class_type: "PrimitiveInt", inputs: { seed: "987" } };
|
||||
assert.equal(factsFor(graph).seed.status, "unresolved", "a matching input is not the output's value");
|
||||
graph.primitive.inputs = { value: "42", seed: "987" };
|
||||
assert.equal(factsFor(graph).seed.value, "42");
|
||||
for (const [input, class_type, inputs] of [
|
||||
["seed", "RandomNoise", { noise_seed: "42", seed: "42" }],
|
||||
["sampler_name", "KSamplerSelect", { sampler_name: "euler" }],
|
||||
["steps", "BasicScheduler", { steps: "42" }],
|
||||
["cfg", "CFGGuider", { cfg: "7" }],
|
||||
]) {
|
||||
const graph = factGraph({ [input]: ["provider", 0] }); graph.provider = { class_type, inputs };
|
||||
assert.equal(factsFor(graph)[input === "sampler_name" ? "sampler" : input].status, "unresolved", `${class_type} is not a generic scalar output`);
|
||||
}
|
||||
});
|
||||
|
||||
function customFactGraph() {
|
||||
return {
|
||||
s: { class_type: "SamplerCustomAdvanced", inputs: { guider: ["g", 0], noise: ["n", 0], sampler: ["k", 0], sigmas: ["sigmas", 0] } },
|
||||
g: { class_type: "CFGGuider", inputs: { model: ["m", 0], cfg: ["cfg", 0] } },
|
||||
n: { class_type: "RandomNoise", inputs: { noise_seed: ["seed", 0] } },
|
||||
k: { class_type: "KSamplerSelect", inputs: { sampler_name: ["sampler", 0] } },
|
||||
sigmas: { class_type: "BasicScheduler", inputs: { steps: ["steps", 0], scheduler: ["scheduler", 0] } },
|
||||
seed: { class_type: "PrimitiveInt", inputs: { value: "9007199254740993" } },
|
||||
cfg: { class_type: "PrimitiveFloat", inputs: { value: "4.0000000000000001" } },
|
||||
steps: { class_type: "PrimitiveInt", inputs: { value: 22 } },
|
||||
sampler: { class_type: "PrimitiveString", inputs: { value: "euler" } },
|
||||
scheduler: { class_type: "PrimitiveString", inputs: { value: "karras" } },
|
||||
m: { class_type: "UNETLoader", inputs: { unet_name: "flux.safetensors" } },
|
||||
};
|
||||
}
|
||||
|
||||
test("facts require supported custom providers and guider outputs", () => {
|
||||
assert.deepEqual(factFields.map(field => factsFor(customFactGraph())[field].value), ["9007199254740993", "22", "4.0000000000000001", "euler", "karras", "flux.safetensors"]);
|
||||
for (const [input, nodeId, fields] of [["noise", "n", ["seed"]], ["sampler", "k", ["sampler"]], ["sigmas", "sigmas", ["steps", "scheduler"]], ["guider", "g", ["cfg", "model"]]]) {
|
||||
for (const slot of [1, 999, -1, 0.5]) {
|
||||
const graph = customFactGraph(); graph.s.inputs[input] = [nodeId, slot];
|
||||
for (const field of fields) assert.equal(factsFor(graph)[field].status, "unresolved", `${input} output${slot} ${field}`);
|
||||
}
|
||||
for (const class_type of ["UnknownProvider", "PrimitiveInt", "PrimitiveString", "BasicGuider"]) {
|
||||
if (input === "guider" && class_type === "BasicGuider") continue;
|
||||
const graph = customFactGraph(); graph[nodeId].class_type = class_type;
|
||||
for (const field of fields) assert.equal(factsFor(graph)[field].status, "unresolved", `${class_type} cannot provide ${input}`);
|
||||
}
|
||||
}
|
||||
const basic = customFactGraph(); basic.g = { class_type: "BasicGuider", inputs: { model: ["m", 0] } };
|
||||
assert.equal(factsFor(basic).model.value, "flux.safetensors");
|
||||
assert.equal(factsFor(basic).cfg.status, "not-recorded");
|
||||
});
|
||||
|
||||
test("facts require model output ancestry with supported loaders and passthroughs", () => {
|
||||
for (const [class_type, key] of [["CheckpointLoader", "ckpt_name"], ["CheckpointLoaderSimple", "ckpt_name"], ["unCLIPCheckpointLoader", "ckpt_name"], ["UNETLoader", "unet_name"]]) {
|
||||
for (const slot of [0, 1, 2, 999, -1, 0.5]) {
|
||||
const graph = factGraph({ model: ["loader", slot] });
|
||||
graph.loader = { class_type, inputs: { [key]: "selected.safetensors" } };
|
||||
assert.equal(factsFor(graph).model.value, slot === 0 ? "selected.safetensors" : null, `${class_type} output${slot}`);
|
||||
assert.equal(factsFor(graph).model.status, slot === 0 ? "known" : "unresolved");
|
||||
}
|
||||
}
|
||||
for (const class_type of ["LoraLoader", "LoraLoaderModelOnly", "UnknownModelPassThrough"]) {
|
||||
for (const slot of [0, 1, 2, 999]) {
|
||||
const graph = factGraph({ model: ["lora", slot] }); graph.lora = { class_type, inputs: { model: ["model", 0] } };
|
||||
assert.equal(factsFor(graph).model.status, class_type !== "UnknownModelPassThrough" && slot === 0 ? "known" : "unresolved", `${class_type} output${slot}`);
|
||||
}
|
||||
}
|
||||
for (const [class_type, inputs] of [["UnknownLoader", { ckpt_name: "fake" }], ["PrimitiveString", { model_name: "fake", value: "fake" }], ["__proto__", { "[object Object]": "fake" }]]) {
|
||||
const graph = factGraph({ model: ["unknown", 0] }); graph.unknown = { class_type, inputs };
|
||||
assert.equal(factsFor(graph).model.status, "unresolved");
|
||||
}
|
||||
for (const [input, field] of [["sampler_name", "sampler"], ["scheduler", "scheduler"]]) assert.equal(factsFor(factGraph({ [input]: 42 }))[field].status, "unresolved");
|
||||
const numericModel = factGraph(); numericModel.model.inputs.ckpt_name = 42;
|
||||
assert.equal(factsFor(numericModel).model.status, "unresolved", "model names require string evidence");
|
||||
});
|
||||
|
||||
test("wide workflow lookups retain selected core sampler facts", () => {
|
||||
const nodes = [{ id: "text", type: "PrimitiveString", widgets_values_named: { value: "unrelated text" } }], links = [];
|
||||
let linkId = 1;
|
||||
for (let index = 0; index < 20; index++) {
|
||||
nodes.push({ id: `r${index}`, type: "Reroute", inputs: [{ name: "value", link: linkId }] });
|
||||
links.push([linkId++, index ? `r${index - 1}` : "text", 0, `r${index}`, 0, "STRING"]);
|
||||
}
|
||||
for (let index = 0; index < 40; index++) {
|
||||
nodes.push({ id: `encoder${index}`, type: "CLIPTextEncode", inputs: [{ name: "text", link: linkId }] });
|
||||
links.push([linkId++, "r19", 0, `encoder${index}`, 0, "STRING"]);
|
||||
}
|
||||
nodes.push({ id: "model", type: "CheckpointLoaderSimple", widgets_values_named: { ckpt_name: "selected.safetensors" } },
|
||||
{ id: "steps", type: "PrimitiveInt", widgets_values_named: { value: 28 } },
|
||||
{ id: "s", type: "KSampler", widgets_values: ["9007199254740993", "fixed", 28, "6.5000000000000000001", "euler", "normal"], inputs: [{ name: "model", link: linkId }, { name: "steps", link: linkId + 1 }] });
|
||||
links.push([linkId, "model", 0, "s", 0, "MODEL"], [linkId + 1, "steps", 0, "s", 1, "INT"]);
|
||||
const facts = factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph({ nodes, links }) });
|
||||
assert.deepEqual(factFields.map(field => facts[field].value), ["9007199254740993", "28", "6.5000000000000000001", "euler", "normal", "selected.safetensors"]);
|
||||
});
|
||||
|
||||
test("workflow facts require supported passthrough output slots and bypass ancestry", () => {
|
||||
function workflow(type, slot, mode) {
|
||||
const pass = { id: "pass", type, ...(mode === undefined ? {} : { mode }), widgets_values: ["selected"], inputs: [{ name: type === "Reroute" ? "value" : "model", type: "MODEL", link: 1 }] };
|
||||
const nodes = [{ id: "m", type: "CheckpointLoaderSimple", widgets_values_named: { ckpt_name: "selected.safetensors" } }, pass,
|
||||
{ id: "s", type: "KSampler", widgets_values: [0, "fixed", 28, "0", "euler", "normal"], inputs: [{ name: "model", link: 2 }] }];
|
||||
if (type === "GetNode") {
|
||||
nodes.push({ id: "set", type: "SetNode", widgets_values: ["selected"], inputs: [{ name: "model", link: 1 }] });
|
||||
pass.inputs = [];
|
||||
}
|
||||
return { nodes, links: [[1, "m", 0, "pass", 0, "MODEL"], [2, "pass", slot, "s", 0, "MODEL"]] };
|
||||
}
|
||||
const model = value => factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(value) }).model;
|
||||
for (const [type, mode] of [["Reroute", undefined], ["GetNode", undefined], ["SetNode", undefined], ["LoraLoader", 4], ["LoraLoaderModelOnly", 4]]) {
|
||||
for (const slot of [0, 1, 999, -1, 0.5]) {
|
||||
assert.equal(model(workflow(type, slot, mode)).status, slot === 0 ? "known" : "unresolved", `${type} output${slot}`);
|
||||
}
|
||||
}
|
||||
assert.equal(model(workflow("UnknownBypass", 0, 4)).status, "unresolved");
|
||||
const duplicate = workflow("LoraLoader", 0, 4);
|
||||
duplicate.nodes[1].inputs.push({ name: "model", type: "MODEL", link: 1 });
|
||||
assert.equal(model(duplicate).status, "unresolved", "bypass model input is uniquely named");
|
||||
const wrongOrigin = workflow("Reroute", 0); wrongOrigin.links[0][2] = 1;
|
||||
assert.equal(model(wrongOrigin).status, "unresolved", "passthrough cannot turn CLIP into MODEL");
|
||||
const cycle = workflow("Reroute", 0); cycle.links[0][1] = "pass";
|
||||
assert.equal(model(cycle).status, "unresolved");
|
||||
const unnamed = workflow("GetNode", 0);
|
||||
delete unnamed.nodes.find(node => node.id === "pass").widgets_values;
|
||||
delete unnamed.nodes.find(node => node.id === "set").widgets_values;
|
||||
assert.equal(model(unnamed).status, "unresolved", "missing Get/Set names are not a source association");
|
||||
const scalar = factGraph({ seed: ["bypass", 0] });
|
||||
scalar.bypass = { class_type: "LoraLoader", inputs: { model: ["primitive", 0] } };
|
||||
scalar.primitive = { class_type: "PrimitiveInt", inputs: { value: 42 } };
|
||||
const scalarWorkflow = factWorkflowFromGraph(scalar); scalarWorkflow.nodes.find(node => node.id === "bypass").mode = 4;
|
||||
assert.equal(factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(scalarWorkflow) }).seed.status, "unresolved", "bypassed MODEL output cannot become a scalar");
|
||||
for (const [input, field] of [["noise", "seed"], ["sampler", "sampler"], ["sigmas", "steps"], ["guider", "model"]]) {
|
||||
const graph = customFactGraph(), target = graph.s.inputs[input];
|
||||
graph.s.inputs[input] = ["bypass", 0]; graph.bypass = { class_type: "LoraLoaderModelOnly", inputs: { model: target } };
|
||||
const workflow = factWorkflowFromGraph(graph); workflow.nodes.find(node => node.id === "bypass").mode = 4;
|
||||
assert.equal(factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(workflow) })[field].status, "unresolved", `MODEL output cannot become ${input}`);
|
||||
}
|
||||
});
|
||||
|
||||
function factWorkflowFromGraph(graph) {
|
||||
const nodes = [], links = [];
|
||||
for (const [id, node] of Object.entries(graph)) {
|
||||
const inputs = [], widgets_values_named = {};
|
||||
for (const [name, value] of Object.entries(node.inputs)) {
|
||||
if (Array.isArray(value)) {
|
||||
const link = links.length + 1;
|
||||
links.push([link, value[0], value[1], id, inputs.length, "*"]);
|
||||
inputs.push({ name, link });
|
||||
} else widgets_values_named[name] = value;
|
||||
}
|
||||
nodes.push({ id, type: node.class_type, inputs, widgets_values_named });
|
||||
}
|
||||
return { nodes, links };
|
||||
}
|
||||
|
||||
test("workflow facts count combined original ancestry once across passthroughs and providers", () => {
|
||||
const inspect = graph => factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(factWorkflowFromGraph(graph)) });
|
||||
function chain(graph, reroutes, primitives, model = false) {
|
||||
let link = model ? ["model", 0] : null;
|
||||
for (let index = 0; index < primitives; index++) {
|
||||
graph[`p${index}`] = { class_type: model ? "LoraLoader" : "PrimitiveInt", inputs: model ? { model: link } : { value: link || "42" } };
|
||||
link = [`p${index}`, 0];
|
||||
}
|
||||
for (let index = 0; index < reroutes; index++) {
|
||||
graph[`r${index}`] = { class_type: "Reroute", inputs: { value: link } };
|
||||
link = [`r${index}`, 0];
|
||||
}
|
||||
return link;
|
||||
}
|
||||
for (const [reroutes, known] of [[32, true], [33, false]]) {
|
||||
const scalar = factGraph(); scalar.s.inputs.seed = chain(scalar, reroutes, 32);
|
||||
assert.equal(inspect(scalar).seed.status, known ? "known" : "unresolved", `${reroutes}+32 scalar links`);
|
||||
const model = factGraph(); model.s.inputs.model = chain(model, reroutes, 31, true);
|
||||
assert.equal(inspect(model).model.status, known ? "known" : "unresolved", `${reroutes}+31 LoRA+loader links`);
|
||||
const noise = customFactGraph();
|
||||
noise.n.inputs.noise_seed = chain(noise, reroutes - 1, 31);
|
||||
noise.noiseAlias = { class_type: "Reroute", inputs: { value: ["n", 0] } };
|
||||
noise.s.inputs.noise = ["noiseAlias", 0];
|
||||
assert.equal(inspect(noise).seed.status, known ? "known" : "unresolved", `noise alias+provider+${reroutes - 1}+31 links`);
|
||||
const guider = customFactGraph();
|
||||
guider.model = { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "model.safetensors" } };
|
||||
guider.g.inputs.model = chain(guider, reroutes - 1, 30, true);
|
||||
guider.guiderAlias = { class_type: "Reroute", inputs: { value: ["g", 0] } };
|
||||
guider.s.inputs.guider = ["guiderAlias", 0];
|
||||
assert.equal(inspect(guider).model.status, known ? "known" : "unresolved", `guider alias+provider+${reroutes - 1}+30 LoRA+loader links`);
|
||||
}
|
||||
const cycle = factGraph();
|
||||
cycle.s.inputs.seed = ["r", 0]; cycle.r = { class_type: "Reroute", inputs: { value: ["p", 0] } }; cycle.p = { class_type: "PrimitiveInt", inputs: { value: ["r", 0] } };
|
||||
assert.equal(inspect(cycle).seed.status, "unresolved");
|
||||
cycle.s.inputs.model = ["r", 0]; cycle.p = { class_type: "LoraLoader", inputs: { model: ["r", 0] } };
|
||||
assert.equal(inspect(cycle).model.status, "unresolved");
|
||||
for (const [reroutes, known] of [[32, true], [33, false]]) {
|
||||
const graph = factGraph(), value = chain(graph, reroutes, 31);
|
||||
const workflow = factWorkflowFromGraph(graph);
|
||||
const sourceLink = workflow.links.length + 1;
|
||||
workflow.nodes.push({ id: "set", type: "SetNode", widgets_values: ["seed"], inputs: [{ name: "value", link: sourceLink }] }, { id: "get", type: "GetNode", widgets_values: ["seed"], inputs: [] });
|
||||
workflow.links.push([sourceLink, value[0], 0, "set", 0, "INT"], [sourceLink + 1, "get", 0, "s", 0, "INT"]);
|
||||
workflow.nodes.find(node => node.id === "s").inputs.push({ name: "seed", link: sourceLink + 1 });
|
||||
const fact = factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(workflow) }).seed;
|
||||
assert.equal(fact.status, known ? "known" : "unresolved", `Get+${reroutes}+31 links, no extra setter expansion`);
|
||||
}
|
||||
});
|
||||
|
||||
test("workflow sampler selection retains prototype-named node identities", () => {
|
||||
const workflow = { nodes: [
|
||||
{ id: "__proto__", type: "KSampler", widgets_values: [1, "fixed", 20, "7", "euler", "normal"] },
|
||||
{ id: "other", type: "KSampler", widgets_values: [2, "fixed", 20, "7", "euler", "normal"] },
|
||||
], links: [] };
|
||||
assert.ok(Object.values(factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(workflow) })).every(fact => fact.status === "ambiguous"));
|
||||
});
|
||||
|
||||
test("workflow-only facts are configuration without creating sendable roles", () => {
|
||||
const workflow = { nodes: [
|
||||
{ id: 1, type: "UNETLoader", widgets_values: ["flux.safetensors", "default"] },
|
||||
{ id: 2, type: "SetNode", widgets_values: ["model"], inputs: [{ name: "MODEL", link: 1 }] },
|
||||
{ id: 3, type: "GetNode", widgets_values: ["model"] },
|
||||
{ id: 4, type: "KSamplerAdvanced", widgets_values: ["enable", "9007199254740993", "fixed", 28, "6.5000000000000000001", "euler", "normal", 0, 28, "disable"], inputs: [{ name: "model", link: 2 }] },
|
||||
], links: [[1, 1, 0, 2, 0, "MODEL"], [2, 3, 0, 4, 0, "MODEL"]] };
|
||||
const facts = factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(workflow) });
|
||||
assert.deepEqual(factFields.map(field => facts[field].value), ["9007199254740993", "28", "6.5000000000000000001", "euler", "normal", "flux.safetensors"]);
|
||||
assert.ok(Object.values(facts).every(fact => fact.source === "workflow" && fact.evidence === "configuration"));
|
||||
assert.equal(facts.positive, undefined);
|
||||
workflow.nodes.push({ ...workflow.nodes[3] });
|
||||
assert.ok(Object.values(factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(workflow) })).every(fact => fact.status === "unresolved"));
|
||||
});
|
||||
|
||||
test("limited projection stays unresolved", () => {
|
||||
for (const extra of [
|
||||
{ prompt_graph: { status: "limited", value: null } },
|
||||
{ prompt_graph: absentFactGraph(), workflow_graph: { status: "limited", value: null } },
|
||||
{ prompt_graph: absentFactGraph(), metadataStatus: "limited", parameters: null },
|
||||
]) {
|
||||
const facts = factsFor(null, extra);
|
||||
assert.ok(Object.values(facts).every(fact => fact.status === "unresolved" && fact.value === null && fact.reason === "projection-limited"));
|
||||
}
|
||||
const unreadable = factsFor(null, { prompt_graph: absentFactGraph(), metadataStatus: "unreadable" });
|
||||
assert.equal(unreadable.seed.reason, "metadata-unreadable");
|
||||
const absent = factsFor(null, { prompt_graph: absentFactGraph(), metadataStatus: "absent" });
|
||||
assert.equal(absent.seed.status, "not-recorded");
|
||||
});
|
||||
|
||||
test("facts preserve exact video-projected decimals without reading reserialized raw", () => {
|
||||
const graph = factGraph();
|
||||
const facts = factsFor(graph, { recordFormats: { prompt: "reserialized-json" }, raw: { prompt: '{"s":{"inputs":{"seed":9007199254740992,"cfg":6.5}}}' } });
|
||||
assert.equal(facts.seed.value, "9007199254740993");
|
||||
assert.equal(facts.cfg.value, "6.5000000000000000001");
|
||||
assert.equal(factsFor(factGraph({ cfg: "1E+1000000000" })).cfg.value, "1E+1000000000");
|
||||
});
|
||||
|
||||
test("nontext parameters remain unresolved", () => {
|
||||
const parameters = "A portrait\nNegative prompt: blur\nSteps: 28, Sampler: Euler, Schedule type: Karras, CFG scale: 6.5000000000000000001, Seed: 9007199254740993, Model: model";
|
||||
const base = { prompt_graph: absentFactGraph(), parameters };
|
||||
for (const format of [undefined, "reserialized-json", "invalid"]) {
|
||||
const facts = factsFor(null, { ...base, recordFormats: format ? { parameters: format } : {} });
|
||||
assert.ok(Object.values(facts).every(fact => fact.status === "unresolved" && fact.value === null));
|
||||
}
|
||||
const facts = factsFor(null, { ...base, recordFormats: { parameters: "original-text" } });
|
||||
assert.deepEqual(factFields.map(field => facts[field].value), ["9007199254740993", "28", "6.5000000000000000001", "Euler", "Karras", "model"]);
|
||||
assert.ok(Object.values(facts).every(fact => fact.source === "embedded-parameters" && fact.evidence === "embedded-parameters"));
|
||||
for (const invalid of [parameters + "\nSteps: 5, Seed: 2", parameters + ", Seed: 1", parameters + "\ntrailing", parameters.replace("Seed: 9007199254740993", "Seed: -1"), parameters.replace("Karras", "invented"), parameters.replace("Negative prompt: blur", "Negative prompt: blur\nNegative prompt: again")]) {
|
||||
assert.ok(Object.values(factsFor(null, { ...base, parameters: invalid, recordFormats: { parameters: "original-text" } })).some(fact => fact.status === "unresolved"));
|
||||
}
|
||||
});
|
||||
|
||||
test("malformed or unsupported API evidence blocks plausible workflow and parameter fallback", () => {
|
||||
const workflow = { nodes: [{ id: 1, type: "KSampler", widgets_values: [42, "fixed", 20, 7, "euler", "normal"] }], links: [] };
|
||||
const extra = { workflow_graph: parsedFactGraph(workflow), parameters: "portrait\nSteps: 20, Sampler: Euler, CFG scale: 7, Seed: 42", recordFormats: { parameters: "original-text" } };
|
||||
for (const prompt_graph of [{ status: "malformed", value: null }, parsedFactGraph({ s: { class_type: "UnknownSampler", inputs: { seed: 99 } } }), parsedFactGraph({ s: { class_type: "KSampler", inputs: [] } })]) {
|
||||
const facts = factsFor(null, { ...extra, prompt_graph });
|
||||
assert.ok(Object.values(facts).every(fact => fact.status === "unresolved" && fact.value === null));
|
||||
assert.ok(Object.values(facts).every(fact => fact.source !== "workflow" && fact.source !== "embedded-parameters"));
|
||||
}
|
||||
});
|
||||
|
||||
test("conservative facts bound cycles fan-out visits and link depth", () => {
|
||||
const graph = factGraph({ seed: ["loop", 0], model: ["loop", 0] });
|
||||
graph.loop = { class_type: "Unknown", inputs: { seed: ["loop", 0], model: ["loop", 0], a: ["loop", 0], b: ["loop", 0] } };
|
||||
assert.equal(factsFor(graph).seed.status, "unresolved");
|
||||
assert.equal(factsFor(graph).model.status, "unresolved");
|
||||
graph.loop = { class_type: "PrimitiveInt", inputs: { value: ["loop", 0] } };
|
||||
assert.equal(factsFor(graph).seed.status, "unresolved", "registered scalar cycle is bounded");
|
||||
graph.loop = { class_type: "LoraLoader", inputs: { model: ["loop", 0] } };
|
||||
assert.equal(factsFor(graph).model.status, "unresolved", "registered model cycle is bounded");
|
||||
const chain = depth => {
|
||||
const value = factGraph({ seed: ["p0", 0] });
|
||||
for (let index = 0; index < depth; index++) value[`p${index}`] = { class_type: "PrimitiveInt", inputs: { value: index === depth - 1 ? "42" : [`p${index + 1}`, 0] } };
|
||||
return value;
|
||||
};
|
||||
assert.equal(factsFor(chain(64)).seed.value, "42");
|
||||
assert.equal(factsFor(chain(65)).seed.status, "unresolved");
|
||||
const custom = chain(64);
|
||||
custom.s = { class_type: "SamplerCustomAdvanced", inputs: { noise: ["noise", 0] } };
|
||||
custom.noise = { class_type: "RandomNoise", inputs: { noise_seed: ["p0", 0] } };
|
||||
assert.equal(factsFor(custom).seed.status, "unresolved", "the provider itself consumes one of the 64 allowed links");
|
||||
const large = factGraph();
|
||||
for (let index = 0; index < 498; index++) large[`unused${index}`] = { class_type: "PrimitiveInt", inputs: { value: index } };
|
||||
assert.equal(factsFor(large).seed.value, "9007199254740993");
|
||||
large.extra = { class_type: "PrimitiveInt", inputs: { value: 1 } };
|
||||
assert.equal(factsFor(large).seed.status, "unresolved");
|
||||
});
|
||||
|
||||
// Same shape as the workflow attached to #75: PromptManager nodes are saved as
|
||||
// CLIPTextEncode with extra inputs, and the checkpoint loader is pysssss'.
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createScope} from '../../web/js/shared/toolkit/core.js';
|
||||
import * as module from '../../web/js/workspace/prompt-inspector.mjs';
|
||||
|
||||
function dom() {
|
||||
class Element {
|
||||
constructor(tag){this.tagName=tag;this.children=[];this.dataset={};this.attributes={};this.listeners=new Map();this.classList={add:()=>{}};}
|
||||
append(...children){for(const child of children){child.parent=this;this.children.push(child);}}
|
||||
replaceChildren(...children){this.children=[];this.append(...children);}
|
||||
setAttribute(key,value){this.attributes[key]=value;}
|
||||
addEventListener(type,listener,{signal}={}){this.listeners.set(type,listener);signal?.addEventListener('abort',()=>this.listeners.delete(type),{once:true});}
|
||||
closest(selector){if(selector==='section'&&this.tagName==='section')return this;if(selector==='[data-send-role]'&&Object.hasOwn(this.dataset,'sendRole'))return this;if(selector==='[data-copy-role]'&&Object.hasOwn(this.dataset,'copyRole'))return this;if(selector==='[data-retry-prompts]'&&Object.hasOwn(this.dataset,'retryPrompts'))return this;return this.parent?.closest(selector);}
|
||||
click(target){this.listeners.get('click')?.({target});}
|
||||
}
|
||||
const previous=global.document;global.document={createElement:tag=>new Element(tag),createElementNS:(_,tag)=>new Element(tag)};
|
||||
const walk=(root,predicate)=>[...(predicate(root)?[root]:[]),...root.children.flatMap(child=>walk(child,predicate))];
|
||||
return {Element,walk,restore:()=>{global.document=previous;}};
|
||||
}
|
||||
const record=text=>({status:'resolved',text,source:'library'});
|
||||
test('shared prompt blocks send complete selected library values and explicit empty negatives',()=>{
|
||||
const f=dom(),scope=createScope(),container=new f.Element('div'),sent=[];
|
||||
try{
|
||||
const view=module.mountPromptBlocks(container,{parentScope:scope,sender:{open:roles=>sent.push(roles)},prompts:{positive:record('<literal>\nfull positive'),negative:{status:'unresolved',message:'Choose a saved negative.'}}});
|
||||
assert.ok(f.walk(container,node=>node.textContent==='<literal>\nfull positive').length);
|
||||
assert.equal(f.walk(container,node=>node.dataset.sendRole==='pair').length,0);
|
||||
view.update({positive:record('Complete positive'),negative:record('')});
|
||||
const pair=f.walk(container,node=>node.dataset.sendRole==='pair')[0];container.click(pair);
|
||||
assert.deepEqual(sent,[{positive:'Complete positive',negative:''}]);
|
||||
assert.equal(f.walk(container,node=>node.textContent==='Saved library prompt').length,2);
|
||||
view.dispose();container.click(pair);assert.equal(sent.length,1);
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
test('positive-only cards never display or send a hidden negative or pair',()=>{
|
||||
const f=dom(),scope=createScope(),container=new f.Element('div'),sent=[];
|
||||
try{
|
||||
module.mountPromptBlocks(container,{parentScope:scope,roles:['positive'],prompts:{positive:record('positive'),negative:record('hidden')},sender:{open:roles=>sent.push(roles)}});
|
||||
assert.equal(f.walk(container,node=>node.attributes['aria-label']==='Negative prompt').length,0);
|
||||
assert.equal(f.walk(container,node=>node.dataset.sendRole==='pair').length,0);
|
||||
const button=f.walk(container,node=>node.dataset.sendRole==='positive')[0];container.click(button);assert.deepEqual(sent,[{positive:'positive'}]);
|
||||
scope.dispose();container.click(button);assert.equal(sent.length,1);
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
|
||||
test('disposed blocks ignore updates and aborted parents never mount content',()=>{
|
||||
const f=dom(),scope=createScope(),container=new f.Element('div');
|
||||
try{
|
||||
const view=module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:record('original')}});
|
||||
view.dispose();view.update({positive:record('late')});assert.equal(f.walk(container,node=>node.textContent==='late').length,0);
|
||||
scope.dispose();const other=new f.Element('div');module.mountPromptBlocks(other,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:record('late')}});assert.equal(other.children.length,0);
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
|
||||
test('frozen fields disclose their exact source and keep null, empty and whitespace actions distinct',()=>{
|
||||
const f=dom(),scope=createScope(),container=new f.Element('div'),sent=[];
|
||||
const field=(text,source)=>({status:text===null?'unresolved':'resolved',text,source:'execution',evidence:{provenance:'recorded',source,reason:null}});
|
||||
try{
|
||||
const view=module.mountPromptBlocks(container,{parentScope:scope,sender:{open:roles=>sent.push(roles)},prompts:{positive:field('positive','runtime_return'),negative:field(null,'unknown')}});
|
||||
assert.equal(f.walk(container,node=>node.dataset.sendRole==='negative'||node.dataset.sendRole==='pair'||node.dataset.copyRole==='negative').length,0);
|
||||
assert.ok(f.walk(container,node=>node.textContent?.includes('runtime_return')).length);
|
||||
view.update({positive:field('positive','runtime_return'),negative:field('','host_cache_output')});
|
||||
assert.ok(f.walk(container,node=>node.textContent?.includes('host_cache_output')).length);
|
||||
const pair=f.walk(container,node=>node.dataset.sendRole==='pair')[0];container.click(pair);
|
||||
assert.deepEqual(sent,[{positive:'positive',negative:''}]);
|
||||
view.update({positive:field('positive','runtime_return'),negative:field(' \n\t ','host_cache_output')});
|
||||
assert.equal(f.walk(container,node=>node.tagName==='pre'&&node.textContent===' \n\t ').length,1);
|
||||
container.click(f.walk(container,node=>node.dataset.sendRole==='negative')[0]);
|
||||
assert.deepEqual(sent[1],{negative:' \n\t '});
|
||||
view.setState({prompts:{positive:field('positive','runtime_return'),negative:field('old','host_cache_output')},actionsEnabled:false});
|
||||
container.click(f.walk(container,node=>node.dataset.sendRole==='pair')[0]);assert.equal(sent.length,2);
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const source = import('../../web/js/prompt-bridge-adapter.js');
|
||||
function fixture(type='PromptManagerPair') {
|
||||
const widgets = ['text','negative_text','prepend_text','append_text','category','tags'].map((name,index) => ({name,value:['old','bad',' start ',' end ','cat','tag'][index],inputEl:{value:['old','bad',' start ',' end ','cat','tag'][index]}}));
|
||||
const node = {id:7,type,title:'My prompts',widgets,widgets_values:widgets.map(w=>w.value),inputs:[],_focusedView:{render(){node.rendered=(node.rendered||0)+1;}},setDirtyCanvas(){}};
|
||||
const app = {graph:{_nodes:[node],_version:1,setDirtyCanvas(){}},canvas:{}};
|
||||
return {app,node,widget:name=>node.widgets.find(w=>w.name===name)};
|
||||
}
|
||||
test('lists only explicit PromptManager destinations and roles for all five schemas',async()=>{
|
||||
const {createPromptAdapter}=await source;
|
||||
const types=['PromptManager','PromptManagerText','PromptManagerFocused','PromptManagerSplit','PromptManagerPair'];
|
||||
const nodes=types.map((type,id)=>({...fixture(type).node,id})); nodes.push({...fixture().node,id:8,type:'CLIPTextEncode'});
|
||||
const targets=createPromptAdapter({graph:{_nodes:nodes}}).listTargets();
|
||||
assert.deepEqual(targets.map(t=>t.type),types);
|
||||
assert.deepEqual(targets.map(t=>t.roles),types.map(type=>type==='PromptManagerPair'?['positive','negative']:['positive']));
|
||||
assert.equal(targets[0].id,'0'); assert.equal(targets[0].label,'My prompts');
|
||||
assert.deepEqual(createPromptAdapter({}).listTargets(),[]);
|
||||
});
|
||||
test('previews exact composition, updates requested visible/serialized values, preserves other fields',async()=>{
|
||||
const {createPromptAdapter}=await source; const {app,node,widget}=fixture(); const adapter=createPromptAdapter(app);
|
||||
const {preview,snapshot}=adapter.preview('7',{positive:'new'});
|
||||
assert.deepEqual(preview.fields,[{role:'positive',before:'old',after:'new'}]);
|
||||
assert.deepEqual(preview.composition,{prepend:' start ',append:' end ',prependConnected:false,appendConnected:false,before:'start old end',after:'start new end'});
|
||||
let callback; widget('text').callback=(value,canvas,target)=>{callback=[value,canvas,target];};
|
||||
assert.deepEqual(adapter.apply(snapshot),{targetId:'7',values:{positive:'new'}});
|
||||
assert.equal(widget('text').inputEl.value,'new'); assert.equal(node.widgets_values[0],'new'); assert.equal(node.rendered,1);
|
||||
assert.deepEqual(callback,['new',app.canvas,node]);
|
||||
for(const [name,value] of [['negative_text','bad'],['prepend_text',' start '],['append_text',' end '],['category','cat'],['tags','tag']]) assert.equal(widget(name).value,value);
|
||||
assert.throws(()=>adapter.apply(snapshot),/snapshot|preview/i);
|
||||
});
|
||||
test('omitted positive stays intact, empty negative clears explicitly',async()=>{
|
||||
const {createPromptAdapter}=await source; const {app,widget}=fixture(); const adapter=createPromptAdapter(app);
|
||||
adapter.apply(adapter.preview('7',{negative:''}).snapshot);
|
||||
assert.equal(widget('text').value,'old'); assert.equal(widget('negative_text').value,'');
|
||||
});
|
||||
test('rejects invalid roles, unsupported targets, missing/connected fields before mutation',async()=>{
|
||||
const {createPromptAdapter}=await source; const {app,node,widget}=fixture('PromptManagerText'); const adapter=createPromptAdapter(app);
|
||||
for(const roles of [null,{},[],{positive:2},{positive:'x',other:'y'},{negative:'x'},Object.create({positive:'x'})]) assert.throws(()=>adapter.preview('7',roles));
|
||||
assert.throws(()=>adapter.preview('99',{positive:'x'}));
|
||||
node.inputs=[{name:'text',link:0}]; assert.throws(()=>adapter.preview('7',{positive:'x'}),/connect/i);
|
||||
node.inputs=[]; node.widgets=node.widgets.filter(w=>w.name!=='text'); assert.throws(()=>adapter.preview('7',{positive:'x'}),/widget/i);
|
||||
assert.equal(widget('negative_text').value,'bad');
|
||||
});
|
||||
test('connected composition is unknown and never overwritten',async()=>{
|
||||
const {createPromptAdapter}=await source; const {app,node}=fixture(); node.inputs=[{name:'prepend_text',link:0}];
|
||||
const adapter=createPromptAdapter(app), result=adapter.preview('7',{positive:'x'});
|
||||
assert.equal(result.preview.composition.before,null); assert.equal(result.preview.composition.after,null); assert.equal(result.preview.composition.prependConnected,true);
|
||||
adapter.apply(result.snapshot); assert.equal(node.inputs[0].link,0);
|
||||
});
|
||||
test('rejects stale workflow, node, widget, values, serialization, visible values, and connections',async()=>{
|
||||
const {createPromptAdapter}=await source;
|
||||
const mutations=[f=>{f.app.graph={...f.app.graph};},f=>{f.app.graph._version++;},f=>{f.app.graph._nodes=[{...f.node}];},f=>{f.node.type='PromptManagerText';},f=>{f.node.widgets[0]={...f.node.widgets[0]};},f=>{f.widget('negative_text').value='changed';},f=>{f.widget('text').inputEl.value='typing';},f=>{f.node.widgets_values[0]='changed';},f=>{f.node.inputs.push({name:'text',link:2});},f=>{f.node.inputs.push({name:'append_text',link:2});}];
|
||||
for(const mutate of mutations){const f=fixture(),adapter=createPromptAdapter(f.app),{snapshot}=adapter.preview('7',{positive:'new',negative:'newbad'}); mutate(f); assert.throws(()=>adapter.apply(snapshot),/changed|stale/i); assert.notEqual(f.widget('text').value,'new');}
|
||||
});
|
||||
test('validates both roles before writing and rejects forged snapshots',async()=>{
|
||||
const {createPromptAdapter}=await source; const {app,node,widget}=fixture(); const adapter=createPromptAdapter(app),{snapshot}=adapter.preview('7',{positive:'new',negative:'newbad'});
|
||||
node.inputs.push({name:'negative_text',link:3}); assert.throws(()=>adapter.apply(snapshot)); assert.equal(widget('text').value,'old'); assert.throws(()=>adapter.apply({}));
|
||||
});
|
||||
test('callback failure or transformed value rolls all requested values back without an acknowledgement',async()=>{
|
||||
const {createPromptAdapter}=await source;
|
||||
for(const fail of ['throw','transform','visible','serialization']) {
|
||||
const {app,node,widget}=fixture(),adapter=createPromptAdapter(app);
|
||||
widget('negative_text').callback=()=>{if(fail==='throw') throw new Error('callback failed'); if(fail==='transform') widget('text').value='altered'; if(fail==='visible') widget('text').inputEl.value='altered'; if(fail==='serialization') node.widgets_values[0]='altered';};
|
||||
assert.throws(()=>adapter.apply(adapter.preview('7',{positive:'new',negative:'newbad'}).snapshot),/failed|changed|match/i);
|
||||
assert.equal(widget('text').value,'old'); assert.equal(widget('text').inputEl.value,'old'); assert.equal(node.widgets_values[0],'old'); assert.equal(widget('negative_text').value,'bad');
|
||||
}
|
||||
});
|
||||
test('snapshots isolate caller mutations and require the originating adapter',async()=>{
|
||||
const {createPromptAdapter}=await source; const {app,widget}=fixture(),adapter=createPromptAdapter(app),roles={positive:'new'};
|
||||
const result=adapter.preview('7',roles); roles.positive='changed'; result.preview.fields[0].after='changed';
|
||||
assert.throws(()=>createPromptAdapter(app).apply(result.snapshot)); adapter.apply(result.snapshot); assert.equal(widget('text').value,'new');
|
||||
});
|
||||
test('applies positive text for every supported schema without a serialization array',async()=>{
|
||||
const {createPromptAdapter}=await source;
|
||||
for(const type of ['PromptManager','PromptManagerText','PromptManagerFocused','PromptManagerSplit','PromptManagerPair']) {
|
||||
const {app,node,widget}=fixture(type); delete node.widgets_values; delete node._focusedView; delete node.setDirtyCanvas; delete app.graph.setDirtyCanvas;
|
||||
const adapter=createPromptAdapter(app); adapter.apply(adapter.preview('7',{positive:''}).snapshot);
|
||||
assert.equal(widget('text').value,''); assert.equal(node.widgets_values[0],'');
|
||||
}
|
||||
});
|
||||
test('matches Python whitespace composition and validates the combined one MiB UTF-8 role limit',async()=>{
|
||||
const {createPromptAdapter}=await source; const {app,node,widget}=fixture(); node.title=''; widget('prepend_text').value=' '; widget('append_text').value='';
|
||||
const adapter=createPromptAdapter(app);
|
||||
assert.equal(adapter.preview('7',{positive:' x '}).preview.composition.after,' x ');
|
||||
assert.equal(adapter.preview('7',{positive:''}).preview.composition.after,'');
|
||||
assert.equal(adapter.preview('7',{positive:'x'.repeat(1_048_576)}).preview.fields[0].after.length,1_048_576);
|
||||
assert.throws(()=>adapter.preview('7',{positive:'x'.repeat(1_048_577)}));
|
||||
assert.throws(()=>adapter.preview('7',{positive:'x'.repeat(524_288),negative:'y'.repeat(524_289)}));
|
||||
assert.throws(()=>adapter.preview('7',{positive:'é'.repeat(524_289)}));
|
||||
assert.equal(adapter.preview('7',{positive:'é'.repeat(524_288)}).preview.fields[0].after.length,524_288);
|
||||
assert.throws(()=>adapter.preview('7',{get positive(){throw new Error('Getter must not run');}}),/strings/i);
|
||||
assert.throws(()=>adapter.preview('7',{positive:'x',[Symbol('unknown')]:'y'}));
|
||||
assert.equal(adapter.preview('7',Object.assign(Object.create(null),{positive:'x'})).preview.fields[0].after,'x');
|
||||
assert.equal(adapter.listTargets()[0].label,'PromptManagerPair');
|
||||
});
|
||||
test('callback changes to graph, connections, widgets or protected prompt fields fail and restore fields',async()=>{
|
||||
const {createPromptAdapter}=await source;
|
||||
const mutations=[f=>{f.app.graph={...f.app.graph};},f=>{f.node.inputs=[{name:'text',link:4}];},f=>{f.node.widgets[0]={...f.node.widgets[0]};},f=>{f.widget('negative_text').value='unexpected';},f=>{f.widget('prepend_text').value='unexpected';},f=>{f.widget('category').value='unexpected';}];
|
||||
for(const mutate of mutations){const f=fixture(),adapter=createPromptAdapter(f.app); f.widget('text').callback=()=>mutate(f); assert.throws(()=>adapter.apply(adapter.preview('7',{positive:'new'}).snapshot),/failed/i); assert.equal(f.widget('negative_text').value,'bad'); assert.equal(f.widget('prepend_text').value,' start '); assert.equal(f.widget('category').value,'cat');}
|
||||
});
|
||||
test('minimal classic node without optional widgets remains a valid destination',async()=>{
|
||||
const {createPromptAdapter}=await source; const {app,node}=fixture('PromptManager'); node.widgets=node.widgets.slice(0,1); node.widgets_values=['old']; delete node.widgets[0].inputEl;
|
||||
const adapter=createPromptAdapter(app), result=adapter.preview('7',{positive:'new'});
|
||||
assert.equal(result.preview.composition.before,'old'); assert.equal(result.preview.composition.after,'new');
|
||||
assert.deepEqual(adapter.apply(result.snapshot).values,{positive:'new'});
|
||||
});
|
||||
test('callback removal of serialization or replacement of visible controls cannot acknowledge success',async()=>{
|
||||
const {createPromptAdapter}=await source;
|
||||
for(const mutate of [f=>{delete f.node.widgets_values;},f=>{f.widget('text').inputEl={value:'new'};},f=>{f.node.id=8;},f=>{f.node.widgets.reverse();},f=>{delete f.node.widgets;},f=>{f.node.widgets=[];}]) {
|
||||
const f=fixture(),adapter=createPromptAdapter(f.app); const originalInput=f.widget('text').inputEl;
|
||||
f.widget('text').callback=()=>mutate(f);
|
||||
assert.throws(()=>adapter.apply(adapter.preview('7',{positive:'new'}).snapshot),/failed/i);
|
||||
assert.equal(f.widget('text').value,'old'); assert.equal(originalInput.value,'old'); assert.equal(f.node.widgets_values[0],'old');
|
||||
}
|
||||
});
|
||||
test('benign host serialization may establish or normalize equivalent serialized values after preview',async()=>{
|
||||
const {createPromptAdapter}=await source;
|
||||
for (const initial of ['absent','sparse','old']) {
|
||||
const {app,node,widget}=fixture();
|
||||
if(initial==='absent') delete node.widgets_values;
|
||||
if(initial==='sparse') node.widgets_values=[];
|
||||
if(initial==='old') node.widgets_values[0]='earlier serialized draft';
|
||||
const adapter=createPromptAdapter(app),{snapshot}=adapter.preview('7',{positive:'new',negative:'newbad'});
|
||||
// ComfyUI serialize() materializes the live widget values without editing them.
|
||||
node.widgets_values=node.widgets.map(item=>item.inputEl ? item.inputEl.value : item.value);
|
||||
assert.deepEqual(adapter.apply(snapshot).values,{positive:'new',negative:'newbad'});
|
||||
assert.equal(widget('text').value,'new');
|
||||
}
|
||||
});
|
||||
test('serialization normalization still rejects a different execution value',async()=>{
|
||||
const {createPromptAdapter}=await source; const {app,node,widget}=fixture(); delete node.widgets_values;
|
||||
const adapter=createPromptAdapter(app),{snapshot}=adapter.preview('7',{positive:'new'});
|
||||
node.widgets_values=node.widgets.map(item=>item.value); node.widgets_values[0]='unapproved';
|
||||
assert.throws(()=>adapter.apply(snapshot),/changed/i); assert.equal(widget('text').value,'old');
|
||||
});
|
||||
test('callback failure preserves serialization normalized between preview and apply',async()=>{
|
||||
const {createPromptAdapter}=await source; const {app,node,widget}=fixture(); node.widgets_values[0]='earlier draft';
|
||||
const adapter=createPromptAdapter(app),{snapshot}=adapter.preview('7',{positive:'new'});
|
||||
node.widgets_values=node.widgets.map(item=>item.value); widget('text').callback=()=>{throw new Error('callback failed');};
|
||||
assert.throws(()=>adapter.apply(snapshot),/failed/); assert.equal(node.widgets_values[0],'old'); assert.equal(widget('text').value,'old');
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
import * as bridge from '../../web/js/shared/toolkit/prompt-bridge.js';
|
||||
import { registerPromptBridgeContracts } from '../../tools/vendor/node_modules/@comfyassets/toolkit/testing/prompt-bridge-contracts.mjs';
|
||||
|
||||
registerPromptBridgeContracts(bridge, () => import('../../web/js/shared/toolkit/prompt-bridge.js?independent-consumer=0.1'));
|
||||
@@ -0,0 +1,68 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { installPromptBridge } from '../../web/js/prompt-bridge-host.js';
|
||||
|
||||
function fixture() {
|
||||
const runtime = new EventTarget();
|
||||
runtime.location = { origin: 'https://comfy.example', href: 'https://comfy.example/team/' };
|
||||
runtime.document = { title: 'My workflow' };
|
||||
const warnings = []; runtime.console = { warn: (...args) => warnings.push(args) };
|
||||
const hosts = [], adapter = {};
|
||||
const options = { runtime, baseUrl: '/team/prompt_manager/', adapterFactory: () => adapter,
|
||||
hostFactory: config => { const host = { config, disposed: 0, dispose() { this.disposed++; } }; hosts.push(host); return host; } };
|
||||
return { runtime, options, hosts, adapter, warnings };
|
||||
}
|
||||
|
||||
test('host uses the exact deployment prefix and explicit pack label', () => {
|
||||
const f = fixture(), app = {};
|
||||
const controller = installPromptBridge(app, f.options);
|
||||
assert.equal(f.hosts.length, 1);
|
||||
assert.equal(f.hosts[0].config.namespace, '/team/');
|
||||
assert.equal(f.hosts[0].config.origin, 'https://comfy.example');
|
||||
assert.equal(f.hosts[0].config.label, 'PromptManager — My workflow');
|
||||
assert.equal(f.hosts[0].config.adapter, f.adapter);
|
||||
assert.equal(installPromptBridge(app, f.options), controller);
|
||||
controller.dispose(); controller.dispose();
|
||||
assert.equal(f.hosts[0].disposed, 1);
|
||||
});
|
||||
|
||||
test('page hide disposes and cached restoration mounts exactly one fresh host', () => {
|
||||
const f = fixture(), controller = installPromptBridge({}, f.options);
|
||||
f.runtime.dispatchEvent(new Event('pagehide'));
|
||||
assert.equal(f.hosts[0].disposed, 1);
|
||||
f.runtime.dispatchEvent(new Event('pageshow'));
|
||||
f.runtime.dispatchEvent(new Event('pageshow'));
|
||||
assert.equal(f.hosts.length, 2);
|
||||
controller.dispose();
|
||||
f.runtime.dispatchEvent(new Event('pageshow'));
|
||||
assert.equal(f.hosts.length, 2);
|
||||
assert.equal(f.hosts[1].disposed, 1);
|
||||
});
|
||||
|
||||
test('a disposed installation can be installed again', () => {
|
||||
const f = fixture(), app = {};
|
||||
installPromptBridge(app, f.options).dispose();
|
||||
const next = installPromptBridge(app, f.options);
|
||||
assert.equal(f.hosts.length, 2);
|
||||
next.dispose();
|
||||
});
|
||||
|
||||
test('unavailable messaging leaves the existing host usable and can recover after restoration', () => {
|
||||
const f = fixture(); let unavailable = true;
|
||||
const factory = f.options.hostFactory;
|
||||
f.options.hostFactory = config => { if (unavailable) throw new Error('BroadcastChannel unavailable'); return factory(config); };
|
||||
const controller = installPromptBridge({}, f.options);
|
||||
assert.equal(f.warnings.length, 1);
|
||||
unavailable = false;
|
||||
f.runtime.dispatchEvent(new Event('pageshow'));
|
||||
assert.equal(f.hosts.length, 1);
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
test('deployment namespace rejects external origins and unexpected paths', () => {
|
||||
for (const baseUrl of ['https://comfy.example.attacker.test/prompt_manager/', '/wrong/', 'data:text/plain,hello']) {
|
||||
const f = fixture();
|
||||
assert.throws(() => installPromptBridge({}, { ...f.options, baseUrl }), /deployment|origin|path/i);
|
||||
assert.equal(f.hosts.length, 0);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { inspectPath, promptRoles, createInspectionLoader, mountPromptBlocks } from '../../web/js/workspace/prompt-inspector.mjs';
|
||||
|
||||
test('inspection keeps exact physical root and encodes every path component', () => {
|
||||
assert.equal(inspectPath({root_index:2,path:'a b/x#%.png'}), '/prompt_manager/images/inspect/a%20b/x%23%25.png?root=2');
|
||||
});
|
||||
test('only confirmed strings can be sent, including explicit empty negatives', () => {
|
||||
const record = {positive:{status:'resolved',text:'full\npositive'},negative:{status:'resolved',text:''}};
|
||||
assert.deepEqual(promptRoles(record), {positive:'full\npositive',negative:''});
|
||||
for (const status of ['absent','ambiguous','unresolved','other']) {
|
||||
assert.deepEqual(promptRoles({positive:{status,text:'placeholder'},negative:{status:'resolved',text:null}}), {});
|
||||
}
|
||||
assert.deepEqual(promptRoles(null), {});
|
||||
});
|
||||
test('already aborted prompt blocks touch no container DOM or active handlers', () => {
|
||||
const parent = new AbortController(); parent.abort();
|
||||
const container = new Proxy({}, { get() { assert.fail('aborted mount accessed the container'); } });
|
||||
const parentScope = { signal: parent.signal };
|
||||
const view = mountPromptBlocks(container, { parentScope, sender: { open() { assert.fail('aborted action'); } } });
|
||||
view.update({ positive: { status: 'resolved', text: 'late' } });
|
||||
view.setState({ loading: true }); view.dispose();
|
||||
});
|
||||
test('superseded requests and disposed loaders cannot replace current metadata', async () => {
|
||||
const pending = [], changes = [];
|
||||
const loader = createInspectionLoader({get:(url,{signal})=>new Promise(resolve=>pending.push({url,signal,resolve}))}, value=>changes.push(value));
|
||||
const first=loader.load({root_index:0,path:'one.png'});
|
||||
const second=loader.load({root_index:1,path:'one.png'});
|
||||
assert.equal(pending[0].signal.aborted,true);
|
||||
pending[1].resolve({prompts:{positive:{status:'resolved',text:'latest'}}}); await second;
|
||||
pending[0].resolve({prompts:{positive:{status:'resolved',text:'old'}}}); await first;
|
||||
assert.equal(changes.at(-1).prompts.positive.text,'latest');
|
||||
const third=loader.load({root_index:0,path:'third.png'}); loader.dispose();
|
||||
assert.equal(pending[2].signal.aborted,true);
|
||||
pending[2].resolve({prompts:{positive:{status:'resolved',text:'disposed'}}}); await third;
|
||||
assert.equal(changes.at(-1).loading,true);
|
||||
});
|
||||
test('failed reads report a retryable error and later reads recover', async () => {
|
||||
let fail=true; const changes=[];
|
||||
const loader=createInspectionLoader({get:async()=>{if(fail)throw Error('bad');return {prompts:{}};}},value=>changes.push(value));
|
||||
await loader.load({root_index:0,path:'one.png'}); assert.equal(changes.at(-1).error,true);
|
||||
fail=false; await loader.load({root_index:0,path:'one.png'}); assert.deepEqual(changes.at(-1).prompts,{});
|
||||
loader.dispose(); await loader.load({root_index:0,path:'one.png'}); assert.equal(changes.at(-1).loading,undefined);
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const source = import('../../web/js/workspace/prompt-sender.mjs');
|
||||
const hosts = [{hostId:'host',label:'Workflow',targets:[{id:'one',label:'Positive',roles:['positive']},{id:'pair',label:'Pair',roles:['positive','negative']}]}];
|
||||
const tick = () => new Promise(resolve => setImmediate(resolve));
|
||||
function setup(createSendSession, overrides = {}, roles = {positive:'new'}) {
|
||||
const calls = [];
|
||||
const client = {discover:async () => hosts, preview:async (...args) => {calls.push(['preview',...args]);return {ticket:'ticket',target:{id:args[1]},fields:[{role:'positive',before:'old',after:'new'}],composition:{before:'prefix old',after:'prefix new'}};},apply:async (...args) => {calls.push(['apply',...args]);return {targetId:'one',values:{positive:'new'}};},dispose:() => calls.push(['dispose']),...overrides};
|
||||
const states = []; const session = createSendSession({client,roles,onChange:state=>states.push(state)});
|
||||
return {client,calls,states,session};
|
||||
}
|
||||
test('sender requires explicit compatible host and target, preview, then acknowledged apply', async () => {
|
||||
const {createSendSession} = await source; const s=setup(createSendSession);
|
||||
await s.session.discover(); assert.equal(s.session.state.hostId,'');assert.equal(s.session.state.targetId,'');
|
||||
await s.session.preview(); assert.equal(s.calls.length,0);
|
||||
s.session.selectHost('host');assert.equal(s.session.state.targetId,''); s.session.selectTarget('one');
|
||||
await s.session.apply();assert.equal(s.calls.length,0);
|
||||
await s.session.preview();assert.equal(s.session.state.phase,'ready');assert.equal(s.session.state.preview.composition.after,'prefix new');
|
||||
await s.session.apply();assert.equal(s.session.state.phase,'success');assert.equal(s.calls.filter(call=>call[0]==='apply').length,1);
|
||||
await s.session.apply();assert.equal(s.calls.filter(call=>call[0]==='apply').length,1); s.session.dispose();
|
||||
});
|
||||
test('pair roles filter incompatible nodes and do not silently choose the sole destination', async()=>{
|
||||
const {createSendSession}=await source;const s=setup(createSendSession,{}, {positive:'',negative:'bad'});
|
||||
await s.session.discover();assert.deepEqual(s.session.state.hosts[0].targets.map(target=>target.id),['pair']);
|
||||
s.session.selectHost('host');s.session.selectTarget('one');assert.equal(s.session.state.targetId,'');
|
||||
s.session.selectTarget('pair');assert.equal(s.session.state.targetId,'pair');s.session.dispose();
|
||||
});
|
||||
test('late previews are ignored after target changes; aborted and disposed work cannot update state',async()=>{
|
||||
const {createSendSession}=await source;let resolve;const s=setup(createSendSession,{preview:()=>new Promise(done=>resolve=done)});
|
||||
await s.session.discover();s.session.selectHost('host');s.session.selectTarget('one');const pending=s.session.preview();
|
||||
s.session.selectTarget('pair');resolve({ticket:'old'});await pending;assert.equal(s.session.state.preview,null);
|
||||
s.session.selectTarget('one');const pending2=s.session.preview();s.session.dispose();const count=s.states.length;resolve({ticket:'old'});await pending2;assert.equal(s.states.length,count);
|
||||
});
|
||||
test('unknown apply outcome never retries a ticket and requires a new preview',async()=>{
|
||||
const {createSendSession}=await source;const s=setup(createSendSession,{apply:async()=>{throw Error('timed out');}});
|
||||
await s.session.discover();s.session.selectHost('host');s.session.selectTarget('one');await s.session.preview();await s.session.apply();
|
||||
assert.equal(s.session.state.phase,'error');assert.match(s.session.state.message,/may have applied/i);assert.equal(s.session.state.preview,null);
|
||||
await s.session.apply();assert.equal(s.session.state.phase,'error');await s.session.preview();assert.equal(s.session.state.phase,'ready');s.session.dispose();
|
||||
});
|
||||
test('only matching explicit acknowledgements count as success',async()=>{
|
||||
const {createSendSession}=await source;
|
||||
for(const ack of [undefined,{targetId:'other',values:{positive:'new'}},{targetId:'one',values:{positive:'wrong'}}]){
|
||||
const s=setup(createSendSession,{apply:async()=>ack});await s.session.discover();s.session.selectHost('host');s.session.selectTarget('one');await s.session.preview();await s.session.apply();assert.equal(s.session.state.phase,'error');s.session.dispose();
|
||||
}
|
||||
});
|
||||
test('discovery, empty results and stale preview errors remain actionable',async()=>{
|
||||
const {createSendSession}=await source;const s=setup(createSendSession,{discover:async()=>[]});await s.session.discover();assert.match(s.session.state.message,/No compatible/i);s.session.dispose();
|
||||
const failed=setup(createSendSession,{discover:async()=>{throw Error('unavailable');}});await failed.session.discover();assert.match(failed.session.state.message,/unavailable/);failed.session.dispose();
|
||||
const stale=setup(createSendSession,{preview:async()=>{throw Error('Workflow changed. Preview again.');}});await stale.session.discover();stale.session.selectHost('host');stale.session.selectTarget('one');await stale.session.preview();assert.match(stale.session.state.message,/Workflow changed/);stale.session.dispose();
|
||||
});
|
||||
test('rediscovery invalidates selections and old discovery cannot replace new results',async()=>{
|
||||
const {createSendSession}=await source;let resolve;let count=0;const s=setup(createSendSession,{discover:()=>++count===1?new Promise(done=>resolve=done):Promise.resolve(hosts)});
|
||||
const first=s.session.discover();await s.session.discover();resolve([]);await first;assert.equal(s.session.state.hosts.length,1);
|
||||
s.session.selectHost('host');s.session.selectTarget('one');await s.session.preview();await s.session.discover();assert.equal(s.session.state.hostId,'');assert.equal(s.session.state.preview,null);s.session.dispose();
|
||||
});
|
||||
test('pending apply explains closing cannot undo the sent update',async()=>{
|
||||
const {createSendSession}=await source;let done;const s=setup(createSendSession,{apply:()=>new Promise(resolve=>done=resolve)});
|
||||
await s.session.discover();s.session.selectHost('host');s.session.selectTarget('one');await s.session.preview();const pending=s.session.apply();
|
||||
assert.match(s.session.state.message,/Closing this dialog does not undo/i);s.session.dispose();done({targetId:'one',values:{positive:'new'}});await pending;
|
||||
});
|
||||
function fakeDOM() {
|
||||
class Node extends EventTarget {
|
||||
constructor(tag){super();this.tagName=tag;this.children=[];this.attributes={};this.dataset={};this.value='';this.textContent='';this.isConnected=true;this.open=false;}
|
||||
append(...children){this.children.push(...children);}
|
||||
add(option){this.append(option);}
|
||||
replaceChildren(...children){this.children=children;}
|
||||
setAttribute(key,value){this.attributes[key]=value;}
|
||||
showModal(){this.open=true;}
|
||||
close(){this.open=false;this.dispatchEvent(new Event('close'));}
|
||||
remove(){this.isConnected=false;}
|
||||
focus(){this.focused=true;}
|
||||
}
|
||||
const previous={document:global.document,window:global.window,Option:global.Option};
|
||||
const body=new Node('body');
|
||||
global.document={body,createElement:tag=>new Node(tag),createElementNS:(namespace,tag)=>new Node(tag)};
|
||||
global.Option=class extends Node {constructor(text,value){super('option');this.textContent=text;this.value=value;}};
|
||||
global.window={location:new URL('https://example.test/prefix/prompt_manager/workspace'),ApiClient:{resolveUrl:()=>'/prefix/prompt_manager/'}};
|
||||
const find=(root,predicate)=>predicate(root)?root:root.children.map(child=>find(child,predicate)).find(Boolean);
|
||||
return {body,Node,find,restore(){Object.assign(global,previous);}};
|
||||
}
|
||||
test('native sender dialog uses prefixed namespace, full safe text, explicit choices and owned cleanup',async()=>{
|
||||
const {createPromptSender,createSendButton}=await source;
|
||||
const {createScope}=await import('../../web/js/shared/toolkit/core.js');
|
||||
const dom=fakeDOM();const scope=createScope();const trigger=new dom.Node('button');let options,disposed=0;
|
||||
const client={discover:async()=>hosts,preview:async()=>({ticket:'t',target:{id:'one'},fields:[{role:'positive',before:'',after:'<unsafe>new</unsafe>'}],composition:{prepend:'prefix',append:'suffix',before:null,after:null,prependConnected:true}}),apply:async()=>({targetId:'one',values:{positive:'<unsafe>new</unsafe>'}}),dispose:()=>disposed++};
|
||||
try {
|
||||
const button=createSendButton('Send positive prompt');assert.equal(button.attributes['aria-label'],'Send positive prompt');assert.equal(button.children[0].attributes['aria-hidden'],'true');
|
||||
const sender=createPromptSender({scope,clientFactory:value=>{options=value;return client;}});sender.open({positive:'<unsafe>new</unsafe>'},trigger);await tick();
|
||||
assert.deepEqual(options,{namespace:'/prefix/',origin:'https://example.test'});
|
||||
const dialog=dom.body.children.at(-1);assert.equal(dialog.open,true);assert.equal(dialog.attributes['aria-label'],'Send to ComfyUI');
|
||||
const host=dom.find(dialog,node=>node.id==='prompt-sender-host');const target=dom.find(dialog,node=>node.id==='prompt-sender-target');
|
||||
assert.equal(host.value,'');assert.equal(target.value,'');host.value='host';host.dispatchEvent(new Event('change'));target.value='one';target.dispatchEvent(new Event('change'));
|
||||
dom.find(dialog,node=>node.textContent==='Preview changes').dispatchEvent(new Event('click'));await tick();
|
||||
assert.ok(dom.find(dialog,node=>node.tagName==='code' && node.textContent==='(empty)'));assert.ok(dom.find(dialog,node=>node.tagName==='code' && node.textContent==='<unsafe>new</unsafe>'));
|
||||
const apply=dom.find(dialog,node=>node.textContent==='Apply to ComfyUI');assert.equal(apply.className,'ca-primary');assert.equal(apply.disabled,false);apply.dispatchEvent(new Event('click'));await tick();
|
||||
assert.ok(dom.find(dialog,node=>node.textContent==='Sent to ComfyUI.'));scope.dispose();assert.equal(dialog.isConnected,false);assert.equal(trigger.focused,true);assert.equal(disposed,1);
|
||||
sender.open({positive:'new'},trigger);assert.equal(dom.body.children.length,1);
|
||||
} finally {scope.dispose();dom.restore();}
|
||||
});
|
||||
test('unsupported client or deployment errors are visible and close stays available',async()=>{
|
||||
const {createPromptSender}=await source;const {createScope}=await import('../../web/js/shared/toolkit/core.js');const dom=fakeDOM();
|
||||
try {
|
||||
for(const baseUrl of ['https://other.test/prompt_manager/','/prefix/not-prompt-manager/','/prefix/prompt_manager/']) {
|
||||
const scope=createScope();const sender=createPromptSender({scope,baseUrl,clientFactory:()=>{throw Error('BroadcastChannel unavailable');}});
|
||||
sender.open({positive:'new'});const dialog=dom.body.children.at(-1);const alert=dom.find(dialog,node=>node.attributes.role==='alert');assert.match(alert.textContent,/unavailable|Invalid/);
|
||||
dom.find(dialog,node=>node.textContent==='Close').dispatchEvent(new Event('click'));assert.equal(dialog.isConnected,false);scope.dispose();
|
||||
}
|
||||
} finally {dom.restore();}
|
||||
});
|
||||
test('approval requires an exact destination and complete unambiguous matching preview',async()=>{
|
||||
const {createSendSession}=await source;
|
||||
const valid={ticket:'ticket',target:{id:'one'},fields:[{role:'positive',before:'old',after:'new'}]};
|
||||
for(const preview of [null,{}, {...valid,ticket:''},{...valid,target:{id:'other'}},{...valid,fields:[]},{...valid,fields:new Array(1)},{...valid,fields:[null]},{...valid,fields:[...valid.fields,...valid.fields]},{...valid,fields:[{role:'negative',before:'old',after:'new'}]},{...valid,fields:[{role:'positive',before:null,after:'new'}]},{...valid,fields:[{role:'positive',before:'old',after:'unexpected'}]}]) {
|
||||
const s=setup(createSendSession,{preview:async()=>preview});await s.session.discover();s.session.selectHost('host');s.session.selectTarget('one');await s.session.preview();
|
||||
assert.equal(s.session.state.phase,'error');assert.equal(s.session.state.preview,null);await s.session.apply();assert.equal(s.calls.filter(call=>call[0]==='apply').length,0);s.session.dispose();
|
||||
}
|
||||
});
|
||||
test('acknowledgement cannot claim an extra role beyond approved changes',async()=>{
|
||||
const {createSendSession}=await source;const s=setup(createSendSession,{apply:async()=>({targetId:'one',values:{positive:'new',negative:'also changed'}})});
|
||||
await s.session.discover();s.session.selectHost('host');s.session.selectTarget('one');await s.session.preview();await s.session.apply();assert.equal(s.session.state.phase,'error');assert.match(s.session.state.message,/unexpected acknowledgement/);s.session.dispose();
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const modulePath='../../web/js/workspace/file-inspector.mjs';
|
||||
const load=()=>import(modulePath);
|
||||
const rawWorkflow='{ "nodes": [], "seed": 9007199254740993, "cfg": 1.0000000000000000001 }';
|
||||
function record() {
|
||||
return {identity:{kind:'rooted-file',key:'2:a.png',root_index:2,path:'a.png',root_scope:'a'.repeat(64)},file:{format:'PNG'},prompts:{positive:{status:'resolved',text:'a\n猫'}},generation:{seed:{status:'known',value:'9007199254740993',source:'workflow',evidence:'configuration'}},library:{metadata_scope:'pair',tags:['saved']},download:{filename:'a.png',url:'/prompt_manager/images/serve/a.png?root=2'},metadata:{status:'available',omitted_reason:null,raw:{workflow:rawWorkflow,prompt:'{broken',parameters:'literal <svg> </pre>'},record_formats:{workflow:'original-text',prompt:'original-text',parameters:'original-text'},workflow_graph:{status:'parsed',value:{seed:'9007199254740993'}},prompt_graph:{status:'malformed',value:null},parameters:'literal <svg> </pre>'}};
|
||||
}
|
||||
|
||||
test('metadata export retains literal raw and scoped evidence',async()=>{
|
||||
const {inspectionDownloads}=await load(),source=record(),download=inspectionDownloads(source).metadata;
|
||||
assert.equal(download.available,true);assert.equal(download.mime,'application/json');assert.equal(download.filename,'a.metadata.json');
|
||||
const value=JSON.parse(download.bodyText);assert.equal(value.inspection_version,1);
|
||||
for(const key of ['identity','file','prompts','generation','library'])assert.deepEqual(value[key],source[key]);
|
||||
assert.deepEqual(value.metadata.raw,source.metadata.raw);assert.deepEqual(value.metadata.record_formats,source.metadata.record_formats);
|
||||
assert.equal(value.metadata.prompt_graph,undefined);assert.equal(value.metadata.workflow_graph,undefined);
|
||||
});
|
||||
test('workflow export never reserializes unsafe numeric literals',async()=>{
|
||||
const {inspectionDownloads}=await load();assert.equal(inspectionDownloads(record()).workflow.bodyText,rawWorkflow);
|
||||
const source=record();source.metadata.workflow_graph.status='malformed';assert.equal(inspectionDownloads(source).workflow.available,false);
|
||||
});
|
||||
test('omitted raw forbids partial export',async()=>{
|
||||
const {inspectionDownloads}=await load(),source=record();source.metadata.status='limited';source.metadata.omitted_reason='response-size-limit';source.metadata.raw={};
|
||||
for(const value of Object.values(inspectionDownloads(source))) {assert.equal(value.available,false);assert.equal(value.bodyText,null);assert.equal(value.reason,'Raw metadata exceeds the response limit. Download the original file to inspect it externally.');}
|
||||
assert.ok(source.download.url);
|
||||
});
|
||||
test('download names cannot introduce path segments',async()=>{
|
||||
const {inspectionDownloads}=await load(),source=record();source.download.filename='../../evil\\nested\u0000.png';
|
||||
for(const value of Object.values(inspectionDownloads(source)))assert.doesNotMatch(value.filename,/[\\/\x00-\x1f]/);
|
||||
});
|
||||
test('already disposed inspector creates no DOM URLs or handlers',async()=>{
|
||||
const {mountFileInspector,mountPromptInspector}=await load(),parent=new AbortController();parent.abort();
|
||||
const forbidden=new Proxy({}, {get(){assert.fail('disposed mount touched DOM/API');}});
|
||||
for(const mount of [mountFileInspector,mountPromptInspector]) {
|
||||
const view=mount(forbidden,{parentScope:{signal:parent.signal},sender:forbidden,api:forbidden,image:forbidden});
|
||||
view.setState?.({record:record()});view.dispose();view.dispose();
|
||||
}
|
||||
});
|
||||
test('reserialized container workflow never exports as original',async()=>{
|
||||
const {inspectionDownloads}=await load(),source=record();source.metadata.record_formats.workflow='reserialized-json';
|
||||
const workflow=inspectionDownloads(source).workflow;assert.equal(workflow.available,false);assert.equal(workflow.bodyText,null);
|
||||
assert.equal(workflow.reason,'Original workflow JSON text is unavailable for this container record. Download metadata or the original file.');
|
||||
});
|
||||
test('metadata export preserves representation formats and omits private carrier',async()=>{
|
||||
const {inspectionDownloads}=await load(),source=record();source.metadata.record_formats.workflow='reserialized-json';source.metadata.raw.private_carrier='SECRET';source.metadata.carrier='SECRET';
|
||||
const download=inspectionDownloads(source).metadata,value=JSON.parse(download.bodyText);
|
||||
assert.equal(value.metadata?.record_formats?.workflow,'reserialized-json');assert.equal(value.metadata.raw.workflow,rawWorkflow);assert.ok(!download.bodyText.includes('SECRET'));
|
||||
assert.equal(value.metadata.representations.workflow,'Reserialized container JSON; numeric spelling/precision may differ');
|
||||
});
|
||||
|
||||
test('reported location metadata export carries exact occurrence without assigning historical ownership',async()=>{
|
||||
const {inspectionDownloads}=await load(),source=record();
|
||||
source.identity={kind:'reported-location',key:'b'.repeat(64),path:'folder/a.png',occurrence:{run_id:'22222222-2222-2222-2222-222222222222',output_node_id:'𐀀',image_ordinal:255},history_epoch:'11111111-1111-1111-1111-111111111111',current_scope:'c'.repeat(64),reference:{folder_type:'temp',subfolder:'folder',filename:'a.png',path_style:'posix',root_key:null}};
|
||||
const value=JSON.parse(inspectionDownloads(source).metadata.bodyText);assert.deepEqual(value.identity,source.identity);assert.equal(value.identity.root_index,undefined);assert.equal(value.context_id,undefined);assert.equal(value.file_identity,undefined);assert.deepEqual(value.prompts,source.prompts);
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const source = import('../../web/js/workspace/history-state.mjs');
|
||||
|
||||
test('chronological columns keep the visible runs, three-column overscan and distant focus in ascending order', async () => {
|
||||
const {visibleHistoryColumns} = await source;
|
||||
assert.equal(typeof visibleHistoryColumns, 'function', 'history must expose a bounded column window');
|
||||
assert.deepEqual(visibleHistoryColumns(1001, 640, 128, 999), [7,8,9,10,11,12,13,14,999]);
|
||||
assert.deepEqual(visibleHistoryColumns(1001, 0, 64), [0,1,2,3]);
|
||||
assert.deepEqual(visibleHistoryColumns(1001, 64000, 390, 0), [0,997,998,999,1000]);
|
||||
assert.deepEqual(visibleHistoryColumns(3, 100, 1), [0,1,2]);
|
||||
assert.deepEqual(visibleHistoryColumns(0, 0, 500), []);
|
||||
});
|
||||
|
||||
test('column geometry handles fractional edges, invalid scroll and focus without scanning all loaded runs', async () => {
|
||||
const {visibleHistoryColumns} = await source;
|
||||
assert.equal(typeof visibleHistoryColumns, 'function');
|
||||
assert.deepEqual(visibleHistoryColumns(50000, 640.5, 64, 0), [0,7,8,9,10,11,12,13,14]);
|
||||
assert.deepEqual(visibleHistoryColumns(121, NaN, Infinity), [0,1,2,3]);
|
||||
assert.equal(visibleHistoryColumns(120, 900, 1).length, 120);
|
||||
assert.ok(visibleHistoryColumns(10000000, 40000, 390).length <= 14);
|
||||
for(const count of [-1, 1.1, NaN, '4']) assert.throws(()=>visibleHistoryColumns(count,0,100), TypeError);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
import {createInspectionController,originalMediaDownload} from '../../web/js/workspace/inspection-state.mjs';
|
||||
import {createViewerSession} from '../../web/js/workspace/viewer-state.mjs';
|
||||
const require=createRequire(import.meta.url),{extractGenerationFacts}=require('../../web/js/comfy-metadata.js');
|
||||
const m=await import('../../web/js/workspace/history-media.mjs').catch(e=>{if(e.code!=='ERR_MODULE_NOT_FOUND') throw e;return {};});
|
||||
const epoch='11111111-1111-1111-1111-111111111111',run='22222222-2222-2222-2222-222222222222',scope='a'.repeat(64),location='b'.repeat(64);
|
||||
function media(node='1',ordinal=0,key=location) {const occurrence={run_id:run,output_node_id:node,image_ordinal:ordinal},reference={folder_type:'output',subfolder:'folder',filename:'a #%.png',path_style:'posix',root_key:'c'.repeat(64)},base=`/prompt_manager/history/runs/${run}/output/current`,query=new URLSearchParams({node,ordinal:String(ordinal),epoch,current_scope:scope});return {kind:'reported-location',occurrence,reference,history_epoch:epoch,current_scope:scope,location_key:key,filename:reference.filename,is_video:false,url:`${base}/media?${query}`,inspection_url:`${base}/inspection?${query}`,thumbnail_url:`${base}/thumbnail?${query}`};}
|
||||
const receipt=value=>({occurrence:value.occurrence,reference:value.reference,file_identity:'unverified',newly_generated:'unknown',context_membership:'unknown',transient:false,current_candidate:{status:'available',reason:null,evidence_class:'locator_candidate',current_scope:value.current_scope,location_key:value.location_key,relative_path:'folder/a #%.png',source_label:'output',gallery_match:'none',gallery_image_id:null,media:value}});
|
||||
function envelope(target=media()) {return {success:true,inspection_version:1,history_version:1,history_epoch:epoch,current_output:{occurrence:target.occurrence,reference:target.reference,history_epoch:epoch,current_scope:scope,location_key:target.location_key,evidence_class:'locator_candidate',observation:'current-file',file_identity:'unverified',newly_generated:'unknown',context_membership:'unknown',transient:false},prompts:{positive:{status:'resolved',text:'current metadata only',source:'execution'},negative:{status:'unresolved',text:null,source:null}},file:{observation:'current-file',source_label:'output',media_type:'image',extension:'.png',format:'PNG',size:10,width:2,height:3,modified_time:1},metadata:{status:'absent',omitted_reason:null,raw:{},record_formats:{},prompt_graph:{status:'absent',value:null},workflow_graph:{status:'absent',value:null},parameters:null},resolution:{state:'absent',sampler_id:null,reason:'no graph'},library:{status:'none',scope:'saved-library',tags:[],metadata_scope:null,positive_id:null,pair_id:null,title:null,category:null}};}
|
||||
function adapter(){assert.ok(m.reportedLocationAdapter,'reported location adapter is exported');return m.reportedLocationAdapter;}
|
||||
|
||||
test('reported location uses explicit occurrence routes and never widens rooted defaults',()=>{const target=media(),a=adapter();assert.doesNotThrow(()=>a.validate(target));assert.equal(a.key(target),location);assert.equal(a.scope(target),`${epoch}:${scope}`);assert.equal(a.inspectionPath(target),target.inspection_url);assert.deepEqual(a.originalDownload(target),{url:target.url,filename:'a #%.png'});assert.throws(()=>originalMediaDownload(target),TypeError);assert.throws(()=>createViewerSession({items:[target],initialKey:location}),TypeError);});
|
||||
|
||||
test('occurrence URLs reject endpoint query origin credentials fragments and identity ambiguities',()=>{const a=adapter(),target=media();for(const patch of [{url:target.url.replace('/media?','/thumbnail?')},{url:target.url+'&node=1'},{url:target.url+'&ordinal=0'},{url:target.url+'&epoch='+epoch},{url:target.url+'¤t_scope='+scope},{url:target.url+'&root=0'},{url:target.url+'#x'},{url:'https://attacker.test'+target.url},{url:'http://user:pass@localhost'+target.url},{url:target.url.replace('ordinal=0','ordinal=00')},{url:target.url.replace('node=1','node=2')},{inspection_url:target.url},{thumbnail_url:target.url},{history_epoch:'bad'},{current_scope:'A'.repeat(64)},{location_key:''},{occurrence:{...target.occurrence,image_ordinal:256}},{reference:{...target.reference,subfolder:'../bad'}}]) assert.throws(()=>a.validate({...target,...patch}),TypeError);const cached={...target,url:target.url+'&_pm_viewer=1'};assert.equal(a.originalDownload(cached).url,cached.url);});
|
||||
|
||||
test('proxy URLs retain exact configured prefix and reject unprefixed alternatives',()=>{const a=adapter(),previous=globalThis.window;globalThis.window={location:{href:'https://host.test/proxy/prompt_manager/workspace'},ApiClient:{resolveUrl:value=>value.startsWith('/prompt_manager/')?'/proxy'+value:value}};try {const target=media();assert.equal(a.originalDownload(target).url,'/proxy'+target.url);assert.throws(()=>a.originalDownload({...target,url:'https://host.test'+target.url}),TypeError);assert.throws(()=>a.originalDownload({...target,url:'https://host.test.attacker.test/proxy'+target.url}),TypeError);}finally{if(previous===undefined)delete globalThis.window;else globalThis.window=previous;}});
|
||||
|
||||
test('four repeated receipt coordinates form one loaded Viewer location retaining selected representative',()=>{assert.equal(typeof m.distinctCurrentMedia,'function');const values=[media('1',0),media('1',1),media('2',0),media('2',1)],receipts=values.map(receipt);assert.equal(new Set(receipts.map(r=>m.occurrenceKey(r.occurrence))).size,4);for(const chosen of [values[0],values[3]]) {const items=m.distinctCurrentMedia(receipts,chosen.occurrence);assert.equal(items.length,1);assert.deepEqual(items[0].occurrence,chosen.occurrence);}assert.deepEqual(m.distinctCurrentMedia(receipts,null)[0].occurrence,values[0].occurrence);const unavailable={...receipts[0],current_candidate:{status:'unavailable',reason:'missing',evidence_class:'locator_candidate'}};assert.deepEqual(m.distinctCurrentMedia([unavailable],null),[]);});
|
||||
|
||||
test('shared occurrence inspection keeps current facts and exports exact unverified evidence',async()=>{const a=adapter(),target=media('2',1),data=envelope(target);const record=a.toRecord(data,target,extractGenerationFacts);assert.deepEqual(record.identity,{kind:'reported-location',key:location,path:'folder/a #%.png',occurrence:target.occurrence,history_epoch:epoch,current_scope:scope,reference:target.reference});assert.equal(record.prompts.positive.text,'current metadata only');assert.equal(record.generation.seed.status,'not-recorded');assert.equal(record.identity.root_index,undefined);assert.deepEqual(record.download,{url:target.url,filename:target.filename});const {inspectionDownloads}=await import('../../web/js/workspace/file-inspector.mjs');const exported=JSON.parse(inspectionDownloads(record).metadata.bodyText);assert.deepEqual(exported.identity.occurrence,{run_id:run,output_node_id:'2',image_ordinal:1});assert.equal(exported.identity.history_epoch,epoch);assert.equal(exported.prompts.positive.text,'current metadata only');});
|
||||
|
||||
test('identity scope epoch or occurrence echo mismatches require refresh but malformed metadata is retryable',()=>{const a=adapter();for(const mutate of [p=>p.history_epoch='33333333-3333-3333-3333-333333333333',p=>p.current_output.current_scope='d'.repeat(64),p=>p.current_output.occurrence={...p.current_output.occurrence,image_ordinal:1},p=>p.current_output.reference={...p.current_output.reference,filename:'other.png'},p=>p.current_output.location_key='d'.repeat(64)]){const data=envelope();mutate(data);assert.throws(()=>a.toRecord(data,media(),extractGenerationFacts),e=>e.refreshRequired===true && typeof e.code==='string');}for(const mutate of [p=>p.success=false,p=>p.inspection_version=2,p=>p.metadata.parameters='fake',p=>p.current_output.file_identity='verified',p=>p.current_output.observation='historical-file']){const data=envelope();mutate(data);assert.throws(()=>a.toRecord(data,media(),extractGenerationFacts),e=>e instanceof TypeError && !e.refreshRequired);}});
|
||||
|
||||
test('shared inspection controller distinguishes recovery codes and obsolete receipt errors',async()=>{const a=adapter();for(const [status,code,refresh] of [[409,'history-changed',true],[409,'candidate-stale',true],[409,'capture-changed',true],[400,'invalid-request',false],[400,'invalid-cursor',false]]){const calls=[];const c=createInspectionController({adapter:a,extractFacts:extractGenerationFacts,api:{get:(url,{signal})=>new Promise((resolve,reject)=>calls.push({url,signal,resolve,reject}))}});const p=c.select(media());assert.equal(calls[0].url,media().inspection_url);calls[0].reject({status,code,recovery:{action:'reload-outputs'}});await p;assert.equal(c.state.refreshRequired,refresh);assert.equal(c.state.code,code);const retry=c.retry();assert.equal(calls.length,refresh?1:2);if(!refresh){calls[1].resolve(envelope());await retry;}c.dispose();}const calls=[],c=createInspectionController({adapter:a,extractFacts:extractGenerationFacts,api:{get:(url,{signal})=>new Promise((resolve,reject)=>calls.push({url,signal,resolve,reject}))}});const first=c.select(media('1',0)),second=c.select(media('1',1));assert.equal(calls.length,2,'same location needs exact occurrence selection');assert.equal(calls[0].signal.aborted,true);calls[1].resolve(envelope(media('1',1)));await second;calls[0].reject({status:409,code:'history-changed'});await first;assert.equal(c.state.record.identity.occurrence.image_ordinal,1);c.dispose();});
|
||||
|
||||
test('Viewer freezes selected occurrence and location entries through navigation and late timers',()=>{const a=adapter(),first=media('1',1),second=media('2',0,'d'.repeat(64)),items=[first,second],seen=[];const view=createViewerSession({adapter:a,items,initialKey:location,onChange:(state,item)=>seen.push([state,item])});first.occurrence.image_ordinal=9;first.reference.filename='mutated';items.reverse();assert.equal(seen[0][1].occurrence.image_ordinal,1);assert.equal(seen[0][1].reference.filename,'a #%.png');view.navigate(1);assert.equal(view.state.key,'d'.repeat(64));view.navigate(-1);assert.equal(seen.at(-1)[1].occurrence.image_ordinal,1);view.dispose();const count=seen.length;view.navigate(1);view.ready(location);view.play();assert.equal(seen.length,count);});
|
||||
|
||||
test('malformed occurrence echoes invalidate actions instead of producing retryable current facts',()=>{const a=adapter();for(const occurrence of [{run_id:run,output_node_id:'1',image_ordinal:'0'},{run_id:'bad',output_node_id:'1',image_ordinal:0},{}]){const data=envelope();data.current_output.occurrence=occurrence;assert.throws(()=>a.toRecord(data,media(),extractGenerationFacts),error=>error.refreshRequired===true && error.code==='candidate-stale');}});
|
||||
|
||||
test('stale inspection echoes use the canonical recovery vocabulary and identity status',()=>{
|
||||
for(const [code,action] of [['history-changed','refresh-history'],['candidate-stale','reload-outputs']]) {
|
||||
const data=envelope();
|
||||
if(code==='history-changed')data.history_epoch='33333333-3333-3333-3333-333333333333';
|
||||
else data.current_output.current_scope='d'.repeat(64);
|
||||
assert.throws(()=>adapter().toRecord(data,media(),extractGenerationFacts),error=>{
|
||||
assert.equal(error.code,code);assert.equal(error.status,409);assert.deepEqual(error.recovery,{action});return true;
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
const {createApiClient} = createRequire(import.meta.url)('../../web/js/api-client.js');
|
||||
const {extractGenerationFacts} = createRequire(import.meta.url)('../../web/js/comfy-metadata.js');
|
||||
import {createInspectionController} from '../../web/js/workspace/inspection-state.mjs';
|
||||
import {reportedLocationAdapter} from '../../web/js/workspace/history-media.mjs';
|
||||
const m = await import('../../web/js/workspace/history-state.mjs').catch(e => {if(e.code !== 'ERR_MODULE_NOT_FOUND') throw e; return {};});
|
||||
const epoch='11111111-1111-1111-1111-111111111111', session='22222222-2222-2222-2222-222222222222';
|
||||
const id=n=>`00000000-0000-0000-0000-${String(n).padStart(12,'0')}`;
|
||||
const digest=n=>String(n).repeat(64), fields=['raw','effective','prepend','append'];
|
||||
const proof=(provenance='recorded')=>({provenance,source:provenance==='recorded'?'runtime_return':'unknown',reason:provenance==='recorded'?null:'missing'});
|
||||
function context(anchor='1',branch='a') {return {anchor_id:anchor,context_id:digest(branch),positive_raw_text_id:digest('e'),positive_snapshot_id:digest('c'),negative_snapshot_id:digest('d'),positive:{raw:'positive\n猫',effective:null,prepend:'',append:''},negative:{raw:'',effective:'negative',prepend:'',append:''},evidence:{format_version:1,role_provenance:'inferred',positive:Object.fromEntries(fields.map(f=>[f,proof(f==='effective'?'unknown':'recorded')])),negative:Object.fromEntries(fields.map(f=>[f,proof()]))},positive_prompt_id:7,negative_prompt_id:8,pair_id:9,reference_epoch:epoch,navigation:{positive_available:true,negative_available:true,pair_available:true}};}
|
||||
const capture={state:'complete',reason:'captured',format_version:1};
|
||||
const output={state:'complete',reason:'reported',format_version:1,source:'host_final_history',retained_occurrence_count:4,list_status:'complete'};
|
||||
function run(n,branch='a') {return {run_id:id(n),session_id:session,storage_epoch:epoch,job_id:id(99),record_version:1,queue_item_id:String(n),observed_dequeue_at:String(9007199254740990n+BigInt(n)),observed_callback_at:null,executor_started_at:null,terminal_event_at:null,outcome:'observed',reason:null,diagnostics_omitted:false,diagnostics_available:false,context_capture:capture,output_summary:output,matching_anchors:[{anchor_id:'1',context_id:digest(branch),positive_snapshot_id:digest('c'),negative_snapshot_id:digest('d')}]};}
|
||||
function windowPage(items=[run(1),run(2),run(3,'b')],more=false) {const summaries=new Map();for(const r of items) {const a=r.matching_anchors[0];if(!summaries.has(a.context_id)) summaries.set(a.context_id,{context_id:a.context_id,positive_snapshot_id:a.positive_snapshot_id,negative_snapshot_id:a.negative_snapshot_id,negative_raw:{state:'explicit_empty',preview:'',truncated:false,evidence:[]}});summaries.get(a.context_id).negative_raw.evidence.push({run_id:r.run_id,anchor_id:'1',...proof()});}return {success:true,history_version:1,history_epoch:epoch,group:{positive_id:7,raw_text_id:digest('e'),relation:'exact_frozen_raw_text',scope_status:'matched'},recorded_run_count:3,resume_cursor:items.length?'resume-token':null,items,context_summaries:[...summaries.values()],has_more:more,next_cursor:more?'window-token':null};}
|
||||
const detail=(n=1)=>({success:true,history_version:1,history_epoch:epoch,run:run(n),contexts:[context(),context('2','b')],context_capture:capture,output_summary:output});
|
||||
function receipt(node='1',ordinal=0) {return {occurrence:{run_id:id(1),output_node_id:node,image_ordinal:ordinal},reference:{folder_type:'output',subfolder:'',filename:'image.png',path_style:'posix',root_key:digest('a')},file_identity:'unverified',newly_generated:'unknown',context_membership:'unknown',transient:false,current_candidate:{status:'unavailable',reason:'missing',evidence_class:'locator_candidate'}};}
|
||||
const outputs=(items=[receipt('1',0),receipt('1',1)],more=true)=>({success:true,history_version:1,history_epoch:epoch,capture:{state:'complete',reason:'reported',format_version:1,source:'host_final_history'},capture_fingerprint:digest('f'),retained_occurrence_count:4,list_status:'complete',items,has_more:more,next_cursor:more?'outputs-token':null});
|
||||
function setup(restore) {const requests=[],changes=[];assert.equal(typeof m.createHistoryController,'function','history controller is exported');const c=m.createHistoryController({positiveId:7,restore,onChange:s=>changes.push(s),api:{get:(url,{signal})=>new Promise((resolve,reject)=>requests.push({url,signal,resolve,reject}))}});return {c,requests,changes};}
|
||||
async function loaded(restore) {const s=setup(restore),p=s.c.load();s.requests.at(-1).resolve(windowPage());await p;return s;}
|
||||
async function selected() {const s=await loaded(),p=s.c.selectRun(id(1));s.requests.at(-1).resolve(detail());await p;return s;}
|
||||
|
||||
test('chronology keeps A A B as three runs and two branches with exact decimal ordering',async()=>{const {c,requests}=await loaded();assert.deepEqual(c.state.window.items.map(r=>r.run_id),[id(1),id(2),id(3)]);assert.equal(c.state.window.context_summaries.length,2);assert.equal(c.state.window.items[2].observed_dequeue_at,'9007199254740993');assert.equal(requests[0].url,'/prompt_manager/library/7/history?limit=40');assert.equal(c.state.window.recorded_run_count,3);c.dispose();});
|
||||
|
||||
test('earlier windows prepend full identities once and refresh replaces bounded count',async()=>{const s=setup();let p=s.c.load();s.requests[0].resolve(windowPage([run(2),run(3,'b')],true));await p;p=s.c.loadEarlier();assert.match(s.requests[1].url,/cursor=window-token/);s.requests[1].resolve(windowPage([run(1),run(2)],false));await p;assert.deepEqual(s.c.state.window.items.map(r=>r.run_id),[id(1),id(2),id(3)]);p=s.c.refresh();s.requests[2].resolve({...windowPage([run(3)]),recorded_run_count:1});await p;assert.deepEqual(s.c.state.window.items.map(r=>r.run_id),[id(3)]);assert.equal(s.c.state.window.recorded_run_count,1);s.c.dispose();});
|
||||
|
||||
test('malformed windows never enable actions',async()=>{for(const mutate of [p=>p.items[0].queue_item_id='01',p=>p.items[0].observed_dequeue_at='9223372036854775808',p=>p.items[0].run_id='bad',p=>p.history_epoch='bad',p=>p.group.positive_id=8,p=>p.context_summaries[0].negative_raw.preview='x'.repeat(101),p=>p.has_more=true,p=>p.next_cursor='',p=>p.items[0].matching_anchors[0].context_id='x',p=>p.items[0].observed_callback_at=1,p=>p.recorded_run_count=-1]) {const {c,requests}=setup();const p=c.load(),data=windowPage();mutate(data);requests[0].resolve(data);await p;assert.ok(c.state.window.error);assert.equal(c.state.actionsEnabled,false);c.dispose();}});
|
||||
|
||||
test('frozen roles retain explicit empty and unknown values with their own evidence',()=>{assert.equal(typeof m.frozenPromptRoles,'function');const ctx=context(),raw=m.frozenPromptRoles(ctx,'raw'),effective=m.frozenPromptRoles(ctx,'effective');assert.equal(raw.negative.status,'resolved');assert.equal(raw.negative.text,'');assert.deepEqual(raw.negative.evidence,proof());assert.equal(effective.positive.status,'unresolved');assert.equal(effective.positive.text,null);assert.deepEqual(effective.positive.evidence,proof('unknown'));assert.equal(effective.negative.text,'negative');assert.equal(raw.positive.text,'positive\n猫');assert.throws(()=>m.frozenPromptRoles(ctx,'wrong'),TypeError);});
|
||||
|
||||
test('detail validates exact roles evidence IDs and epoch before selection',async()=>{for(const mutate of [p=>p.history_epoch=id(4),p=>p.run.run_id=id(2),p=>p.contexts[0].negative_snapshot_id=null,p=>p.contexts[0].positive.raw=42,p=>p.contexts[0].evidence.positive.raw.source='unknown',p=>p.contexts[0].navigation.pair_available=1,p=>p.contexts[0].positive_prompt_id=0,p=>p.contexts.push(p.contexts[0])]) {const {c,requests}=await loaded();const p=c.selectRun(id(1)),data=detail();mutate(data);requests.at(-1).resolve(data);await p;assert.ok(c.state.run.error || c.state.refreshRequired);assert.equal(c.state.run.detail,null);assert.equal(c.state.actionsEnabled,false);c.dispose();}});
|
||||
|
||||
test('dependent selections abort late run output success and errors including disposal',async()=>{const {c,requests,changes}=await loaded();const p=c.selectRun(id(1)),q=c.selectRun(id(2));assert.equal(requests[1].signal.aborted,true);requests[2].resolve(detail(2));await q;requests[1].reject({status:409,code:'history-changed'});await p;assert.equal(c.state.selection.run_id,id(2));assert.equal(c.state.refreshRequired,null);const r=c.selectRun(id(1));requests[3].resolve(detail());await r;for(const change of [()=>c.selectContext('2'),()=>c.setMode('effective')]) {const a=c.loadOutputs();const request=requests.at(-1);change();assert.equal(request.signal.aborted,true);request.resolve(outputs());await a;assert.equal(c.state.outputs.items.length,0);}const a=c.loadOutputs(),request=requests.at(-1);c.dispose();const count=changes.length;request.reject({status:409,code:'history-changed'});await a;assert.equal(changes.length,count);});
|
||||
|
||||
test('output pagination preserves binary order and exact repeated receipt selection on retry',async()=>{const {c,requests}=await selected();let p=c.loadOutputs();requests.at(-1).resolve(outputs());await p;c.selectReceipt({run_id:id(1),output_node_id:'1',image_ordinal:1});assert.equal(c.state.selection.receipt.image_ordinal,1);p=c.loadOutputs();const url=requests.at(-1).url;requests.at(-1).reject(new Error('offline'));await p;assert.equal(c.state.outputs.items.length,2);assert.equal(c.state.outputs.next_cursor,'outputs-token');p=c.retryOutputs();assert.equal(requests.at(-1).url,url);requests.at(-1).resolve(outputs([receipt('\ue000'),receipt('𐀀')],false));await p;assert.deepEqual(c.state.outputs.items.map(r=>r.occurrence.output_node_id),['1','1','\ue000','𐀀']);assert.equal(c.state.selection.receipt.image_ordinal,1);c.dispose();});
|
||||
|
||||
test('receipt selection invalidates pending responses without losing loaded coordinates',async()=>{const {c,requests}=await selected();let p=c.loadOutputs();requests.at(-1).resolve(outputs());await p;p=c.loadOutputs();const pending=requests.at(-1);c.selectReceipt({run_id:id(1),output_node_id:'1',image_ordinal:0});assert.equal(pending.signal.aborted,true);pending.reject({status:409,code:'capture-changed'});await p;assert.equal(c.state.outputs.items.length,2);assert.equal(c.state.selection.receipt.image_ordinal,0);assert.equal(c.state.refreshRequired,null);c.dispose();});
|
||||
|
||||
test('response-limit retries only the failed cursor at the permitted smaller limit',async()=>{const {c,requests}=await selected();let p=c.loadOutputs();requests.at(-1).resolve(outputs());await p;p=c.loadOutputs();requests.at(-1).reject({status:422,code:'response-limit',recovery:{action:'smaller-limit',limit:20}});await p;assert.equal(c.state.outputs.items.length,2);p=c.retryOutputs();assert.match(requests.at(-1).url,/limit=20/);assert.match(requests.at(-1).url,/cursor=outputs-token/);requests.at(-1).resolve(outputs([receipt('2',0),receipt('2',1)],false));await p;assert.equal(c.state.outputs.items.length,4);c.dispose();});
|
||||
|
||||
test('real ApiClient response recovery retries the same failed output cursor at its smaller limit',async()=>{
|
||||
const calls=[];let reads=0;
|
||||
const api=createApiClient(async(url,init)=>{
|
||||
calls.push({url,method:init.method});
|
||||
const path=new URL(url,'http://localhost');
|
||||
if(path.pathname.endsWith('/history'))return Response.json(windowPage());
|
||||
if(!path.pathname.endsWith('/outputs'))return Response.json(detail());
|
||||
reads++;
|
||||
if(reads===1)return Response.json(outputs());
|
||||
if(reads===2)return Response.json({success:false,history_version:1,code:'response-limit',error:'Reported outputs exceed the response budget',recovery:{action:'smaller-limit',limit:20}},{status:422});
|
||||
return Response.json(outputs([receipt('2',0),receipt('2',1)],false));
|
||||
});
|
||||
const c=m.createHistoryController({api,positiveId:7});
|
||||
try {
|
||||
await c.load();await c.selectRun(id(1));await c.loadOutputs();
|
||||
c.selectReceipt(receipt('1',1).occurrence);const retained=c.state.outputs.items;
|
||||
await c.loadOutputs();
|
||||
assert.deepEqual(c.state.outputs.error.recovery,{action:'smaller-limit',limit:20});
|
||||
assert.equal(c.state.outputs.items,retained);assert.equal(c.state.selection.receipt.image_ordinal,1);
|
||||
await c.retryOutputs();
|
||||
const pages=calls.filter(call=>call.url.includes('/outputs?')).map(call=>new URL(call.url,'http://localhost'));
|
||||
assert.deepEqual(pages.map(url=>url.searchParams.get('limit')),['40','40','20']);
|
||||
assert.deepEqual(pages.map(url=>url.searchParams.get('cursor')),[null,'outputs-token','outputs-token']);
|
||||
assert.deepEqual(c.state.outputs.items.map(item=>[item.occurrence.output_node_id,item.occurrence.image_ordinal]),[['1',0],['1',1],['2',0],['2',1]]);
|
||||
assert.equal(c.state.selection.receipt.image_ordinal,1);assert.equal(c.state.outputs.has_more,false);
|
||||
assert.ok(calls.every(call=>call.method==='GET'));
|
||||
} finally {c.dispose();}
|
||||
});
|
||||
|
||||
test('history change clears every actionable object and revalidates parent independently',async()=>{const {c,requests}=await selected();const p=c.loadOutputs();requests.at(-1).reject({status:409,code:'history-changed',recovery:{action:'refresh-history'}});await p;assert.equal(c.state.refreshRequired,'history-changed');assert.equal(c.state.window.items.length,0);assert.equal(c.state.run.detail,null);assert.equal(c.state.selection.run_id,null);assert.equal(c.state.outputs.next_cursor,null);assert.equal(c.state.actionsEnabled,false);assert.equal(requests.at(-1).url,'/prompt_manager/library/7');requests.at(-1).resolve({positive:{id:7,text:'saved'},variants:[]});await new Promise(resolve=>setImmediate(resolve));assert.equal(c.state.parent.detail.positive.id,7);assert.equal(c.state.refreshLabel,'Reload recorded history');c.dispose();});
|
||||
|
||||
test('capture and candidate changes clear candidates and reload output page one',async()=>{for(const code of ['capture-changed','candidate-stale']) {const {c,requests}=await selected();let p=c.loadOutputs();requests.at(-1).resolve(outputs());await p;p=c.loadOutputs();requests.at(-1).reject({status:409,code});await p;assert.equal(c.state.outputs.items.length,0);assert.equal(c.state.actionsEnabled,false);assert.doesNotMatch(requests.at(-1).url,/cursor=/);requests.at(-1).resolve({...outputs([],false),retained_occurrence_count:0});await new Promise(resolve=>setImmediate(resolve));assert.equal(c.state.outputs.error,null);c.dispose();}});
|
||||
|
||||
test('reentry validates epoch before actions while restore accepts only harmless intent',async()=>{const {c,requests}=await loaded({search:'猫',scrollTop:120,mode:'effective',state:{window:windowPage()},pairId:9});assert.equal(c.state.intent.search,'猫');assert.equal(c.state.selection.mode,'effective');const p=c.revalidate();assert.equal(c.state.actionsEnabled,false);requests.at(-1).resolve({...windowPage(),history_epoch:id(4)});await p;assert.equal(c.state.refreshRequired,'history-changed');assert.equal(c.state.window.items.length,0);requests.at(-1).resolve({positive:{id:7,text:'saved'},variants:[]});c.dispose();});
|
||||
|
||||
test('invalid caller identifiers cannot request history or select nonexistent contexts and receipts',async()=>{assert.equal(typeof m.createHistoryController,'function');for(const positiveId of [0,'7',NaN,9007199254740992]) assert.throws(()=>m.createHistoryController({positiveId,api:{}}),TypeError);const {c,requests}=await selected();await assert.rejects(c.selectRun('bad'),TypeError);assert.throws(()=>c.selectContext('missing'),TypeError);assert.throws(()=>c.selectReceipt({run_id:id(1),output_node_id:'1',image_ordinal:0}),TypeError);assert.equal(requests.length,2);c.dispose();});
|
||||
|
||||
test('malformed output coordinates capture fingerprints evidence and continuations fail closed',async()=>{for(const mutate of [p=>p.items[0].occurrence.image_ordinal=256,p=>p.items[0].occurrence.run_id=id(2),p=>p.capture_fingerprint='bad',p=>p.has_more=false,p=>p.history_epoch=id(4),p=>p.items[0].file_identity='verified',p=>p.items[0].reference.root_key='bad',p=>p.items[0].current_candidate.url='/fake',p=>p.items[0].occurrence.output_node_id='\ud800']) {const {c,requests}=await selected();const p=c.loadOutputs(),data=outputs();mutate(data);requests.at(-1).resolve(data);await p;assert.ok(c.state.outputs.error || c.state.refreshRequired);assert.equal(c.state.outputs.items.length,0);c.dispose();}});
|
||||
|
||||
test('restored identifiers select only matching freshly validated run and context',async()=>{const {c,requests}=setup({runId:id(1),anchorId:'2',contextId:digest('b'),mode:'effective',outputs:outputs(),receipt:receipt().occurrence});assert.equal(c.state.selection.run_id,null);assert.equal(c.state.outputs.items.length,0);let p=c.load();requests[0].resolve(windowPage());await p;assert.equal(requests.length,2);assert.equal(c.state.run.loading,true);requests[1].resolve(detail());await new Promise(resolve=>setImmediate(resolve));assert.equal(c.state.selection.anchor_id,'2');assert.equal(c.state.selection.context_id,digest('b'));assert.equal(c.state.selection.mode,'effective');assert.equal(c.state.selection.receipt,null);c.dispose();});
|
||||
|
||||
test('new run selection invalidates a pending reentry epoch check and its late error',async()=>{const {c,requests}=await selected();const validation=c.revalidate(),old=requests.at(-1),next=c.selectRun(id(2));assert.equal(old.signal.aborted,true);requests.at(-1).resolve(detail(2));await next;old.reject({status:409,code:'history-changed'});await validation;assert.equal(c.state.selection.run_id,id(2));assert.equal(c.state.run.detail.run.run_id,id(2));assert.equal(c.state.refreshRequired,null);assert.equal(c.state.validating,false);c.dispose();});
|
||||
|
||||
test('explicit detail limit errors stay bounded read failures and retry only the detail',async()=>{const {c,requests}=await loaded();let p=c.selectRun(id(1),{anchorId:'2',contextId:digest('b')});requests.at(-1).reject({status:422,code:'response-limit'});await p;assert.equal(c.state.run.error.code,'response-limit');assert.equal(c.state.window.items.length,3);assert.equal(c.state.refreshRequired,null);p=c.retryRun();assert.match(requests.at(-1).url,/\/runs\/00000000-0000-0000-0000-000000000001\?epoch=/);requests.at(-1).resolve(detail());await p;assert.equal(c.state.selection.anchor_id,'2');assert.equal(c.state.run.error,null);assert.equal(requests.length,3);c.dispose();});
|
||||
|
||||
test('repeated stale output responses stop automatic recovery and retain explicit reload state',async()=>{const {c,requests}=await selected();const p=c.loadOutputs();requests.at(-1).reject({status:409,code:'candidate-stale'});await p;assert.equal(requests.length,4);requests.at(-1).reject({status:409,code:'candidate-stale'});await new Promise(resolve=>setImmediate(resolve));assert.equal(requests.length,4,'recovery must not poll indefinitely');assert.equal(c.state.refreshRequired,'candidate-stale');assert.equal(c.state.outputs.error.code,'candidate-stale');c.dispose();});
|
||||
|
||||
test('known and unknown frozen evidence cannot contradict their field or declared role provenance',async()=>{for(const mutate of [p=>p.contexts[0].evidence.role_provenance='recorded',p=>p.contexts[0].evidence.negative.raw.reason='missing',p=>p.contexts[0].evidence.positive.effective.reason=null,p=>p.run.outcome='conflicting',p=>p.run.reason='terminal_error']) {const {c,requests}=await loaded();const p=c.selectRun(id(1)),data=detail();mutate(data);requests.at(-1).resolve(data);await p;assert.ok(c.state.run.error);assert.equal(c.state.run.detail,null);c.dispose();}});
|
||||
|
||||
test('contradictory page counts and unrelated summary evidence are rejected before showing branches',async()=>{for(const mutate of [p=>{p.items=[];p.has_more=true;p.next_cursor='more';},p=>p.context_summaries[0].negative_raw.evidence[0].run_id=id(88),p=>p.context_summaries[0].positive_snapshot_id=digest('f')]){const {c,requests}=setup(),p=c.load(),data=windowPage();mutate(data);requests[0].resolve(data);await p;assert.ok(c.state.window.error);c.dispose();}const {c,requests}=await selected();let p=c.loadOutputs();requests.at(-1).resolve(outputs());await p;p=c.loadOutputs();requests.at(-1).resolve(outputs([receipt('2',0)],false));await p;assert.ok(c.state.outputs.error);assert.equal(c.state.outputs.items.length,2);assert.equal(c.state.outputs.next_cursor,'outputs-token');c.dispose();});
|
||||
|
||||
function availableReceipt(currentScope,node='1',ordinal=0) {
|
||||
const r=receipt(node,ordinal),locationKey=currentScope==='a'?digest('b'):digest('9');
|
||||
const query=new URLSearchParams({node,ordinal:String(ordinal),epoch,current_scope:digest(currentScope)}),base=`/prompt_manager/history/runs/${id(1)}/output/current`;
|
||||
const media={kind:'reported-location',occurrence:r.occurrence,reference:r.reference,history_epoch:epoch,current_scope:digest(currentScope),location_key:locationKey,filename:'image.png',is_video:false,url:`${base}/media?${query}`,thumbnail_url:`${base}/thumbnail?${query}`,inspection_url:`${base}/inspection?${query}`};
|
||||
return {...r,current_candidate:{status:'available',reason:null,evidence_class:'locator_candidate',current_scope:digest(currentScope),location_key:locationKey,relative_path:'image.png',source_label:'output',gallery_match:'none',gallery_image_id:null,media}};
|
||||
}
|
||||
|
||||
test('every revalidation emission clears old available candidates and selected receipt before enabling actions',async()=>{
|
||||
const {c,requests,changes}=await selected();let p=c.loadOutputs();
|
||||
requests.at(-1).resolve({...outputs([availableReceipt('a')],false),retained_occurrence_count:1});await p;
|
||||
c.selectReceipt({run_id:id(1),output_node_id:'1',image_ordinal:0});assert.equal(c.state.actionsEnabled,true);
|
||||
const first=changes.length,validation=c.revalidate();assert.equal(c.state.validating,true);assert.equal(c.state.actionsEnabled,false);
|
||||
requests.at(-1).resolve(detail());await new Promise(resolve=>setImmediate(resolve));
|
||||
const pending=requests.at(-1);assert.match(pending.url,/\/outputs\?/);assert.equal(c.state.outputs.loading,true);
|
||||
const assertSafe=state=>{if(state.actionsEnabled){assert.equal(state.selection.receipt,null,'an enabled emission cannot retain the pre-validation receipt');assert.ok(state.outputs.items.every(r=>r.current_candidate.media.current_scope!==digest('a')),'an enabled emission cannot expose old current scope');}};
|
||||
try {
|
||||
changes.slice(first).forEach(assertSafe);
|
||||
pending.resolve({...outputs([availableReceipt('9')],false),retained_occurrence_count:1});await validation;
|
||||
changes.slice(first).forEach(assertSafe);assert.equal(c.state.outputs.items[0].current_candidate.media.current_scope,digest('9'));
|
||||
assert.equal(c.state.selection.receipt,null);assert.equal(c.state.validating,false);assert.equal(c.state.actionsEnabled,true);
|
||||
} finally {c.dispose();}
|
||||
});
|
||||
|
||||
test('pending mode-independent detail keeps the current mode and derives its exact effective roles without another GET',async()=>{
|
||||
const {c,requests}=await loaded(),pending=c.selectRun(id(1)),read=requests.at(-1);
|
||||
c.setMode('effective');assert.equal(read.signal.aborted,false);assert.equal(requests.length,2);
|
||||
read.resolve(detail());await pending;
|
||||
assert.equal(c.state.selection.mode,'effective');const ctx=c.state.run.detail.contexts.find(ctx=>ctx.anchor_id===c.state.selection.anchor_id);
|
||||
const roles=m.frozenPromptRoles(ctx,c.state.selection.mode);assert.equal(roles.negative.text,'negative');assert.equal(roles.positive.text,null);assert.equal(roles.positive.status,'unresolved');assert.deepEqual(roles.positive.evidence,proof('unknown'));
|
||||
assert.equal(requests.length,2);assert.equal(c.state.run.error,null);c.dispose();
|
||||
});
|
||||
|
||||
test('active inspection history failure clears every emitted owning state for HTTP and stale-echo errors',async()=>{
|
||||
for(const transport of ['http','echo']) {
|
||||
const {c,requests,changes}=await selected();let pending=c.loadOutputs();
|
||||
const item=availableReceipt('a');requests.at(-1).resolve({...outputs([item],false),retained_occurrence_count:1});await pending;c.selectReceipt(item.occurrence);
|
||||
const target=item.current_candidate.media,client=createApiClient(async()=>transport==='http'
|
||||
? Response.json({success:false,code:'history-changed',error:'Recorded history changed',recovery:{action:'refresh-history'}},{status:409})
|
||||
: Response.json({success:true,history_version:1,inspection_version:1,history_epoch:id(8)}));
|
||||
const inspection=createInspectionController({api:client,adapter:reportedLocationAdapter,extractFacts:extractGenerationFacts});
|
||||
const remove=inspection.subscribe(state=>c.inspectionFailed?.(target,state)),first=changes.length;
|
||||
try {
|
||||
await inspection.select(target);
|
||||
assert.equal(c.state.refreshRequired,'history-changed');
|
||||
for(const state of changes.slice(first)) {
|
||||
assert.equal(state.actionsEnabled,false);assert.deepEqual(state.window.items,[]);assert.equal(state.window.history_epoch,null);
|
||||
assert.equal(state.run.detail,null);assert.equal(state.selection.run_id,null);assert.equal(state.selection.anchor_id,null);assert.equal(state.selection.context_id,null);assert.equal(state.selection.receipt,null);
|
||||
assert.equal(state.window.next_cursor,null);assert.equal(state.outputs.next_cursor,null);assert.deepEqual(state.outputs.items,[]);
|
||||
}
|
||||
assert.equal(requests.at(-1).url,'/prompt_manager/library/7');requests.at(-1).resolve({positive:{id:7,text:'saved'},variants:[]});await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.equal(c.state.parent.detail.positive.text,'saved');assert.equal(c.state.actionsEnabled,false);
|
||||
} finally {remove();inspection.dispose();c.dispose();}
|
||||
}
|
||||
});
|
||||
|
||||
test('candidate and capture inspection failures keep their narrower recovery and obsolete targets cannot invalidate selection',async()=>{
|
||||
for(const code of ['candidate-stale','capture-changed']) {
|
||||
const {c,requests}=await selected();let pending=c.loadOutputs();const item=availableReceipt('a');
|
||||
requests.at(-1).resolve({...outputs([item],false),retained_occurrence_count:1});await pending;c.selectReceipt(item.occurrence);
|
||||
c.inspectionFailed?.(item.current_candidate.media,{error:true,refreshRequired:true,code});
|
||||
assert.equal(c.state.refreshRequired,code);assert.equal(c.state.selection.run_id,id(1));assert.equal(c.state.run.detail.run.run_id,id(1));assert.equal(c.state.selection.receipt,null);assert.deepEqual(c.state.outputs.items,[]);
|
||||
assert.match(requests.at(-1).url,/\/outputs\?/);requests.at(-1).resolve({...outputs([availableReceipt('9')],false),retained_occurrence_count:1});await new Promise(resolve=>setImmediate(resolve));
|
||||
c.selectReceipt(item.occurrence);c.inspectionFailed?.(item.current_candidate.media,{error:true,refreshRequired:true,code:'history-changed'});
|
||||
assert.equal(c.state.refreshRequired,null,'old candidate scope cannot invalidate the fresh receipt');assert.equal(c.state.selection.run_id,id(1));
|
||||
c.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('targeted unavailable receipt retry replaces only its bounded page and keeps all exact visit selections',async()=>{
|
||||
const {c,requests}=await selected();let p=c.loadOutputs();requests.at(-1).resolve(outputs());await p;
|
||||
p=c.loadOutputs();requests.at(-1).resolve(outputs([receipt('2',0),receipt('2',1)],false));await p;
|
||||
c.selectContext('2');c.setMode('effective');c.selectReceipt(receipt('2',1).occurrence);
|
||||
const earlier=c.state.outputs.items.slice(0,2),readCount=requests.length;
|
||||
p=c.retryReceipt?.(receipt('2',1).occurrence);
|
||||
assert.equal(requests.length,readCount+1,'retry must read the successful page that owns this occurrence');
|
||||
assert.match(requests.at(-1).url,/limit=40&cursor=outputs-token/);
|
||||
requests.at(-1).resolve(outputs([receipt('2',0),availableReceipt('9','2',1)],false));await p;
|
||||
assert.deepEqual(c.state.outputs.items.slice(0,2),earlier);assert.equal(c.state.outputs.items.length,4);
|
||||
assert.equal(c.state.outputs.items[3].current_candidate.status,'available');assert.deepEqual(c.state.selection.receipt,receipt('2',1).occurrence);
|
||||
assert.equal(c.state.selection.anchor_id,'2');assert.equal(c.state.selection.context_id,digest('b'));assert.equal(c.state.selection.mode,'effective');assert.equal(c.state.outputs.next_cursor,null);
|
||||
const before=requests.length;await c.loadOutputs();assert.equal(requests.length,before,'availability retry cannot invent another page');c.dispose();
|
||||
});
|
||||
|
||||
test('targeted retry retains the receipt on read failure and suppresses late availability after a newer selection',async()=>{
|
||||
const {c,requests}=await selected();let p=c.loadOutputs();requests.at(-1).resolve(outputs());await p;c.selectReceipt(receipt().occurrence);
|
||||
p=c.retryReceipt?.(receipt().occurrence);assert.equal(requests.length,4);
|
||||
requests.at(-1).reject(new Error('offline'));await p;assert.equal(c.state.outputs.items.length,2);assert.deepEqual(c.state.selection.receipt,receipt().occurrence);
|
||||
p=c.retryOutputs();const pending=requests.at(-1);c.selectReceipt(receipt('1',1).occurrence);assert.equal(pending.signal.aborted,true);
|
||||
pending.resolve(outputs([availableReceipt('9'),receipt('1',1)]));await p;
|
||||
assert.equal(c.state.outputs.items[0].current_candidate.status,'unavailable');assert.equal(c.state.selection.receipt.image_ordinal,1);c.dispose();
|
||||
});
|
||||
|
||||
test('targeted page retry honors a smaller budget and seeks only within that already loaded receipt page',async()=>{
|
||||
const {c,requests}=await selected();let p=c.loadOutputs();requests.at(-1).resolve(outputs());await p;
|
||||
p=c.loadOutputs();requests.at(-1).resolve(outputs([receipt('2',0),receipt('2',1)],false));await p;c.selectReceipt(receipt('2',1).occurrence);
|
||||
p=c.retryReceipt?.(receipt('2',1).occurrence);assert.equal(requests.length,5);
|
||||
requests.at(-1).reject({status:422,code:'response-limit',recovery:{action:'smaller-limit',limit:1}});await p;
|
||||
p=c.retryOutputs();assert.match(requests.at(-1).url,/limit=1&cursor=outputs-token/);
|
||||
requests.at(-1).resolve({...outputs([receipt('2',0)],true),next_cursor:'target-next'});await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.match(requests.at(-1).url,/limit=1&cursor=target-next/);requests.at(-1).resolve(outputs([availableReceipt('9','2',1)],false));await p;
|
||||
assert.equal(c.state.outputs.items.length,4);assert.equal(c.state.outputs.items[3].current_candidate.status,'available');assert.equal(c.state.outputs.next_cursor,null);assert.equal(c.state.selection.receipt.image_ordinal,1);assert.equal(requests.length,7);c.dispose();
|
||||
});
|
||||
|
||||
test('same-epoch refresh restores exact selected context mode and receipt only from fresh reads',async()=>{
|
||||
const {c,requests,changes}=await selected();let p=c.loadOutputs();requests.at(-1).resolve({...outputs([availableReceipt('a')],false),retained_occurrence_count:1});await p;
|
||||
c.selectContext('2');c.setMode('effective');c.selectReceipt(receipt().occurrence);const first=changes.length;
|
||||
p=c.refresh();requests.at(-1).resolve(windowPage());await p;
|
||||
assert.match(requests.at(-1).url,/\/history\/runs\//,'refresh must revalidate the selected run');assert.equal(c.state.actionsEnabled,false);
|
||||
requests.at(-1).resolve(detail());await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.match(requests.at(-1).url,/\/outputs\?/);assert.equal(c.state.actionsEnabled,false);
|
||||
assert.ok(changes.slice(first).every(state=>!state.actionsEnabled),'old objects must never be briefly enabled');
|
||||
requests.at(-1).resolve({...outputs([availableReceipt('9')],false),retained_occurrence_count:1});await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.equal(c.state.selection.run_id,id(1));assert.equal(c.state.selection.anchor_id,'2');assert.equal(c.state.selection.context_id,digest('b'));assert.equal(c.state.selection.mode,'effective');assert.deepEqual(c.state.selection.receipt,receipt().occurrence);
|
||||
assert.equal(c.state.outputs.items[0].current_candidate.media.current_scope,digest('9'));assert.equal(c.state.actionsEnabled,true);c.dispose();
|
||||
});
|
||||
|
||||
test('fresh exact group membership keeps an older selected run outside a refreshed newest prefix',async()=>{
|
||||
for(const member of [true,false]) {
|
||||
const {c,requests}=await selected();c.selectContext('2');c.setMode('effective');
|
||||
const p=c.refresh();requests.at(-1).resolve({...windowPage([run(60),run(61)]),recorded_run_count:61});await p;
|
||||
assert.match(requests.at(-1).url,/\/runs\/00000000-0000-0000-0000-000000000001\?/);
|
||||
const fresh=detail();if(!member)fresh.contexts.forEach(context=>context.positive_raw_text_id=digest('f'));
|
||||
requests.at(-1).resolve(fresh);await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.deepEqual(c.state.window.items.map(run=>run.run_id),[id(60),id(61)]);
|
||||
if(member) {assert.equal(c.state.selection.run_id,id(1));assert.equal(c.state.selection.anchor_id,'2');assert.equal(c.state.actionsEnabled,true);}
|
||||
else {assert.equal(c.state.run.detail,null);assert.equal(c.state.actionsEnabled,false);assert.ok(c.state.run.error);assert.equal(c.state.selection.run_id,null,'an incompatible saved selection must be discarded');}
|
||||
c.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('reentry restores exactly the prior bounded timeline pages before enabling the older selection',async()=>{
|
||||
const restored={historyEpoch:epoch,resumeCursor:'old-upper-resume',loadedCount:80,runId:id(1),anchorId:'2',contextId:digest('b'),mode:'effective',outputCount:4,receipt:receipt('2',1).occurrence,scrollLeft:731,scrollTop:95};
|
||||
const {c,requests,changes}=setup(restored),p=c.load();assert.match(requests.at(-1).url,/resume=old-upper-resume/);
|
||||
requests.at(-1).resolve({...windowPage(Array.from({length:40},(_,i)=>run(41+i)),true),recorded_run_count:80});await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.equal(c.state.actionsEnabled,false);assert.match(requests.at(-1).url,/cursor=window-token/);
|
||||
requests.at(-1).resolve({...windowPage(Array.from({length:40},(_,i)=>run(1+i))),recorded_run_count:80});await p;
|
||||
assert.equal(c.state.window.items.length,80);assert.equal(c.state.actionsEnabled,false);assert.match(requests.at(-1).url,/\/runs\//);
|
||||
requests.at(-1).resolve(detail());await new Promise(resolve=>setImmediate(resolve));
|
||||
requests.at(-1).resolve(outputs([receipt('1',0),receipt('1',1),receipt('2',0),receipt('2',1)],false));await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.equal(requests.length,4);assert.equal(c.state.selection.run_id,id(1));assert.equal(c.state.selection.anchor_id,'2');assert.equal(c.state.selection.mode,'effective');assert.deepEqual(c.state.selection.receipt,receipt('2',1).occurrence);
|
||||
assert.equal(c.state.intent.scrollLeft,731);assert.equal(c.state.intent.scrollTop,95);assert.equal(c.state.actionsEnabled,true);
|
||||
assert.ok(changes.slice(0,-1).every(state=>!state.actionsEnabled));c.dispose();
|
||||
});
|
||||
|
||||
test('refresh epoch change clears old selection before independent parent revalidation',async()=>{
|
||||
const {c,requests}=await selected();let p=c.refresh();requests.at(-1).resolve({...windowPage(),history_epoch:id(8)});await p;
|
||||
assert.equal(c.state.refreshRequired,'history-changed');assert.equal(c.state.selection.run_id,null);assert.equal(c.state.window.resume_cursor,null);assert.equal(c.state.run.detail,null);assert.deepEqual(c.state.outputs.items,[]);assert.equal(c.state.actionsEnabled,false);
|
||||
requests.at(-1).resolve({positive:{id:7,text:'saved'},variants:[]});c.dispose();
|
||||
});
|
||||
|
||||
test('obsolete inspection errors after another exact receipt and disposal cannot reach the owning history',async()=>{
|
||||
const {c,requests,changes}=await selected();const first=availableReceipt('a'),second=availableReceipt('9','2',0);let p=c.loadOutputs();
|
||||
requests.at(-1).resolve({...outputs([first,second],false),retained_occurrence_count:2});await p;
|
||||
const pending=[];let target=first.current_candidate.media;
|
||||
const inspection=createInspectionController({api:{get:()=>new Promise((resolve,reject)=>pending.push({resolve,reject}))},adapter:reportedLocationAdapter,extractFacts:extractGenerationFacts});
|
||||
const remove=inspection.subscribe(state=>c.inspectionFailed(target,state));
|
||||
c.selectReceipt(first.occurrence);p=inspection.select(target);
|
||||
target=second.current_candidate.media;c.selectReceipt(second.occurrence);const next=inspection.select(target);
|
||||
pending[1].reject({status:503,code:'storage-unavailable'});await next;const before=changes.length;
|
||||
pending[0].reject({status:409,code:'history-changed'});await p;
|
||||
assert.equal(changes.length,before);assert.equal(c.state.refreshRequired,null);assert.deepEqual(c.state.selection.receipt,second.occurrence);
|
||||
p=inspection.retry();inspection.dispose();c.dispose();const count=changes.length;pending[2].reject({status:409,code:'history-changed'});await p;
|
||||
assert.equal(changes.length,count);remove();
|
||||
});
|
||||
|
||||
test('malformed resume and raw membership scalars cannot enable restored actions',async()=>{
|
||||
for(const mutate of [page=>page.resume_cursor='',page=>page.resume_cursor='x'.repeat(4097),page=>page.resume_cursor=42,page=>page.resume_cursor=null]) {
|
||||
const {c,requests}=setup({historyEpoch:epoch,resumeCursor:'old-range',loadedCount:80,runId:id(1)}),p=c.load(),page=windowPage();mutate(page);requests.at(-1).resolve(page);await p;
|
||||
assert.ok(c.state.window.error);assert.equal(c.state.actionsEnabled,false);assert.equal(c.state.run.detail,null);assert.equal(requests.length,1);c.dispose();
|
||||
}
|
||||
for(const value of [undefined,null,'bad',42]) {
|
||||
const {c,requests}=await loaded(),p=c.selectRun(id(1)),fresh=detail();fresh.contexts[0].positive_raw_text_id=value;requests.at(-1).resolve(fresh);await p;
|
||||
assert.ok(c.state.run.error);assert.equal(c.state.run.detail,null);assert.equal(c.state.actionsEnabled,false);c.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('a resumed page success or error cannot replace a later run choice',async()=>{
|
||||
for(const outcome of ['success','error']) {
|
||||
const {c,requests}=setup({historyEpoch:epoch,resumeCursor:'old-range',loadedCount:80,runId:id(1)}),p=c.load();
|
||||
requests[0].resolve({...windowPage(Array.from({length:40},(_,i)=>run(41+i)),true),recorded_run_count:80});await new Promise(resolve=>setImmediate(resolve));
|
||||
const old=requests[1],next=c.selectRun(id(41));assert.equal(old.signal.aborted,true);requests[2].resolve(detail(41));await next;
|
||||
if(outcome==='success')old.resolve({...windowPage(Array.from({length:40},(_,i)=>run(i+1))),recorded_run_count:80});else old.reject({status:409,code:'history-changed'});
|
||||
await p;assert.equal(c.state.selection.run_id,id(41));assert.equal(c.state.window.items.length,40);assert.equal(c.state.refreshRequired,null);assert.equal(c.state.actionsEnabled,true);c.dispose();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createScope} from '../../web/js/shared/toolkit/core.js';
|
||||
import {mountImages} from '../../web/js/workspace/images.mjs';
|
||||
|
||||
test('Images mounted after its parent is aborted creates no DOM, requests or observers',()=>{
|
||||
const parent=createScope();parent.dispose();
|
||||
const originals=new Map(['document','window','ResizeObserver','IntersectionObserver'].map(key=>[key,Object.getOwnPropertyDescriptor(globalThis,key)]));
|
||||
const touches=[];
|
||||
const forbidden=name=>new Proxy({}, {get(target,key){touches.push(`${name}.${String(key)}`);throw Error(`Disposed mount accessed ${name}.${String(key)}`);}});
|
||||
Object.defineProperty(globalThis,'document',{configurable:true,value:forbidden('document')});
|
||||
Object.defineProperty(globalThis,'window',{configurable:true,value:forbidden('window')});
|
||||
for(const key of ['ResizeObserver','IntersectionObserver']) Object.defineProperty(globalThis,key,{configurable:true,value:class {constructor(){touches.push(key);throw Error('Observer created after parent disposal');}}});
|
||||
const api={get(){touches.push('request');assert.fail('Request started after parent disposal');}};
|
||||
try {
|
||||
let mounted;
|
||||
assert.doesNotThrow(()=>{mounted=mountImages(forbidden('container'),api,parent,{embedded:true,fetchPage(){touches.push('page');assert.fail('Page requested after parent disposal');}});});
|
||||
assert.equal(typeof mounted.dispose,'function');mounted.dispose();mounted.dispose();
|
||||
assert.deepEqual(touches,[]);
|
||||
} finally {
|
||||
for(const [key,descriptor] of originals) {if(descriptor) Object.defineProperty(globalThis,key,descriptor);else delete globalThis[key];}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const source = import('../../web/js/workspace/images-state.mjs');
|
||||
const image = (path, root_index = 0) => ({path, root_index, id:'same-hash'});
|
||||
const page = (images, total = images.length, next_cursor = null) => ({images, total, next_cursor, has_more:next_cursor !== null});
|
||||
const deferred = () => { let resolve, reject; const promise = new Promise((a,b) => {resolve=a;reject=b;}); return {promise,resolve,reject}; };
|
||||
|
||||
test('stream serializes allowed filters, appends distinct rooted paths, and emits immutable snapshots', async () => {
|
||||
const { createImageStream, imageKey } = await source;
|
||||
const calls = [], states = [];
|
||||
const pages = [page([image('a'),image('a',1)],3,'next'), page([image('a',1),image('b')],3)];
|
||||
const stream = createImageStream({api:{get:async (url, options) => { calls.push([url, options]);return pages.shift(); }},onChange:s => states.push(s),batchSize:2});
|
||||
assert.equal(await stream.reset({q:'a & b',root:0,media:'image',sort:'oldest',subfolder:'nested/one',ignored:'unsafe'}), 2);
|
||||
const query = new URL(calls[0][0],'http://local').searchParams;
|
||||
assert.equal(query.get('q'),'a & b'); assert.equal(query.get('root'),'0');
|
||||
assert.equal(query.get('subfolder'),'nested/one'); assert.equal(query.get('mode'),'workspace');
|
||||
assert.equal(query.get('limit'),'2'); assert.equal(query.has('cursor'),false); assert.equal(query.has('ignored'),false);
|
||||
assert.ok(calls[0][1].signal instanceof AbortSignal);
|
||||
assert.equal(imageKey(image('a',0)),'0:a'); assert.notEqual(imageKey(image('a',0)), imageKey(image('a',1)));
|
||||
const first = stream.state;
|
||||
assert.ok(Object.isFrozen(first)); assert.ok(Object.isFrozen(first.items)); assert.ok(Object.isFrozen(first.items[0]));
|
||||
const more = stream.loadMore();
|
||||
assert.strictEqual(stream.state.items,first.items);
|
||||
assert.equal(await more,1);
|
||||
assert.equal(new URL(calls[1][0],'http://local').searchParams.get('cursor'),'next');
|
||||
assert.deepEqual(stream.state.items.map(imageKey),['0:a','1:a','0:b']);
|
||||
assert.equal(stream.state.loaded,3); assert.equal(stream.state.total,3); assert.equal(stream.state.hasMore,false);
|
||||
assert.equal(await stream.loadMore(),0); assert.equal(calls.length,2);
|
||||
assert.equal(first.items.length,2); assert.equal(states.at(-1),stream.state);
|
||||
});
|
||||
|
||||
test('reset aborts pending requests and ignores stale success and failure', async () => {
|
||||
const { createImageStream } = await source;
|
||||
const requests=[]; const states=[];
|
||||
const stream=createImageStream({api:{get:(url,{signal})=>{const d=deferred();requests.push({...d,url,signal});return d.promise;}},onChange:s=>states.push(s)});
|
||||
const old=stream.reset({q:'old'}); const fresh=stream.reset({q:'fresh'});
|
||||
assert.equal(requests[0].signal.aborted,true);
|
||||
requests[1].resolve(page([image('fresh')]));await fresh;
|
||||
requests[0].resolve(page([image('old')])); assert.equal(await old,0);
|
||||
assert.equal(stream.state.items[0].path,'fresh');
|
||||
const failing=stream.reset({q:'failure'}); const newest=stream.reset();
|
||||
requests[2].reject(new Error('stale'));requests[3].resolve(page([]));
|
||||
await Promise.all([failing,newest]);assert.equal(stream.state.error,null);
|
||||
assert.equal(stream.state.loading,false);
|
||||
});
|
||||
|
||||
test('deferred reset cancels stale source work without fetching until discovery succeeds', async () => {
|
||||
const {createImageStream}=await source;const requests=[];
|
||||
const stream=createImageStream({api:{get:(url,{signal})=>{const d=deferred();requests.push({...d,url,signal});return d.promise;}}});
|
||||
const old=stream.reset({root:1,subfolder:'old'});
|
||||
const deferredReset=stream.reset({root:'all',q:'png',media:'image',sort:'name'},{load:false});
|
||||
assert.equal(requests[0].signal.aborted,true);assert.equal(requests.length,1);
|
||||
assert.equal(await deferredReset,0);
|
||||
assert.equal(stream.state.items.length,0);assert.equal(stream.state.cursor,null);assert.equal(stream.state.loading,false);
|
||||
requests[0].resolve(page([image('old',1)],2,'stale'));assert.equal(await old,0);assert.equal(stream.state.items.length,0);
|
||||
const fresh=stream.loadMore();assert.equal(requests.length,2);
|
||||
const query=new URL(requests[1].url,'http://local').searchParams;
|
||||
assert.equal(query.has('root'),false);assert.equal(query.has('subfolder'),false);assert.equal(query.get('q'),'png');assert.equal(query.get('media'),'image');assert.equal(query.get('sort'),'name');
|
||||
requests[1].resolve(page([image('fresh',0)]));assert.equal(await fresh,1);assert.equal(stream.state.items[0].path,'fresh');
|
||||
stream.dispose();assert.equal(await stream.reset({}, {load:false}),0);assert.equal(requests.length,2);
|
||||
});
|
||||
|
||||
test('concurrent loads do not duplicate requests, and failures retain data and cursor for retry', async () => {
|
||||
const {createImageStream}=await source; const pending=deferred(); const calls=[];
|
||||
const responses=[page([image('a')],2,'next'),pending.promise,page([image('b')],2)];
|
||||
const stream=createImageStream({api:{get:async url=>{calls.push(url);return responses.shift();}}});
|
||||
await stream.reset();const more=stream.loadMore();assert.equal(await stream.loadMore(),0);assert.equal(calls.length,2);
|
||||
pending.reject(new Error('offline'));assert.equal(await more,0);
|
||||
assert.equal(stream.state.items.length,1);assert.equal(stream.state.cursor,'next');assert.equal(stream.state.error.message,'offline');
|
||||
assert.equal(stream.state.hasMore,true);assert.equal(await stream.loadMore(),1);assert.equal(stream.state.error,null);
|
||||
assert.equal(calls[1],calls[2]);
|
||||
});
|
||||
|
||||
test('malformed pages fail atomically and allow retry without corrupting the stream', async () => {
|
||||
const {createImageStream}=await source;
|
||||
for(const invalid of [null,{},page('bad'),page([{}]),page([image('')]),page([image('x',-1)]),page([], -1),{...page([]),has_more:'yes'},page([],2,''),{...page([],2,'next'),has_more:false}]) {
|
||||
const replies=[page([image('existing')],2,'next'),invalid,page([image('fixed')],2)];
|
||||
const stream=createImageStream({api:{get:async()=>replies.shift()}});await stream.reset();
|
||||
await stream.loadMore();assert.match(stream.state.error.message,/image|page|cursor/i);
|
||||
assert.equal(stream.state.items[0].path,'existing'); assert.equal(stream.state.cursor,'next');
|
||||
await stream.loadMore();assert.equal(stream.state.items.length,2);assert.equal(stream.state.error,null);
|
||||
}
|
||||
});
|
||||
|
||||
test('duplicate pages can advance cursors, but repeated cursors fail instead of looping', async () => {
|
||||
const {createImageStream}=await source;
|
||||
const replies=[page([image('a')],2,'one'),page([image('a')],2,'two'),page([image('b')],2,'two')];
|
||||
const stream=createImageStream({api:{get:async()=>replies.shift()}});await stream.reset();
|
||||
assert.equal(await stream.loadMore(),0);assert.equal(stream.state.cursor,'two');assert.equal(stream.state.hasMore,true);
|
||||
await stream.loadMore();assert.match(stream.state.error.message,/cursor/i);assert.equal(stream.state.items.length,1);
|
||||
});
|
||||
|
||||
test('dispose aborts the active request and suppresses notifications and further requests', async () => {
|
||||
const {createImageStream}=await source;const d=deferred();let signal,count=0,notifications=0;
|
||||
const stream=createImageStream({api:{get:(url,opts)=>{signal=opts.signal;count++;return d.promise;}},onChange:()=>notifications++});
|
||||
const request=stream.reset();stream.dispose();stream.dispose();assert.equal(signal.aborted,true);
|
||||
const before=notifications;d.reject(Object.assign(new Error('aborted'),{name:'AbortError'}));await request;
|
||||
assert.equal(notifications,before);assert.equal(await stream.reset(),0);assert.equal(await stream.loadMore(),0);assert.equal(count,1);
|
||||
});
|
||||
|
||||
test('stream validates constructor and filter input before requesting', async () => {
|
||||
const {createImageStream}=await source;
|
||||
assert.throws(()=>createImageStream({api:{}}),/api/i);
|
||||
for(const batchSize of [0,-1,1.5,Infinity,201]) assert.throws(()=>createImageStream({api:{get(){}},batchSize}),/batch/i);
|
||||
const stream=createImageStream({api:{get:async()=>page([])}});
|
||||
for(const filters of [{root:-1},{root:'oops'},{media:'executable'},{sort:'random'},{q:{}},{subfolder:[]}]) await assert.rejects(stream.reset(filters),/filter/i);
|
||||
await stream.reset({root:'all',media:'all',sort:'newest'});assert.equal(stream.state.hasMore,false);
|
||||
});
|
||||
|
||||
test('the metadata stream retains earlier images beyond one rendered window and deduplicates within a batch', async () => {
|
||||
const {createImageStream}=await source;
|
||||
let request=0;
|
||||
const stream=createImageStream({api:{get:async()=>{
|
||||
const start=request++*40;
|
||||
return page(Array.from({length:40},(_,index)=>image(`image-${start+index}`)),320,start+40<320?String(start+40):null);
|
||||
}}});
|
||||
await stream.reset();
|
||||
while(stream.state.hasMore) await stream.loadMore();
|
||||
assert.equal(stream.state.loaded,320);assert.equal(stream.state.items[0].path,'image-0');
|
||||
assert.equal(stream.state.items.at(-1).path,'image-319');
|
||||
const duplicate=createImageStream({api:{get:async()=>page([image('a'),image('a')],1)}});
|
||||
assert.equal(await duplicate.reset(),1);assert.equal(duplicate.state.items.length,1);
|
||||
});
|
||||
|
||||
test('all-source defaults are omitted and descending name sort remains supported', async () => {
|
||||
const {createImageStream}=await source;let query;
|
||||
const stream=createImageStream({api:{get:async url=>{query=new URL(url,'http://local').searchParams;return page([]);}}});
|
||||
await stream.reset({root:'all',media:'all',sort:'name-desc'});
|
||||
assert.equal(query.has('root'),false);assert.equal(query.has('media'),false);
|
||||
assert.equal(query.get('sort'),'name-desc');assert.equal(query.get('limit'),'40');
|
||||
});
|
||||
|
||||
test('restored browsing state resumes its cursor and filters without an initial request', async () => {
|
||||
const {createImageStream}=await source;
|
||||
const calls=[];
|
||||
const cachedItems=[image('existing')];
|
||||
const cache={filters:{q:'saved search',root:1,sort:'oldest',media:'image',subfolder:'nested'},state:{items:cachedItems,total:3,cursor:'saved-cursor',hasMore:true,loaded:1,loading:true,error:new Error('old error')}};
|
||||
const stream=createImageStream({api:{get:async url=>{calls.push(url);return page([image('existing'),image('new')],3,'next');}},restore:cache});
|
||||
assert.equal(calls.length,0);assert.equal(stream.state.items.length,1);
|
||||
assert.equal(stream.state.loading,false);assert.equal(stream.state.error,null);
|
||||
assert.ok(Object.isFrozen(stream.state));assert.ok(Object.isFrozen(stream.state.items));assert.ok(Object.isFrozen(stream.state.items[0]));
|
||||
cachedItems[0].path='external mutation';cachedItems.push(image('external'));
|
||||
assert.equal(stream.state.items[0].path,'existing');assert.equal(stream.state.items.length,1);
|
||||
assert.equal(await stream.loadMore(),1);
|
||||
const query=new URL(calls[0],'http://local').searchParams;
|
||||
for(const [key,value] of Object.entries({q:'saved search',root:'1',sort:'oldest',media:'image',subfolder:'nested',cursor:'saved-cursor'})) assert.equal(query.get(key),value);
|
||||
assert.deepEqual(stream.state.items.map(item=>item.path),['existing','new']);
|
||||
await stream.reset({q:'new search'});
|
||||
assert.equal(new URL(calls[1],'http://local').searchParams.has('cursor'),false);
|
||||
assert.equal(new URL(calls[1],'http://local').searchParams.get('q'),'new search');
|
||||
stream.dispose();assert.equal(await stream.loadMore(),0);
|
||||
});
|
||||
|
||||
test('restoring frozen state reuses immutable metadata and respects a completed collection', async () => {
|
||||
const {createImageStream}=await source;
|
||||
const first=createImageStream({api:{get:async()=>page([image('saved')])}});
|
||||
await first.reset();let requests=0;
|
||||
const restored=createImageStream({api:{get:async()=>{requests++;return page([]);}},restore:{filters:{},state:first.state}});
|
||||
assert.strictEqual(restored.state.items,first.state.items);assert.equal(restored.state.loaded,1);
|
||||
assert.equal(await restored.loadMore(),0);assert.equal(requests,0);
|
||||
});
|
||||
|
||||
test('restored interrupted initial loading can restart from the first request',async()=>{
|
||||
const {createImageStream}=await source;let called=0;
|
||||
const stream=createImageStream({api:{get:async url=>{called++;assert.equal(new URL(url,'http://local').searchParams.has('cursor'),false);return page([]);}},
|
||||
restore:{filters:{},state:{items:[],total:0,cursor:null,hasMore:true,loading:true}}});
|
||||
assert.equal(called,0);assert.equal(await stream.loadMore(),0);assert.equal(called,1);assert.equal(stream.state.hasMore,false);
|
||||
});
|
||||
|
||||
test('malformed restored state is rejected before making requests',async()=>{
|
||||
const {createImageStream}=await source;
|
||||
const base={items:[image('a')],total:2,cursor:'saved',hasMore:true};
|
||||
const invalid=[null,{}, {filters:{},state:null},
|
||||
...[{items:'bad'},{items:[{}]},{items:[image('a'),image('a')]},{total:-1},{cursor:''},{cursor:null},{hasMore:'yes'},{hasMore:false}].map(change=>({filters:{},state:{...base,...change}})),
|
||||
{filters:{root:-1},state:base}, {filters:null,state:base}];
|
||||
for(const restore of invalid) assert.throws(()=>createImageStream({api:{get(){assert.fail('unexpected request');}},restore}),TypeError);
|
||||
});
|
||||
|
||||
test('custom collection pages reuse image-stream cancellation, cursors and identity checks',async()=>{
|
||||
const {createImageStream}=await source;const calls=[];
|
||||
const stream=createImageStream({fetchPage:async({parameters,signal})=>{calls.push({parameters,signal});return calls.length===1?page([image('linked.png')],2,'next'):page([image('other.png')],2);},batchSize:1});
|
||||
assert.equal(await stream.reset(),1);assert.equal(calls[0].parameters.get('limit'),'1');assert.ok(calls[0].signal instanceof AbortSignal);
|
||||
assert.equal(await stream.loadMore(),1);assert.equal(calls[1].parameters.get('cursor'),'next');assert.equal(stream.state.loaded,2);stream.dispose();
|
||||
assert.throws(()=>createImageStream({api:{get:async()=>{}},fetchPage:'invalid'}),/page/i);
|
||||
});
|
||||
@@ -0,0 +1,343 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
const require = createRequire(import.meta.url);
|
||||
const { extractGenerationFacts } = require('../../web/js/comfy-metadata.js');
|
||||
const { ApiError } = require('../../web/js/api-client.js');
|
||||
const module = await import('../../web/js/workspace/inspection-state.mjs').catch(error => {
|
||||
if (error.code !== 'ERR_MODULE_NOT_FOUND') throw error;
|
||||
return {};
|
||||
});
|
||||
const scopeA = 'a'.repeat(64), scopeB = 'b'.repeat(64);
|
||||
const refreshMessage = 'The source changed. Refresh the image collection before inspecting this file.';
|
||||
const fields = ['seed', 'steps', 'cfg', 'sampler', 'scheduler', 'model'];
|
||||
function contract(name) { assert.equal(typeof module[name], 'function', `${name} contract is exported`); return module[name]; }
|
||||
function image(root_scope = scopeA) {
|
||||
return { root_index: 0, path: 'folder/a #%.png', filename: 'a #%.png', url: `/prompt_manager/images/serve/folder/a%20%23%25.png?root=0${root_scope === undefined ? '' : `&root_scope=${root_scope}`}`, ...(root_scope === undefined ? {} : { root_scope }) };
|
||||
}
|
||||
function envelope(entry = image()) {
|
||||
return {
|
||||
success: true, inspection_version: 1, root_scope: entry.root_scope ?? scopeA,
|
||||
image: { root_index: entry.root_index, path: entry.path, filename: entry.filename },
|
||||
file: { observation: 'current-file', source_label: 'same label', media_type: 'image', extension: '.png', format: 'PNG', size: 123, width: 720, height: 480, modified_time: 1234 },
|
||||
prompts: { positive: { status: 'resolved', text: 'exact\npositive', source: 'execution' }, negative: { status: 'ambiguous', text: null, source: null } },
|
||||
metadata: {
|
||||
status: 'available', omitted_reason: null, raw: { prompt: '{"rounded":"raw copy"}' }, record_formats: { prompt: 'reserialized-json' },
|
||||
prompt_graph: { status: 'parsed', value: { s: { class_type: 'KSampler', inputs: { seed: '9007199254740993', steps: 28, cfg: '6.5000000000000000001', sampler_name: 'euler', scheduler: 'normal', model: ['model', 0] } }, model: { class_type: 'CheckpointLoaderSimple', inputs: { ckpt_name: 'model.safetensors' } } } },
|
||||
workflow_graph: { status: 'absent', value: null }, parameters: null,
|
||||
},
|
||||
resolution: { state: 'ambiguous', sampler_id: null, reason: 'unknown negative' },
|
||||
library: { status: 'linked', scope: 'saved-library', positive_id: 7, pair_id: 8, title: 'Recipe', category: 'Subject', tags: ['cat'], metadata_scope: 'pair' },
|
||||
};
|
||||
}
|
||||
function pendingApi() {
|
||||
const requests = [];
|
||||
const api = { get: (url, { signal }) => new Promise((resolve, reject) => requests.push({ url, signal, resolve, reject })) };
|
||||
return { api, requests };
|
||||
}
|
||||
|
||||
test('original media validation is strict before inspection and preserves rooted URLs', () => {
|
||||
const validate = contract('originalMediaDownload'), entry = image();
|
||||
assert.deepEqual(validate(entry), {url:entry.url,filename:'a #%.png'});
|
||||
for (const change of [{url:'https://other.test/prompt_manager/images/serve/folder/a%20%23%25.png?root=0'},
|
||||
{url:entry.url.replace('root=0','root=1')},{url:entry.url.replace(scopeA,scopeB)},
|
||||
{url:entry.url.replace('folder/a','elsewhere/a')},{url:entry.url.replace(`&root_scope=${scopeA}`,'')},
|
||||
{url:entry.url+'&root=0'},{url:entry.url+'#wrong'},{path:'../a.png'},{root_scope:''}]) {
|
||||
assert.throws(() => validate({...entry,...change}), TypeError);
|
||||
}
|
||||
const before = Object.getOwnPropertyDescriptor(globalThis,'window');
|
||||
Object.defineProperty(globalThis,'window',{configurable:true,value:{location:{href:'https://example.test/proxy/prompt_manager/workspace'},ApiClient:{resolveUrl:url=>`/proxy${url}`}}});
|
||||
try { assert.deepEqual(validate(entry),{url:`/proxy${entry.url}`,filename:'a #%.png'}); }
|
||||
finally { if (before) Object.defineProperty(globalThis,'window',before); else delete globalThis.window; }
|
||||
});
|
||||
|
||||
test('record preserves exact projections and unknown roles without execution fact promotion', () => {
|
||||
const entry = image(), data = envelope(entry);
|
||||
const record = contract('toInspectionRecord')(data, entry, extractGenerationFacts);
|
||||
assert.deepEqual(record.identity, { kind: 'rooted-file', key: '0:folder/a #%.png', root_index: 0, path: entry.path, root_scope: scopeA });
|
||||
assert.equal(record.generation.seed.value, '9007199254740993');
|
||||
assert.equal(record.generation.cfg.value, '6.5000000000000000001');
|
||||
assert.equal(record.generation.seed.evidence, 'configuration');
|
||||
assert.equal(record.prompts.negative.status, 'ambiguous');
|
||||
assert.equal(record.prompts.negative.text, null);
|
||||
assert.equal(record.historyTarget, '#/library/7');
|
||||
assert.deepEqual(record.download, { url: entry.url, filename: entry.filename });
|
||||
assert.equal(record.file.source_label, 'same label');
|
||||
for (const status of ['none', 'ambiguous', 'unavailable', 'limited']) {
|
||||
const result = envelope(entry); result.library.status = status;
|
||||
assert.equal(module.toInspectionRecord(result, entry, extractGenerationFacts).historyTarget, null);
|
||||
}
|
||||
});
|
||||
|
||||
test('record format validation never guesses original text', () => {
|
||||
const toRecord = contract('toInspectionRecord'), entry = image();
|
||||
for (const mutate of [
|
||||
data => delete data.metadata.record_formats,
|
||||
data => { data.metadata.record_formats = { prompt: 'guess' }; },
|
||||
data => { data.metadata.record_formats.extra = 'original-text'; },
|
||||
data => { data.metadata.record_formats.prompt = 'invalid'; },
|
||||
data => { data.metadata.raw.workflow = '{}'; },
|
||||
data => { data.metadata.raw.prompt = {}; },
|
||||
data => { data.metadata.raw.extra = 'secret'; },
|
||||
data => { data.metadata.parameters = 'manufactured'; },
|
||||
data => { data.metadata.prompt_graph = { status: 'absent', value: null }; },
|
||||
data => { data.metadata.record_formats = {}; data.metadata.raw = {}; },
|
||||
data => { data.metadata.status = 'limited'; },
|
||||
]) {
|
||||
const data = envelope(entry); mutate(data);
|
||||
assert.throws(() => toRecord(data, entry, extractGenerationFacts), TypeError);
|
||||
}
|
||||
const data = envelope(entry);
|
||||
data.metadata.record_formats.workflow = 'invalid';
|
||||
data.metadata.workflow_graph = { status: 'malformed', value: null };
|
||||
assert.equal(toRecord(data, entry, extractGenerationFacts).metadata.record_formats.workflow, 'invalid');
|
||||
data.metadata.raw = {}; data.metadata.status = 'limited'; data.metadata.omitted_reason = 'response-size-limit';
|
||||
data.metadata.prompt_graph = { status: 'limited', value: null };
|
||||
data.metadata.workflow_graph = { status: 'limited', value: null };
|
||||
const limited = toRecord(data, entry, extractGenerationFacts);
|
||||
assert.deepEqual(limited.metadata.record_formats, { prompt: 'reserialized-json', workflow: 'invalid' });
|
||||
assert.equal(limited.generation.seed.status, 'unresolved');
|
||||
assert.equal(limited.generation.seed.reason, 'projection-limited');
|
||||
let seen;
|
||||
toRecord(envelope(entry), entry, input => { seen = input; return extractGenerationFacts(input); });
|
||||
assert.deepEqual(seen.recordFormats, { prompt: 'reserialized-json' });
|
||||
assert.equal(seen.raw, undefined, 'facts never receive raw graphs for reparsing');
|
||||
});
|
||||
|
||||
test('record accepts actual atomic omission producer states', () => {
|
||||
const toRecord = contract('toInspectionRecord');
|
||||
for (const wholeRaw of [false, true]) {
|
||||
const data = envelope();
|
||||
data.metadata.record_formats.workflow = 'invalid';
|
||||
data.metadata.prompt_graph = { status: 'limited', value: null };
|
||||
data.metadata.workflow_graph = { status: 'limited', value: null };
|
||||
if (wholeRaw) Object.assign(data.metadata, { raw: {}, parameters: null, status: 'limited', omitted_reason: 'response-size-limit' });
|
||||
let record;
|
||||
assert.doesNotThrow(() => { record = toRecord(data, image(), extractGenerationFacts); }, 'actual producer omission remains renderable');
|
||||
assert.deepEqual(record.metadata.raw, wholeRaw ? {} : { prompt: '{"rounded":"raw copy"}' });
|
||||
assert.equal(record.metadata.status, wholeRaw ? 'limited' : 'available');
|
||||
assert.equal(record.metadata.omitted_reason, wholeRaw ? 'response-size-limit' : null);
|
||||
assert.deepEqual(record.metadata.record_formats, { prompt: 'reserialized-json', workflow: 'invalid' });
|
||||
assert.equal(record.prompts.positive.text, 'exact\npositive');
|
||||
assert.equal(record.download.url, image().url);
|
||||
assert.ok(Object.values(record.generation).every(fact => fact.status === 'unresolved' && fact.reason === 'projection-limited'));
|
||||
}
|
||||
const absent = envelope();
|
||||
Object.assign(absent.metadata, { status: 'limited', omitted_reason: 'response-size-limit', raw: {}, record_formats: { parameters: 'original-text' }, parameters: null, prompt_graph: { status: 'absent', value: null } });
|
||||
assert.equal(toRecord(absent, image(), extractGenerationFacts).generation.seed.reason, 'projection-limited');
|
||||
});
|
||||
|
||||
test('record rejects contradictory omission provenance before extracting facts', () => {
|
||||
const toRecord = contract('toInspectionRecord');
|
||||
for (const mutate of [
|
||||
data => Object.assign(data.metadata, { status: 'limited', omitted_reason: 'response-size-limit' }),
|
||||
data => Object.assign(data.metadata, { status: 'limited', omitted_reason: 'response-size-limit', raw: {} }),
|
||||
data => { data.metadata.raw = {}; },
|
||||
data => { data.metadata.record_formats.workflow = 'original-text'; data.metadata.workflow_graph = { status: 'parsed', value: { nodes: [] } }; },
|
||||
data => { Object.assign(data.metadata, { status: 'limited', omitted_reason: 'response-size-limit', raw: {}, prompt_graph: { status: 'limited', value: null } }); data.metadata.record_formats.workflow = 'invalid'; data.metadata.workflow_graph = { status: 'malformed', value: null }; },
|
||||
data => { data.metadata.record_formats.workflow = 'invalid'; data.metadata.raw.workflow = 'null'; data.metadata.workflow_graph = { status: 'limited', value: null }; },
|
||||
data => { data.metadata.record_formats.workflow = 'invalid'; data.metadata.workflow_graph = { status: 'limited', value: null }; },
|
||||
]) {
|
||||
const data = envelope(); mutate(data); let extractions = 0;
|
||||
assert.throws(() => toRecord(data, image(), () => { extractions++; return extractGenerationFacts({}); }), TypeError);
|
||||
assert.equal(extractions, 0, 'contradictory transport cannot produce known facts');
|
||||
}
|
||||
});
|
||||
|
||||
test('record rejects root path media and invalid versioned fields with retryable TypeError', () => {
|
||||
const toRecord = contract('toInspectionRecord');
|
||||
for (const mutate of [
|
||||
data => { data.image.root_index = 1; }, data => { data.image.path = 'other.png'; },
|
||||
data => { data.inspection_version = 2; }, data => { data.file.width = 0; },
|
||||
data => { delete data.success; },
|
||||
data => { data.file.size = -1; }, data => { data.library.positive_id = 9007199254740992; },
|
||||
data => { data.metadata.prompt_graph = { status: 'limited', value: {} }; },
|
||||
data => { data.resolution.sampler_id = {}; },
|
||||
]) {
|
||||
const data = envelope(); mutate(data);
|
||||
assert.throws(() => toRecord(data, image(), extractGenerationFacts), error => error instanceof TypeError && !error.refreshRequired);
|
||||
}
|
||||
for (const url of ['https://attacker.invalid/prompt_manager/images/serve/folder/a%20%23%25.png?root=0', 'data:text/plain,unsafe', '/other/a.png', '/prompt_manager/images/serve/other.png?root=0', `/prompt_manager/images/serve/folder/a%20%23%25.png?root=1&root_scope=${scopeA}`, `/prompt_manager/images/serve/folder/a%20%23%25.png?root=0&root_scope=${scopeB}`, `/prompt_manager/images/serve/folder/a%20%23%25.png?root=0&root=1&root_scope=${scopeA}`]) {
|
||||
assert.throws(() => toRecord(envelope(), { ...image(), url }, extractGenerationFacts), TypeError);
|
||||
}
|
||||
});
|
||||
|
||||
test('record preserves proxy originals and rejects opaque or normalized path escapes', () => {
|
||||
const toRecord = contract('toInspectionRecord');
|
||||
const previousLocation = globalThis.location, previousApi = globalThis.ApiClient;
|
||||
globalThis.location = { href: 'https://workspace.example/nested/proxy/prompt_manager/workspace', pathname: '/nested/proxy/prompt_manager/workspace' };
|
||||
globalThis.ApiClient = require('../../web/js/api-client.js');
|
||||
try {
|
||||
assert.equal(toRecord(envelope(), image(), extractGenerationFacts).download.url, `/nested/proxy${image().url}`);
|
||||
const absolute = { ...image(), url: `https://workspace.example:443/nested/proxy${image().url}` };
|
||||
assert.equal(toRecord(envelope(), absolute, extractGenerationFacts).download.url, absolute.url);
|
||||
for (const url of [`https://workspace.example.attacker.invalid/nested/proxy${image().url}`, `/nested/proxy/prompt_manager/images/serve/folder/../a%20%23%25.png?root=0&root_scope=${scopeA}`, `//attacker.invalid${image().url}`]) assert.throws(() => toRecord(envelope(), { ...image(), url }, extractGenerationFacts), TypeError);
|
||||
} finally { globalThis.location = previousLocation; globalThis.ApiClient = previousApi; }
|
||||
});
|
||||
|
||||
test('inspectPath does not coerce or strip malformed scopes', () => {
|
||||
const inspectPath = contract('inspectPath');
|
||||
assert.equal(inspectPath(image()), `/prompt_manager/images/inspect/folder/a%20%23%25.png?root=0&root_scope=${scopeA}`);
|
||||
assert.equal(inspectPath({ root_index: 2, path: 'a b/x#%.png' }), '/prompt_manager/images/inspect/a%20b/x%23%25.png?root=2');
|
||||
for (const root_scope of [null, true, 1, {}, 'a'.repeat(63), 'A'.repeat(64), scopeA + '\n']) {
|
||||
assert.throws(() => inspectPath({ ...image(), root_scope }), TypeError);
|
||||
}
|
||||
for (const entry of [{ root_index: '0', path: 'a.png' }, { root_index: 0, path: '../a.png' }, { root_index: 0, path: 'a//b.png' }, { root_index: 0, path: '/a.png' }, { root_index: 0, path: 'C:file.png' }]) assert.throws(() => inspectPath(entry), TypeError);
|
||||
});
|
||||
|
||||
test('controller shares current request across subscribers', async () => {
|
||||
const { api, requests } = pendingApi(), updates = [[], []];
|
||||
const controller = contract('createInspectionController')({ api, extractFacts: extractGenerationFacts });
|
||||
const off = controller.subscribe(state => updates[0].push(state));
|
||||
controller.subscribe(state => updates[1].push(state));
|
||||
assert.equal(updates[0][0].record, null);
|
||||
const first = controller.select(image()), second = controller.select({ ...image() });
|
||||
assert.equal(requests.length, 1);
|
||||
assert.equal(controller.state.loading, true);
|
||||
requests[0].resolve(envelope()); await Promise.all([first, second]);
|
||||
assert.equal(controller.state.record.generation.seed.value, '9007199254740993');
|
||||
assert.equal(updates[0].at(-1), updates[1].at(-1));
|
||||
await controller.select(image()); assert.equal(requests.length, 1);
|
||||
off(); const count = updates[0].length;
|
||||
const retry = controller.retry(); assert.equal(controller.state.record, null); assert.equal(controller.state.prompts, undefined);
|
||||
assert.equal(requests.length, 2); assert.ok(requests[1].url.endsWith(`root_scope=${scopeA}`));
|
||||
requests[1].resolve(envelope()); await retry;
|
||||
assert.equal(updates[0].length, count);
|
||||
controller.clear(); assert.equal(controller.state.record, null); assert.equal(controller.state.loading, false);
|
||||
const reselect = controller.select(image()); assert.equal(requests.length, 3);
|
||||
requests[2].resolve(envelope()); await reselect; controller.dispose();
|
||||
});
|
||||
|
||||
test('root scope echo and source-changed require refresh', async () => {
|
||||
const SourceChanged = contract('InspectionSourceChangedError'), toRecord = contract('toInspectionRecord');
|
||||
for (const root_scope of [undefined, scopeB]) {
|
||||
const data = envelope();
|
||||
if (root_scope === undefined) delete data.root_scope; else data.root_scope = root_scope;
|
||||
assert.throws(() => toRecord(data, image(), extractGenerationFacts), error => error instanceof SourceChanged && error.refreshRequired && error.message === refreshMessage);
|
||||
const { api, requests } = pendingApi();
|
||||
const controller = contract('createInspectionController')({ api, extractFacts: extractGenerationFacts });
|
||||
const load = controller.select(image()); requests[0].resolve(data); await load;
|
||||
assert.deepEqual([controller.state.error, controller.state.refreshRequired, controller.state.record, controller.state.prompts, controller.state.message], [true, true, null, undefined, refreshMessage]);
|
||||
await controller.retry(); assert.equal(requests.length, 1); controller.dispose();
|
||||
}
|
||||
for (const [status, code, refresh] of [[409, 'source-changed', true], [409, null, false], [409, 'file-changed', false], [500, 'source-changed', false]]) {
|
||||
const { api, requests } = pendingApi();
|
||||
const controller = module.createInspectionController({ api, extractFacts: extractGenerationFacts });
|
||||
const load = controller.select(image()); requests[0].reject(new ApiError(status, refreshMessage, code)); await load;
|
||||
assert.equal(controller.state.refreshRequired, refresh);
|
||||
const retry = controller.retry(); assert.equal(requests.length, refresh ? 1 : 2);
|
||||
if (!refresh) { requests[1].resolve(envelope()); await retry; assert.equal(controller.state.error, false); }
|
||||
controller.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('same image key with changed scope supersedes pending actions', async () => {
|
||||
const { api, requests } = pendingApi();
|
||||
const controller = contract('createInspectionController')({ api, extractFacts: extractGenerationFacts });
|
||||
const first = controller.select(image());
|
||||
requests[0].resolve(envelope()); await first;
|
||||
const changed = image(scopeB), next = controller.select(changed);
|
||||
assert.equal(controller.state.record, null); assert.equal(controller.state.prompts, undefined);
|
||||
assert.equal(requests.length, 2);
|
||||
requests[1].resolve(envelope(changed)); await next;
|
||||
assert.equal(controller.state.record.identity.root_scope, scopeB);
|
||||
assert.equal(controller.state.record.file.source_label, 'same label');
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
test('unscoped role-only callers stay compatible', async () => {
|
||||
const toRecord = contract('toInspectionRecord'), entry = { root_index: 0, path: 'old.png' };
|
||||
const data = { prompts: { positive: { status: 'resolved', text: 'old', source: 'graph-inferred' }, negative: { status: 'unresolved', text: null, source: null } } };
|
||||
const record = toRecord(data, entry, extractGenerationFacts);
|
||||
assert.equal(record.identity.root_scope, null); assert.equal(record.identity.key, '0:old.png');
|
||||
for (const key of ['file', 'metadata', 'library', 'download', 'historyTarget']) assert.equal(record[key], null);
|
||||
for (const field of fields) assert.deepEqual(record.generation[field], { status: 'unresolved', value: null, source: null, evidence: null, reason: null, sampler_id: null });
|
||||
assert.deepEqual(record.prompts, data.prompts);
|
||||
const changes = [];
|
||||
const loader = contract('createInspectionLoader')({ get: async () => data }, value => changes.push(value));
|
||||
await loader.load(entry);
|
||||
assert.deepEqual(changes.at(-1).prompts, data.prompts); assert.equal(changes.at(-1).record.identity.key, '0:old.png');
|
||||
loader.dispose();
|
||||
assert.throws(() => toRecord(data, image(), extractGenerationFacts), error => error.refreshRequired === true);
|
||||
const scoped = toRecord({ ...data, root_scope: scopeA }, image(), extractGenerationFacts);
|
||||
assert.equal(scoped.metadata, null); assert.equal(scoped.download.url, image().url);
|
||||
});
|
||||
|
||||
test('disposed or superseded responses never publish', async () => {
|
||||
const { api, requests } = pendingApi(), updates = [];
|
||||
const controller = contract('createInspectionController')({ api, extractFacts: extractGenerationFacts });
|
||||
controller.subscribe(state => updates.push(state));
|
||||
const first = controller.select(image()), newer = image(scopeB), second = controller.select(newer);
|
||||
assert.equal(requests[0].signal.aborted, true);
|
||||
requests[1].resolve(envelope(newer)); await second;
|
||||
const count = updates.length;
|
||||
requests[0].reject(new ApiError(409, refreshMessage, 'source-changed')); await first;
|
||||
assert.equal(updates.length, count); assert.equal(controller.state.record.identity.root_scope, scopeB);
|
||||
const third = controller.retry(); controller.clear();
|
||||
assert.equal(requests[2].signal.aborted, true);
|
||||
requests[2].resolve(envelope(newer)); await third;
|
||||
assert.equal(controller.state.record, null);
|
||||
const final = controller.select(image()); controller.dispose(); const disposedCount = updates.length;
|
||||
requests[3].resolve(envelope()); await final;
|
||||
await controller.select(newer); await controller.retry(); controller.clear();
|
||||
assert.equal(updates.length, disposedCount); assert.equal(requests.length, 4);
|
||||
});
|
||||
|
||||
const invalidSelections = () => [null, undefined, 0, 'new.png', {}, { ...image(), path: '../new.png' }, { ...image(), root_scope: null }, { ...image(), root_scope: 'malformed' }];
|
||||
|
||||
test('invalid attempted selections clear completed actions and retry validation only', async () => {
|
||||
for (const invalid of invalidSelections()) {
|
||||
const { api, requests } = pendingApi();
|
||||
const controller = contract('createInspectionController')({ api, extractFacts: extractGenerationFacts });
|
||||
const previous = controller.select(image()); requests[0].resolve(envelope()); await previous;
|
||||
assert.equal(controller.state.prompts.positive.text, 'exact\npositive');
|
||||
let rejected, thrown;
|
||||
try { rejected = controller.select(invalid); } catch (error) { thrown = error; }
|
||||
assert.equal(controller.state.record, null, 'invalid navigation cannot retain completed actions'); assert.equal(controller.state.prompts, undefined);
|
||||
assert.equal(thrown, undefined, 'selection validation uses the retryable state');
|
||||
await rejected;
|
||||
assert.deepEqual([controller.state.loading, controller.state.error, controller.state.refreshRequired, controller.state.message], [false, true, false, 'Could not read details from this file.']);
|
||||
await controller.retry(); await controller.retry(); assert.equal(requests.length, 1);
|
||||
const valid = controller.select(image()); assert.equal(requests.length, 2);
|
||||
requests[1].resolve(envelope()); await valid;
|
||||
await controller.select({ ...image() }); assert.equal(requests.length, 2);
|
||||
controller.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('invalid attempted selections abort pending reads and suppress late results', async () => {
|
||||
for (const invalid of invalidSelections()) {
|
||||
const { api, requests } = pendingApi(), changes = [];
|
||||
const controller = contract('createInspectionController')({ api, extractFacts: extractGenerationFacts });
|
||||
controller.subscribe(state => changes.push(state));
|
||||
const previous = controller.select(image());
|
||||
let rejected, thrown;
|
||||
try { rejected = controller.select(invalid); } catch (error) { thrown = error; }
|
||||
assert.equal(requests[0].signal.aborted, true);
|
||||
assert.equal(thrown, undefined, 'selection validation uses the retryable state');
|
||||
assert.equal(controller.state.record, null); assert.equal(controller.state.prompts, undefined);
|
||||
await rejected; const count = changes.length;
|
||||
requests[0].resolve(envelope()); await previous;
|
||||
assert.equal(changes.length, count); assert.equal(controller.state.error, true);
|
||||
assert.equal(controller.state.record, null); assert.equal(controller.state.prompts, undefined);
|
||||
await controller.retry(); assert.equal(requests.length, 1);
|
||||
controller.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('explicit rooted adapter preserves the strict default identity and download contract', () => {
|
||||
assert.ok(module.rootedFileAdapter, 'the default rooted adapter is exported');
|
||||
const a = module.rootedFileAdapter, entry = image();
|
||||
assert.equal(a.key(entry), '0:folder/a #%.png'); assert.equal(a.scope(entry), scopeA);
|
||||
assert.equal(a.inspectionPath(entry), module.inspectPath(entry));
|
||||
assert.deepEqual(a.originalDownload(entry), module.originalMediaDownload(entry));
|
||||
assert.deepEqual(a.toRecord(envelope(), entry, extractGenerationFacts), module.toInspectionRecord(envelope(), entry, extractGenerationFacts));
|
||||
assert.throws(() => a.validate({kind:'reported-location',path:'a.png'}), TypeError);
|
||||
});
|
||||
|
||||
test('rooted controller keeps legacy descriptors with ignored noncloneable fields source compatible', async () => {
|
||||
const {api,requests}=pendingApi(),controller=module.createInspectionController({api,extractFacts:extractGenerationFacts});
|
||||
const selected={...image(),ignoredCallback(){}};
|
||||
let pending;assert.doesNotThrow(()=>{pending=controller.select(selected);});
|
||||
requests[0].resolve(envelope());await pending;assert.equal(controller.state.record.identity.key,'0:folder/a #%.png');controller.dispose();
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const source = import('../../web/js/workspace/image-layout.mjs');
|
||||
|
||||
test('layouts preserve data order, remain within width and never overlap', async () => {
|
||||
const {layoutImages} = await source;
|
||||
const images=Array.from({length:200},(_,i)=>({width:i%2?1024:640,height:i%2?640:1024}));
|
||||
for(const width of [280,760,1400]) for(const mode of ['grid','list','masonry']) {
|
||||
const {positions,height}=layoutImages(images,width,mode);
|
||||
assert.equal(positions.length,200);
|
||||
for(const [i,a] of positions.entries()) {
|
||||
assert.ok(a.x>=0 && a.y>=0 && a.width>0 && a.height>0);
|
||||
assert.ok(a.x+a.width<=width+.01);
|
||||
assert.ok(a.y+a.height<=height+.01);
|
||||
for(const b of positions.slice(i+1)) assert.ok(a.x+a.width<=b.x+.01 || b.x+b.width<=a.x+.01 || a.y+a.height<=b.y+.01 || b.y+b.height<=a.y+.01);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('empty, unknown dimensions and narrow containers keep valid geometry',async()=>{
|
||||
const {layoutImages}=await source;
|
||||
assert.deepEqual(layoutImages([],0,'grid'),{positions:[],height:0,maxHeight:0});
|
||||
for(const mode of ['grid','list','masonry']) {
|
||||
const result=layoutImages([{}, {width:0,height:null}],1,mode);
|
||||
assert.ok(Number.isFinite(result.height));
|
||||
}
|
||||
});
|
||||
|
||||
test('virtual window retains visible rows and focused card while bounding rendered media',async()=>{
|
||||
const {layoutImages,visibleImages}=await source;
|
||||
const {positions}=layoutImages(Array(5000).fill({}),900,'masonry');
|
||||
const indexes=visibleImages(positions,10000,900,0);
|
||||
assert.ok(indexes.length<100);
|
||||
assert.ok(indexes.includes(0));
|
||||
assert.ok(indexes.some(i=>positions[i].y>=10000 && positions[i].y<10900));
|
||||
assert.deepEqual(indexes,[...indexes].sort((a,b)=>a-b));
|
||||
assert.equal(new Set(indexes).size,indexes.length);
|
||||
assert.ok(visibleImages(positions,0,800).includes(0));
|
||||
});
|
||||
|
||||
test('large virtual collections inspect a bounded range rather than every loaded position', async () => {
|
||||
const {layoutImages,visibleImages}=await source;
|
||||
for(const mode of ['grid','list','masonry']) {
|
||||
const geometry=layoutImages(Array.from({length:50000},(_,index)=>({width:640,height:index%2?320:1024})),900,mode);
|
||||
for(let index=1;index<geometry.positions.length;index++) assert.ok(geometry.positions[index].y>=geometry.positions[index-1].y);
|
||||
let reads=0;
|
||||
const positions=new Proxy(geometry.positions,{get(target,key,receiver){
|
||||
if(typeof key==='string' && /^\d+$/.test(key)) reads++;
|
||||
return Reflect.get(target,key,receiver);
|
||||
}});
|
||||
const top=geometry.positions[25000].y;
|
||||
const indexes=visibleImages(positions,top,900,49999,geometry.maxHeight);
|
||||
assert.ok(reads<500,`${mode} read ${reads} positions`);
|
||||
const expected=geometry.positions.flatMap((position,index)=>(index===49999 ||
|
||||
(position.y+position.height>=top-900 && position.y<=top+1800))?[index]:[]);
|
||||
assert.deepEqual(indexes,expected);
|
||||
}
|
||||
});
|
||||
|
||||
test('binary-searched windows handle boundaries, empty collections and focus without duplicates',async()=>{
|
||||
const {layoutImages,visibleImages}=await source;
|
||||
assert.deepEqual(visibleImages([],0,800,0),[]);
|
||||
const {positions}=layoutImages(Array(1000).fill({}),900,'grid');
|
||||
for(const focused of [-1,0,999,1000]) {
|
||||
for(const top of [0,10000,1000000]) {
|
||||
const actual=visibleImages(positions,top,600,focused);
|
||||
const expected=positions.flatMap((position,index)=>(index===focused ||
|
||||
(position.y+position.height>=top-600 && position.y<=top+1200))?[index]:[]);
|
||||
assert.deepEqual(actual,expected);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(visibleImages(positions,1000000,0),[]);
|
||||
});
|
||||
|
||||
test('reported maximum height keeps unusually tall custom layouts inside the virtual window',async()=>{
|
||||
const {layoutImages,visibleImages}=await source;
|
||||
const geometry=layoutImages([{}, {}, {}],10000,'grid',20000);
|
||||
assert.equal(geometry.maxHeight,7554.5);
|
||||
assert.deepEqual(visibleImages(geometry.positions,6000,200,-1,geometry.maxHeight),[0]);
|
||||
});
|
||||
|
||||
|
||||
test('approved workspace densities share viewport metrics and reject unknown values', async () => {
|
||||
const {workspaceImageMetrics, layoutImages} = await source;
|
||||
assert.equal(typeof workspaceImageMetrics, 'function', 'workspace density metrics must be exported');
|
||||
for (const [width, expected] of [[1600,[3,2,4]], [1250,[2,2,3]], [950,[2,2,3]], [700,[2,2,2]], [390,[2,2,2]], [1700,[4,2,5]]]) {
|
||||
assert.deepEqual(['comfortable','large','compact'].map(density => workspaceImageMetrics(width,density).columns), expected);
|
||||
for (const density of ['comfortable','large','compact']) {
|
||||
const metrics = workspaceImageMetrics(width,density);
|
||||
assert.deepEqual(metrics, {columns: expected[['comfortable','large','compact'].indexOf(density)], gap:13, footerHeight:54, listHeight:97});
|
||||
assert.equal(layoutImages([{},{}],990,'list',240,metrics).positions[1].x,0);
|
||||
}
|
||||
}
|
||||
assert.throws(() => workspaceImageMetrics(1600,'unknown'), TypeError);
|
||||
assert.deepEqual(workspaceImageMetrics(1600),workspaceImageMetrics(1600,'comfortable'));
|
||||
});
|
||||
|
||||
test('approved grid uses inner media at four to three and explicit columns with thirteen pixel gaps', async () => {
|
||||
const {workspaceImageMetrics,layoutImages} = await source;
|
||||
assert.equal(typeof workspaceImageMetrics,'function');
|
||||
const metrics = workspaceImageMetrics(1600);
|
||||
const {positions} = layoutImages(Array(4).fill({}),990,'grid',240,metrics);
|
||||
assert.equal(positions[1].x-positions[0].width,13);
|
||||
assert.equal(positions[3].x,0);
|
||||
assert.equal(positions[3].y-positions[0].height,13);
|
||||
assert.equal(positions[0].height-54-2,(positions[0].width-2)*.75);
|
||||
assert.equal(layoutImages([{}],990,'list',240,metrics).height,97);
|
||||
const custom=layoutImages([{}],990,'grid',240,{columns:2,gap:20,footerHeight:60,listHeight:100});
|
||||
assert.equal(custom.positions[0].width,485);
|
||||
assert.equal(custom.height,(485-2)*.75+62);
|
||||
assert.equal(layoutImages([{}],990,'list',240,{listHeight:100}).height,100);
|
||||
});
|
||||
|
||||
test('masonry uses real finite proportions and falls back for invalid dimensions', async () => {
|
||||
const {layoutImages}=await source;
|
||||
const images=[{width:100,height:1000},{width:1000,height:100},{width:Infinity,height:10},{width:10,height:NaN}];
|
||||
const {positions}=layoutImages(images,990,'masonry',240,{columns:3});
|
||||
assert.equal(positions[0].height,(positions[0].width-2)*10+56);
|
||||
assert.equal(positions[1].height,(positions[1].width-2)/10+56);
|
||||
for (const position of positions) assert.ok(Number.isFinite(position.height));
|
||||
});
|
||||
|
||||
test('gallery width limits density columns without changing ordinary workspace geometry', async () => {
|
||||
const {workspaceImageMetrics, layoutImages} = await source;
|
||||
for (const [viewport, width, density, columns] of [
|
||||
[1600,990,'comfortable',3], [1600,990,'compact',4],
|
||||
[951,428,'compact',3], [390,362,'comfortable',2],
|
||||
[1600,190,'comfortable',1], [1600,190,'compact',1],
|
||||
[390,167,'large',1], [390,167,'compact',1], [390,69.5,'comfortable',1], [390,0,'comfortable',1],
|
||||
]) {
|
||||
const metrics = workspaceImageMetrics(viewport,density,width);
|
||||
assert.equal(metrics.columns,columns,`${viewport}/${width}/${density}`);
|
||||
const {positions} = layoutImages(Array(8).fill({}),width,'grid',240,metrics);
|
||||
assert.equal(positions[columns].x,0);
|
||||
assert.ok(positions[columns].y > 0);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const source = import('../../web/js/workspace/library-layout.mjs');
|
||||
|
||||
test('natural Library rows use the tallest measured card and estimate only missing heights', async () => {
|
||||
const {layoutLibraryRows} = await source;
|
||||
const heights = Object.freeze([300, 470, 600, 250, null]);
|
||||
const layout = layoutLibraryRows(heights, 2);
|
||||
assert.deepEqual(layout.rows, [
|
||||
{first: 0, last: 1, top: 0, height: 470},
|
||||
{first: 2, last: 3, top: 488, height: 600},
|
||||
{first: 4, last: 4, top: 1106, height: 468},
|
||||
]);
|
||||
assert.equal(layout.height, 1574);
|
||||
assert.deepEqual(layoutLibraryRows([300, 470], 1), {
|
||||
rows: [{first: 0, last: 0, top: 0, height: 300}, {first: 1, last: 1, top: 318, height: 470}], height: 788,
|
||||
});
|
||||
assert.deepEqual(layoutLibraryRows([], 2), {rows: [], height: 0});
|
||||
});
|
||||
|
||||
test('row measurements validate geometry and invalid heights reserve the configured estimate', async () => {
|
||||
const {layoutLibraryRows} = await source;
|
||||
assert.deepEqual(layoutLibraryRows([0, -1, NaN, Infinity, undefined, '300'], 2, {estimate: 448, gap: 0}).rows.map(row => row.height), [448, 448, 448]);
|
||||
for (const columns of [0, -1, 1.5, Infinity, NaN, '2']) assert.throws(() => layoutLibraryRows([], columns), TypeError);
|
||||
for (const estimate of [0, -1, NaN, Infinity]) assert.throws(() => layoutLibraryRows([], 2, {estimate}), TypeError);
|
||||
for (const gap of [-1, NaN, Infinity]) assert.throws(() => layoutLibraryRows([], 2, {gap}), TypeError);
|
||||
const before = layoutLibraryRows([300, 470, 600], 2);
|
||||
const appended = layoutLibraryRows([300, 470, 600, 250, 310], 2);
|
||||
assert.deepEqual(appended.rows[0], before.rows[0]);
|
||||
assert.deepEqual(appended.rows[1], {first: 2, last: 3, top: 488, height: 600});
|
||||
assert.equal(appended.height, 1416);
|
||||
});
|
||||
|
||||
test('measured windows add two overscan rows and pin distant focus in ascending DOM order', async () => {
|
||||
const {layoutLibraryRows, visibleLibraryRows} = await source;
|
||||
const {rows} = layoutLibraryRows(Array(40).fill(100), 2, {gap: 10});
|
||||
for (const row of rows) Object.freeze(row);
|
||||
Object.freeze(rows);
|
||||
assert.deepEqual(visibleLibraryRows(rows, 550, 100, 0), [0, 3, 4, 5, 6, 7]);
|
||||
assert.deepEqual(visibleLibraryRows(rows, 550, 100, 39), [3, 4, 5, 6, 7, 19]);
|
||||
assert.deepEqual(visibleLibraryRows(rows, 550, 100, 40), [3, 4, 5, 6, 7]);
|
||||
assert.deepEqual(visibleLibraryRows(rows, 550, 100, -1), [3, 4, 5, 6, 7]);
|
||||
assert.deepEqual(visibleLibraryRows([], 0, 900), []);
|
||||
assert.deepEqual(visibleLibraryRows(rows, NaN, Infinity), [0, 1, 2]);
|
||||
});
|
||||
|
||||
test('measured-window lookup binary searches a 50000-row shelf without walking it', async () => {
|
||||
const {visibleLibraryRows} = await source;
|
||||
let reads = 0;
|
||||
const rows = new Proxy({length: 50000}, {get(target, key) {
|
||||
if (/^\d+$/.test(String(key))) { reads++; const index = Number(key); return {first: index * 2, last: index * 2 + 1, top: index * 118, height: 100}; }
|
||||
return Reflect.get(target, key);
|
||||
}});
|
||||
assert.deepEqual(visibleLibraryRows(rows, 1180000, 100, 0), [0, 9998, 9999, 10000, 10001, 10002]);
|
||||
assert.ok(reads < 500, `${reads} numeric row reads`);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
const test=require('node:test');
|
||||
const assert=require('node:assert/strict');
|
||||
const source=import('../../web/js/workspace/library-state.mjs');
|
||||
const item=id=>({id,text:`Positive ${id}`,category:'Art',tags:['blue'],rating:3,variant_count:2,image_count:4,previews:[]});
|
||||
const page=(items,cursor=null)=>({items,total:3,has_more:cursor!==null,next_cursor:cursor});
|
||||
test('parent stream builds literal filters and deduplicates stable identities across infinite pages',async()=>{
|
||||
const {createLibraryStream}=await source;const calls=[];const pending=[page([item(1),item(2)],'next'),page([item(2),item(3)])];const s=createLibraryStream({api:{get:async(url)=>{calls.push(url);return pending.shift();}}});
|
||||
assert.equal(await s.reset({text:'a%_',category:'Art',tags:['blue','red'],sort:'rating'}),2);assert.equal(await s.loadMore(),1);assert.deepEqual(s.state.items.map(row=>row.id),[1,2,3]);assert.equal(s.state.hasMore,false);
|
||||
const query=new URL(calls[0],'https://x').searchParams;assert.equal(query.get('text'),'a%_');assert.deepEqual(query.getAll('tag'),['blue','red']);assert.equal(query.get('sort'),'rating');assert.equal(new URL(calls[1],'https://x').searchParams.get('cursor'),'next');assert.equal(await s.loadMore(),0);s.dispose();
|
||||
});
|
||||
test('reset aborts older requests and late success/failure cannot replace current parents',async()=>{
|
||||
const {createLibraryStream}=await source;const requests=[];const s=createLibraryStream({api:{get:(url,{signal})=>new Promise((resolve,reject)=>requests.push({signal,resolve,reject}))}});
|
||||
const first=s.reset({text:'first'});const second=s.reset({text:'second'});assert.equal(requests[0].signal.aborted,true);requests[1].resolve(page([item(2)]));await second;requests[0].resolve(page([item(1)]));await first;assert.deepEqual(s.state.items.map(row=>row.id),[2]);
|
||||
const third=s.reset();s.dispose();requests[2].reject(Error('late'));await third;assert.equal(await s.loadMore(),0);
|
||||
});
|
||||
test('failed pages retain cursor/data and can retry while duplicate load is guarded',async()=>{
|
||||
const {createLibraryStream}=await source;let calls=0,release;const s=createLibraryStream({api:{get:async()=>{calls++;if(calls===1)return page([item(1)],'next');if(calls===2)throw Error('offline');return new Promise(resolve=>release=resolve);}}});
|
||||
await s.reset();await s.loadMore();assert.equal(s.state.cursor,'next');assert.equal(s.state.items.length,1);assert.match(s.state.error.message,/offline/);const request=s.loadMore();assert.equal(await s.loadMore(),0);release(page([item(2)]));await request;assert.equal(s.state.error,null);assert.equal(s.state.items.length,2);s.dispose();
|
||||
});
|
||||
test('malformed/nonprogressing pages fail atomically and invalid filters never send requests',async()=>{
|
||||
const {createLibraryStream}=await source;
|
||||
for(const malformed of [{},page([{...item(1),id:-1}]),{...page([]),has_more:true},page([{...item(1),text:null}])]){const s=createLibraryStream({api:{get:async()=>malformed}});await s.reset();assert.ok(s.state.error);assert.equal(s.state.items.length,0);s.dispose();}
|
||||
const s=createLibraryStream({api:{get:async()=>page([],'same')}});await s.reset();await s.loadMore();assert.ok(s.state.error);s.dispose();
|
||||
const invalid=createLibraryStream({api:{get:()=>assert.fail()}});for(const filters of [{sort:'bad'},{text:2},{tags:'a'}])await assert.rejects(invalid.reset(filters));invalid.dispose();
|
||||
});
|
||||
test('library detail selection preserves full strings and distinguishes all, empty and unknown',async()=>{
|
||||
const {libraryPromptRoles}=await source;const detail={positive:item(1),variants:[{id:9,negative_text:'',explicit_empty:true},{id:10,negative_text:'long\nnegative',explicit_empty:false}]};
|
||||
const all=libraryPromptRoles(detail,null);assert.equal(all.positive.text,'Positive 1');assert.equal(all.negative.status,'unresolved');assert.equal(libraryPromptRoles(detail,9).negative.text,'');assert.equal(libraryPromptRoles(detail,10).negative.text,'long\nnegative');assert.throws(()=>libraryPromptRoles(detail,999));
|
||||
});
|
||||
test('detail reads validate identities, abort stale selection and expose retryable errors',async()=>{
|
||||
const {createLibraryDetail}=await source;const pending=[],states=[];const loader=createLibraryDetail({api:{get:(url,{signal})=>new Promise((resolve,reject)=>pending.push({url,signal,resolve,reject}))},onChange:state=>states.push(state)});
|
||||
const first=loader.load(1);const second=loader.load(2);assert.equal(pending[0].signal.aborted,true);pending[1].resolve({positive:item(2),variants:[],unassigned_image_count:0});await second;pending[0].resolve({positive:item(1),variants:[]});await first;assert.equal(states.at(-1).detail.positive.id,2);
|
||||
const third=loader.load(3);pending[2].resolve({positive:item(4),variants:[]});await third;assert.ok(states.at(-1).error);
|
||||
const fourth=loader.load(3);loader.dispose();const count=states.length;pending[3].reject(Error('late'));await fourth;assert.equal(states.length,count);await loader.load(5);assert.equal(pending.length,4);
|
||||
});
|
||||
test('restored shelves keep filters, loaded parents and continuation without a fresh first page',async()=>{
|
||||
const {createLibraryStream}=await source;const old=createLibraryStream({api:{get:async()=>page([item(1)],'next')}});await old.reset({text:'retained',sort:'name'});old.dispose();const urls=[];
|
||||
const next=createLibraryStream({api:{get:async url=>{urls.push(url);return page([item(2)]);}},restore:{filters:{text:'retained',sort:'name'},state:old.state}});assert.equal(next.state.items.length,1);await next.loadMore();assert.match(urls[0],/text=retained/);assert.match(urls[0],/cursor=next/);assert.equal(next.state.items.length,2);next.dispose();
|
||||
assert.throws(()=>createLibraryStream({api:{},restore:{filters:{},state:{items:[item(1),item(1)],total:2,cursor:null,hasMore:false}}}));
|
||||
});
|
||||
test('parent shelf window bounds DOM and pins focused rows without changing logical order',async()=>{
|
||||
const {libraryWindow}=await source;const first=libraryWindow(500,2,0,900);assert.equal(first[0],0);assert.ok(first.length<24);
|
||||
const middle=libraryWindow(500,2,50000,900,0);assert.equal(middle[0],0);assert.ok(middle.some(index=>index>150));assert.ok(middle.length<24);assert.deepEqual(middle,[...middle].sort((a,b)=>a-b));assert.deepEqual(libraryWindow(3,2,0,900),[0,1,2]);
|
||||
});
|
||||
test('uniform compatibility window handles huge counts without collection allocation',async()=>{
|
||||
const {libraryWindow}=await source;const original=Array.from;const lengths=[];
|
||||
Array.from=function(value,...args){lengths.push(value.length);assert.ok(!Number.isFinite(value.length)||value.length<32,'collection-sized allocation');return original.call(this,value,...args);};
|
||||
try {const indexes=libraryWindow(Number.MAX_SAFE_INTEGER,2,50000,900,0);assert.ok(indexes.length<32);assert.ok(indexes.includes(0));assert.ok(indexes.includes(2*Math.floor(50000/578)));assert.deepEqual(indexes,[...new Set(indexes)].sort((a,b)=>a-b));assert.ok(lengths.every(length=>length<32));}
|
||||
finally {Array.from=original;}
|
||||
});
|
||||
test('negative variants append continuously without losing selected identity, and failures retry same cursor',async()=>{
|
||||
const {createLibraryDetail}=await source;const calls=[];let count=0;const v=id=>({id,negative_text:id===9?'':'bad',explicit_empty:id===9});
|
||||
const loader=createLibraryDetail({api:{get:async url=>{calls.push(url);count++;if(count===2)throw Error('offline');return {positive:item(1),variants:count===1?[v(9)]:[v(9),v(10)],variant_total:2,variants_has_more:count===1,variants_next_cursor:count===1?'next':null,unassigned_image_count:0};}},onChange:()=>{}});
|
||||
await loader.load(1);assert.equal(loader.state.detail.variants.length,1);await loader.loadMore();assert.match(loader.state.variantsError.message,/offline/);assert.equal(loader.state.detail.variants[0].id,9);await loader.loadMore();assert.deepEqual(loader.state.detail.variants.map(row=>row.id),[9,10]);assert.match(calls[1],/variant_cursor=next/);assert.equal(calls[1],calls[2]);assert.equal(await loader.loadMore(),0);loader.dispose();
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createScope} from '../../web/js/shared/toolkit/core.js';
|
||||
import gallerySettings from '../../web/js/gallery-settings.js';
|
||||
|
||||
const module = await import('../../web/js/workspace/media-viewer.mjs').catch(error => {
|
||||
if (error.code !== 'ERR_MODULE_NOT_FOUND') throw error;
|
||||
return {};
|
||||
});
|
||||
test('disposed media viewer touches no DOM observers or requests', () => {
|
||||
assert.equal(typeof module.createMediaViewer,'function','media viewer contract is exported');
|
||||
const forbidden = new Proxy({}, {get() {assert.fail('Disposed viewer touched DOM or inspection');}});
|
||||
for (const aborted of [false,true]) {
|
||||
const parentScope = createScope(); if (aborted) parentScope.dispose();
|
||||
const view = module.createMediaViewer({container:forbidden,parentScope,inspectionController:forbidden,sender:forbidden,
|
||||
onSelect:() => assert.fail('Disposed viewer selected an entry'),onRefresh:() => assert.fail('Disposed viewer refreshed')});
|
||||
if (!aborted) view.dispose();
|
||||
view.open(forbidden); view.stopSlideshow?.(); view.dispose(); view.dispose(); parentScope.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('preferences handle denied malformed storage without mutation', () => {
|
||||
assert.equal(typeof module.readViewerPreferences,'function');
|
||||
const normalize=gallerySettings.normalizeGallerySettings,defaults={videoAutoplay:false,videoMute:true,videoLoop:true};
|
||||
for(const getStorage of [() => {throw new Error('Denied');},() => ({getItem:() => {throw new Error('Denied');}}),
|
||||
...['{broken','null','[]','42'].map(value=>() => ({getItem:key=>{assert.equal(key,'gallerySettings');return value;},setItem:()=>assert.fail('Read-only preferences')}))]) {
|
||||
assert.deepEqual(module.readViewerPreferences(getStorage,normalize),defaults);
|
||||
}
|
||||
let raw=JSON.stringify({videoAutoplay:true,videoMute:false,videoLoop:false,infiniteScroll:true});
|
||||
const storage={getItem:key=>{assert.equal(key,'gallerySettings');return raw;},setItem:()=>assert.fail('Read-only preferences')};
|
||||
assert.deepEqual(module.readViewerPreferences(()=>storage,normalize),{videoAutoplay:true,videoMute:false,videoLoop:false});
|
||||
raw=JSON.stringify({videoAutoplay:'true',videoMute:0,videoLoop:null});
|
||||
assert.deepEqual(module.readViewerPreferences(()=>storage,normalize),defaults);
|
||||
});
|
||||
|
||||
function mountedDom() {
|
||||
class Node {
|
||||
constructor(tag) {this.tagName=tag;this.children=[];this.dataset={};this.attributes={};this.listeners=new Map();this._text='';this.classList={add(){},remove(){}};}
|
||||
get textContent(){return this._text+this.children.map(c=>c.textContent).join('');}
|
||||
set textContent(value){this._text=String(value);this.children=[];}
|
||||
get childNodes(){return this.children;}
|
||||
get isConnected(){return this.root || !!this.parent?.isConnected;}
|
||||
append(...nodes){for(const node of nodes){node.parent=this;this.children.push(node);}}
|
||||
prepend(...nodes){for(const node of nodes)node.parent=this;this.children.unshift(...nodes);}
|
||||
replaceChildren(...nodes){for(const child of this.children)child.parent=null;this.children=[];this._text='';this.append(...nodes);}
|
||||
remove(){if(this.parent)this.parent.children=this.parent.children.filter(c=>c!==this);this.parent=null;}
|
||||
setAttribute(key,value){this.attributes[key]=String(value);}
|
||||
getAttribute(key){return this.attributes[key]??null;}
|
||||
removeAttribute(key){delete this.attributes[key];}
|
||||
addEventListener(type,callback,options={}){const list=this.listeners.get(type)||[];list.push({callback,signal:options.signal});this.listeners.set(type,list);}
|
||||
removeEventListener(type,callback){this.listeners.set(type,(this.listeners.get(type)||[]).filter(e=>e.callback!==callback));}
|
||||
fire(type,event={}){for(const e of [...(this.listeners.get(type)||[])])if(!e.signal?.aborted)e.callback({target:this,preventDefault(){},...event});}
|
||||
focus(){this.focused=true;}
|
||||
showModal(){this.open=true;}
|
||||
close(){this.open=false;this.fire('close');}
|
||||
contains(node){return node===this||this.children.some(c=>c.contains(node));}
|
||||
closest(selector){if(selector==='section'&&this.tagName==='section')return this;if(selector==='[data-send-role]'&&this.dataset.sendRole)return this;if(selector==='[data-copy-role]'&&this.dataset.copyRole)return this;return this.parent?.closest(selector)||null;}
|
||||
querySelector(selector){return walk(this,n=>n.tagName===selector)[0]||null;}
|
||||
pause(){this.paused=true;}
|
||||
load(){this.loaded=true;}
|
||||
play(){this.played=true;return Promise.resolve();}
|
||||
}
|
||||
const walk=(node,predicate)=>[...(predicate(node)?[node]:[]),...node.children.flatMap(child=>walk(child,predicate))];
|
||||
const oldDocument=globalThis.document,oldWindow=globalThis.window;
|
||||
const container=new Node('main');container.root=true;
|
||||
const doc=new Node('document');doc.createElement=tag=>new Node(tag);doc.createElementNS=(_,tag)=>new Node(tag);
|
||||
doc.querySelectorAll=()=>walk(container,n=>n.tagName==='dialog'&&n.open);doc.activeElement=new Node('button');doc.activeElement.root=true;
|
||||
const win=new Node('window');win.location={href:'http://localhost/prompt_manager/workspace'};win.getSelection=()=>({isCollapsed:true});
|
||||
globalThis.document=doc;globalThis.window=win;
|
||||
return {container,doc,win,walk,Node,restore(){globalThis.document=oldDocument;globalThis.window=oldWindow;}};
|
||||
}
|
||||
function occurrenceMedia(node,ordinal,key) {
|
||||
const run='22222222-2222-2222-2222-222222222222',epoch='11111111-1111-1111-1111-111111111111',scope='a'.repeat(64);
|
||||
const query=new URLSearchParams({node,ordinal:String(ordinal),epoch,current_scope:scope}),base=`/prompt_manager/history/runs/${run}/output/current`;
|
||||
return {kind:'reported-location',location_key:key.repeat(64),history_epoch:epoch,current_scope:scope,occurrence:{run_id:run,output_node_id:node,image_ordinal:ordinal},reference:{folder_type:'output',subfolder:'',filename:'a.png',path_style:'posix',root_key:'b'.repeat(64)},filename:'a.png',is_video:false,url:`${base}/media?${query}`,thumbnail_url:`${base}/thumbnail?${query}`,inspection_url:`${base}/inspection?${query}`};
|
||||
}
|
||||
function borrowedController() {
|
||||
const listeners=new Set();let disposed=0;
|
||||
return {get state(){return {loading:false,error:false,record:null};},subscribe(fn){listeners.add(fn);fn(this.state);return ()=>listeners.delete(fn);},publish(state){for(const fn of listeners)fn(state);},retry(){},dispose(){disposed++;},get disposed(){return disposed;},get listeners(){return listeners.size;}};
|
||||
}
|
||||
|
||||
test('occurrence Viewer retains exact caller selection and synchronously closes without disposing borrowed controller',async()=>{
|
||||
const {reportedLocationAdapter:adapter}=await import('../../web/js/workspace/history-media.mjs');assert.ok(adapter);
|
||||
const f=mountedDom(),parentScope=createScope(),controller=borrowedController(),selections=[],closes=[],items=[occurrenceMedia('1',1,'c'),occurrenceMedia('2',0,'d')];
|
||||
const trigger=f.doc.activeElement;
|
||||
const view=module.createMediaViewer({container:f.container,parentScope,inspectionController:controller,adapter,sender:{open(){}},onSelect:entry=>selections.push(entry.occurrence),onClose(...args){assert.equal(f.walk(f.container,n=>n.tagName==='dialog').length,0);assert.equal(controller.listeners,0);assert.equal(trigger.focused,true);closes.push(args);}});
|
||||
try {
|
||||
view.open({items,selectedKey:'c'.repeat(64),trigger});assert.deepEqual(selections,[{run_id:items[0].occurrence.run_id,output_node_id:'1',image_ordinal:1}]);
|
||||
const media=f.walk(f.container,n=>n.tagName==='img')[0];assert.match(media.src,/\/history\/runs\/.*\/output\/current\/media\?/);assert.match(media.src,/_pm_viewer=/);assert.doesNotMatch(media.src,/root=/);
|
||||
const download=f.walk(f.container,n=>n.attributes['aria-label']==='Download original')[0];assert.equal(download.href,items[0].url);
|
||||
f.walk(f.container,n=>n.attributes['aria-label']==='Next media')[0].fire('click');assert.equal(selections.at(-1).output_node_id,'2');
|
||||
f.walk(f.container,n=>n.attributes['aria-label']==='Close preview')[0].fire('click');assert.deepEqual(closes,[[]]);assert.equal(controller.disposed,0);
|
||||
const count=selections.length;media.fire('load');controller.publish({loading:true});assert.equal(selections.length,count);
|
||||
view.dispose();view.open({items,selectedKey:'c'.repeat(64)});assert.equal(f.container.children.length,0);assert.equal(controller.disposed,0);
|
||||
} finally {view.dispose();parentScope.dispose();f.restore();}
|
||||
});
|
||||
|
||||
test('slideshow stops for detail reading nested send close and parent disposal with late media callbacks ignored',async()=>{
|
||||
const {reportedLocationAdapter:adapter}=await import('../../web/js/workspace/history-media.mjs');const f=mountedDom(),parentScope=createScope(),controller=borrowedController(),items=[occurrenceMedia('1',1,'c'),occurrenceMedia('2',0,'d')];let closes=0,sends=0;
|
||||
const view=module.createMediaViewer({container:f.container,parentScope,inspectionController:controller,adapter,onClose:()=>closes++,sender:{open(){sends++;}}});
|
||||
try {
|
||||
view.open({items,selectedKey:'c'.repeat(64)});
|
||||
const find=label=>f.walk(f.container,n=>n.attributes['aria-label']===label)[0];
|
||||
let media=f.walk(f.container,n=>n.tagName==='img')[0];media.fire('load');find('Play slideshow').fire('click');assert.ok(find('Pause slideshow'));
|
||||
f.walk(f.container,n=>n.tagName==='aside')[0].fire('pointerdown');assert.ok(find('Play slideshow'),'explicit detail reading ends slideshow intent');
|
||||
const record={identity:{kind:'reported-location',key:'c'.repeat(64),path:'a.png',occurrence:items[0].occurrence,reference:items[0].reference,history_epoch:items[0].history_epoch,current_scope:items[0].current_scope},file:null,library:null,metadata:null,generation:{},prompts:{positive:{status:'resolved',text:'current'},negative:{status:'resolved',text:''}},download:{url:items[0].url,filename:'a.png'}};
|
||||
controller.publish({loading:false,error:false,record});assert.ok(f.walk(f.container,n=>n.textContent==='Current file at the reported location · identity unverified').length);
|
||||
find('Play slideshow').fire('click');const pair=f.walk(f.container,n=>n.dataset.sendRole==='pair')[0];pair.fire('click');assert.equal(sends,1);assert.ok(find('Play slideshow'));
|
||||
find('Play slideshow').fire('click');find('Close preview').fire('click');assert.equal(closes,1);assert.equal(controller.listeners,0);media.fire('load');
|
||||
view.open({items,selectedKey:'c'.repeat(64)});media=f.walk(f.container,n=>n.tagName==='img')[0];parentScope.dispose();assert.equal(closes,1,'parent disposal suppresses onClose');assert.equal(f.container.children.length,0);media.fire('load');assert.equal(controller.disposed,0);
|
||||
} finally {view.dispose();parentScope.dispose();f.restore();}
|
||||
});
|
||||
|
||||
function currentInspectionEnvelope(target) {
|
||||
return {success:true,inspection_version:1,history_version:1,history_epoch:target.history_epoch,
|
||||
current_output:{occurrence:target.occurrence,reference:target.reference,history_epoch:target.history_epoch,current_scope:target.current_scope,location_key:target.location_key,evidence_class:'locator_candidate',observation:'current-file',file_identity:'unverified',newly_generated:'unknown',context_membership:'unknown',transient:false},
|
||||
file:{observation:'current-file',source_label:'output',media_type:'image',extension:'.png',format:'PNG',size:10,width:2,height:3,modified_time:1},
|
||||
prompts:{positive:{status:'resolved',text:'current positive',source:'execution'},negative:{status:'resolved',text:'',source:'execution'}},
|
||||
metadata:{status:'absent',omitted_reason:null,raw:{},record_formats:{},prompt_graph:{status:'absent',value:null},workflow_graph:{status:'absent',value:null},parameters:null},
|
||||
resolution:{state:'absent',sampler_id:null,reason:'no graph'},library:{status:'none',scope:'saved-library',positive_id:null,pair_id:null,title:null,category:null,tags:[],metadata_scope:null}};
|
||||
}
|
||||
|
||||
test('real inspection navigation preserves slideshow across two advances and deferred metadata loads',async()=>{
|
||||
const {reportedLocationAdapter:adapter}=await import('../../web/js/workspace/history-media.mjs');
|
||||
const {createInspectionController}=await import('../../web/js/workspace/inspection-state.mjs');
|
||||
const {extractGenerationFacts}=(await import('../../web/js/comfy-metadata.js')).default;
|
||||
const f=mountedDom(),parentScope=createScope(),calls=[],reads=[],selected=[],timers=new Map();let serial=0,closed=0,sent=0;
|
||||
const oldSetTimeout=globalThis.setTimeout,oldClearTimeout=globalThis.clearTimeout;
|
||||
globalThis.setTimeout=(callback,delay)=>{assert.equal(delay,3500);timers.set(++serial,callback);return serial;};
|
||||
globalThis.clearTimeout=token=>timers.delete(token);
|
||||
const controller=createInspectionController({adapter,extractFacts:extractGenerationFacts,api:{get:(url,{signal})=>new Promise((resolve,reject)=>calls.push({url,signal,resolve,reject}))}});
|
||||
const items=[occurrenceMedia('1',1,'c'),occurrenceMedia('2',0,'d'),occurrenceMedia('3',0,'e')];
|
||||
const view=module.createMediaViewer({container:f.container,parentScope,inspectionController:controller,adapter,
|
||||
onSelect(entry){selected.push(entry.occurrence);reads.push(controller.select(entry));},onClose(){closed++;},sender:{open(){sent++;}}});
|
||||
const find=label=>f.walk(f.container,n=>n.attributes['aria-label']===label)[0];
|
||||
const image=()=>f.walk(f.container,n=>n.tagName==='img')[0];
|
||||
const advance=()=>{assert.equal(timers.size,1);const [token,callback]=timers.entries().next().value;timers.delete(token);callback();};
|
||||
try {
|
||||
view.open({items,selectedKey:items[0].location_key});assert.equal(controller.state.loading,true);
|
||||
calls[0].resolve(currentInspectionEnvelope(items[0]));await reads[0];image().fire('load');find('Play slideshow').fire('click');
|
||||
advance();assert.equal(controller.state.loading,true);assert.ok(find('Pause slideshow'),'automatic next-file inspection preserves intent');
|
||||
assert.deepEqual(selected.map(o=>o.output_node_id),['1','2']);assert.equal(timers.size,0,'readiness still gates the next advance');
|
||||
image().fire('load');advance();assert.equal(controller.state.loading,true);assert.ok(find('Pause slideshow'));
|
||||
assert.deepEqual(selected.map(o=>o.output_node_id),['1','2','3']);assert.equal(calls[1].signal.aborted,true);
|
||||
image().fire('load');assert.equal(timers.size,1);calls[1].reject(new Error('late superseded inspection'));await reads[1];assert.ok(find('Pause slideshow'));
|
||||
calls[2].resolve(currentInspectionEnvelope(items[2]));await reads[2];assert.ok(find('Pause slideshow'));assert.equal(timers.size,1);
|
||||
f.walk(f.container,n=>n.tagName==='aside')[0].fire('keydown');assert.ok(find('Play slideshow'));assert.equal(timers.size,0);
|
||||
find('Play slideshow').fire('click');f.walk(f.container,n=>n.dataset.sendRole==='pair')[0].fire('click');assert.equal(sent,1);assert.ok(find('Play slideshow'));assert.equal(timers.size,0);
|
||||
find('Play slideshow').fire('click');const retry=controller.retry();calls.at(-1).reject(new Error('inspection read failed'));await retry;
|
||||
assert.ok(find('Play slideshow'),'inspection errors stop intent');assert.equal(timers.size,0);
|
||||
const retryButton=f.walk(f.container,n=>n.textContent==='Retry prompt details')[0];assert.ok(retryButton);
|
||||
find('Play slideshow').fire('click');f.walk(f.container,n=>n.tagName==='aside')[0].fire('click',{target:retryButton});retryButton.fire('click');
|
||||
assert.ok(find('Play slideshow'),'explicit retry interaction stops intent');assert.equal(controller.state.loading,true);assert.equal(timers.size,0);
|
||||
calls.at(-1).resolve(currentInspectionEnvelope(items[2]));await new Promise(resolve=>setImmediate(resolve));
|
||||
find('Play slideshow').fire('click');const lateTimer=[...timers.values()][0];find('Close preview').fire('click');assert.equal(closed,1);assert.equal(timers.size,0);
|
||||
lateTimer();assert.equal(selected.length,3);assert.equal(controller.state.record.identity.occurrence.output_node_id,'3','closing keeps the borrowed controller live');
|
||||
view.open({items,selectedKey:items[2].location_key});const lateMedia=image();lateMedia.fire('load');find('Play slideshow').fire('click');parentScope.dispose();assert.equal(timers.size,0);assert.equal(closed,1);lateMedia.fire('load');
|
||||
assert.equal(f.container.children.length,0);assert.equal(controller.state.record.identity.occurrence.output_node_id,'3');
|
||||
} finally {view.dispose();controller.dispose();parentScope.dispose();globalThis.setTimeout=oldSetTimeout;globalThis.clearTimeout=oldClearTimeout;f.restore();}
|
||||
});
|
||||
@@ -8,7 +8,7 @@ test('workspace routes resolve only supported deep links', async () => {
|
||||
assert.equal(resolveRoute(`#/${route}`).id, route);
|
||||
}
|
||||
for (const hash of ['', '#', '#/unknown', '#/images/other', '#/<script>']) {
|
||||
assert.equal(resolveRoute(hash).id, 'overview');
|
||||
assert.equal(resolveRoute(hash).id, 'images');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -35,15 +35,29 @@ test('router normalizes unknown routes, avoids duplicate renders, and disposes i
|
||||
}};
|
||||
const seen = [];
|
||||
const dispose = mountRouter(win, route => seen.push(route.id));
|
||||
assert.deepEqual(seen, ['overview']);
|
||||
assert.deepEqual(replacements, [[{keep:1}, '#/overview']]);
|
||||
win.location.hash = '#/images';
|
||||
assert.deepEqual(seen, ['images']);
|
||||
assert.deepEqual(replacements, [[{keep:1}, '#/images']]);
|
||||
win.location.hash = '#/library';
|
||||
win.dispatchEvent(new Event('hashchange'));
|
||||
win.dispatchEvent(new Event('hashchange'));
|
||||
assert.deepEqual(seen, ['overview', 'images']);
|
||||
assert.deepEqual(seen, ['images', 'library']);
|
||||
dispose();
|
||||
dispose();
|
||||
win.location.hash = '#/system';
|
||||
win.dispatchEvent(new Event('hashchange'));
|
||||
assert.deepEqual(seen, ['overview', 'images']);
|
||||
assert.deepEqual(seen, ['images', 'library']);
|
||||
});
|
||||
|
||||
test('positive library deep links preserve the selected identity without duplicate route renders', async () => {
|
||||
const {resolveRoute,mountRouter}=await source;
|
||||
assert.equal(resolveRoute('#/library/12').positiveId,12);
|
||||
for(const hash of ['#/library/0','#/library/-1','#/library/1.5','#/library/9007199254740992','#/library/1/extra']) assert.equal(resolveRoute(hash).id,'images');
|
||||
const win=new EventTarget();win.location={hash:'#/library/12'};
|
||||
win.history={state:null,replaceState(state,unused,hash){win.location.hash=hash;}};
|
||||
const seen=[];const dispose=mountRouter(win,route=>seen.push([route.id,route.positiveId]));
|
||||
assert.equal(win.location.hash,'#/library/12');
|
||||
win.dispatchEvent(new Event('hashchange'));assert.equal(seen.length,1);
|
||||
win.location.hash='#/library/13';win.dispatchEvent(new Event('hashchange'));
|
||||
win.location.hash='#/library';win.dispatchEvent(new Event('hashchange'));
|
||||
assert.deepEqual(seen,[['library',12],['library',13],['library',undefined]]);dispose();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const module = await import('../../web/js/workspace/viewer-state.mjs').catch(error => {
|
||||
if (error.code !== 'ERR_MODULE_NOT_FOUND') throw error;
|
||||
return {};
|
||||
});
|
||||
function session(options) {
|
||||
assert.equal(typeof module.createViewerSession, 'function', 'viewer session contract is exported');
|
||||
return module.createViewerSession(options);
|
||||
}
|
||||
const entries = () => Array.from({length: 161}, (_, index) => ({root_index: index % 2,
|
||||
path: `folder/${index}.png`, filename: `${index}.png`, root_scope: 'a'.repeat(64),
|
||||
thumbnail_url: `/prompt_manager/images/serve/thumbnails/${index}.png?root=${index % 2}&v=17&root_scope=${'a'.repeat(64)}`,
|
||||
url: `/prompt_manager/images/serve/folder/${index}.png?root=${index % 2}&root_scope=${'a'.repeat(64)}`}));
|
||||
|
||||
test('viewer freezes loaded identities and wraps across unmounted entries', () => {
|
||||
const items = entries(), updates = [];
|
||||
const view = session({items, initialKey: '0:folder/0.png', onChange: (state, entry) => updates.push({state, entry})});
|
||||
items[160].url = '/wrong-root'; items[160].thumbnail_url = '/wrong-version?v=18';items[160].root_scope = 'b'.repeat(64); items.reverse(); items.push(items[0]);
|
||||
view.navigate(-1);
|
||||
assert.deepEqual(view.state, {key:'0:folder/160.png',index:160,total:161,phase:'stopped',requested:false,mediaReady:false});
|
||||
assert.equal(updates.at(-1).entry.url, `/prompt_manager/images/serve/folder/160.png?root=0&root_scope=${'a'.repeat(64)}`);
|
||||
assert.equal(updates.at(-1).entry.root_scope, 'a'.repeat(64));
|
||||
assert.equal(updates.at(-1).entry.thumbnail_url, `/prompt_manager/images/serve/thumbnails/160.png?root=0&v=17&root_scope=${'a'.repeat(64)}`);
|
||||
assert.ok(Object.isFrozen(updates.at(-1).entry)); assert.ok(Object.isFrozen(view.state));
|
||||
view.navigate(1); assert.equal(view.state.key, '0:folder/0.png');
|
||||
view.navigate(1); assert.equal(view.state.key, '1:folder/1.png');
|
||||
view.dispose();
|
||||
});
|
||||
|
||||
test('viewer rejects empty duplicate or unknown identity', () => {
|
||||
for (const [items, initialKey] of [[[], '0:a.png'], [[{path:'a.png'}], 'undefined:a.png'],
|
||||
[[{root_index:0,path:''}], '0:'], [[{root_index:0,path:'a.png'},{root_index:0,path:'a.png'}], '0:a.png'],
|
||||
[entries(), '0:missing.png']]) assert.throws(() => session({items,initialKey}), TypeError);
|
||||
const view = session({items:entries(),initialKey:'0:folder/0.png'});
|
||||
for (const direction of [0,2,-2,'1',null]) assert.throws(() => view.navigate(direction), TypeError);
|
||||
assert.equal(view.state.index, 0); view.dispose();
|
||||
});
|
||||
|
||||
test('viewer ignores stale readiness after navigation and disposal', () => {
|
||||
let calls = 0;
|
||||
const view = session({items:entries(),initialKey:'0:folder/0.png',onChange:() => calls++,
|
||||
setTimer:() => assert.fail('Task4 must not start a timer'),clearTimer:() => assert.fail('No timer exists')});
|
||||
view.ready('0:folder/0.png'); assert.equal(view.state.mediaReady,true);
|
||||
view.navigate(1); view.failed('0:folder/0.png'); view.ready('0:folder/0.png');
|
||||
assert.equal(view.state.mediaReady,false); assert.equal(view.state.phase,'stopped');
|
||||
view.failed('1:folder/1.png'); assert.equal(view.state.phase,'error');
|
||||
view.ready('1:folder/1.png'); assert.equal(view.state.phase,'stopped');
|
||||
view.dispose(); const after = calls;
|
||||
view.ready('1:folder/1.png'); view.failed('1:folder/1.png'); view.navigate(1); view.dispose();
|
||||
assert.equal(calls,after); assert.equal(view.state.phase,'disposed'); assert.equal(view.state.requested,false);
|
||||
});
|
||||
|
||||
function timedSession(items = entries().slice(0,3)) {
|
||||
let now = 0, serial = 0;
|
||||
const pending = new Map(), callbacks = [];
|
||||
const view = session({items,initialKey:`${items[0].root_index}:${items[0].path}`,
|
||||
setTimer: (callback, delay) => {const id=++serial;pending.set(id,{at:now+delay,callback});callbacks.push(callback);return id;},
|
||||
clearTimer: id => pending.delete(id)});
|
||||
const tick = ms => {now+=ms;for(const [id,timer] of [...pending])if(timer.at<=now){pending.delete(id);timer.callback();}};
|
||||
return {view,pending,callbacks,tick};
|
||||
}
|
||||
|
||||
test('slideshow schedules once only after current image ready', () => {
|
||||
const {view,pending,tick}=timedSession();
|
||||
view.play?.();assert.equal(view.state.phase,'waiting-image');assert.equal(view.state.requested,true);assert.equal(pending.size,0);
|
||||
view.ready(view.state.key);assert.equal(view.state.phase,'playing-image');assert.equal(pending.size,1);
|
||||
view.ready(view.state.key);view.play();assert.equal(pending.size,1);
|
||||
tick(3499);assert.equal(view.state.index,0);tick(1);assert.equal(view.state.index,1);
|
||||
assert.equal(view.state.phase,'waiting-image');assert.equal(pending.size,0);
|
||||
view.ready(view.state.key);assert.equal(pending.size,1);view.dispose();assert.equal(pending.size,0);
|
||||
});
|
||||
|
||||
test('slideshow pauses at video and resumes retained intent only after manual image navigation', () => {
|
||||
const items=entries().slice(0,3);items[1].is_video=true;
|
||||
const {view,pending,tick}=timedSession(items);
|
||||
view.ready(view.state.key);view.play?.();tick(3500);
|
||||
assert.equal(view.state.index,1);assert.equal(view.state.phase,'paused-video');assert.equal(view.state.requested,true);assert.equal(pending.size,0);
|
||||
view.ready(view.state.key);tick(7000);assert.equal(view.state.index,1);assert.equal(view.state.phase,'paused-video');
|
||||
view.navigate(1);assert.equal(view.state.phase,'waiting-image');view.ready(view.state.key);assert.equal(view.state.phase,'playing-image');
|
||||
view.navigate(-1);view.stop();view.ready(view.state.key);view.navigate(1);view.ready(view.state.key);
|
||||
assert.equal(view.state.phase,'stopped');assert.equal(pending.size,0);view.dispose();
|
||||
});
|
||||
|
||||
test('stop cancels waiting and paused intent despite late callbacks', () => {
|
||||
const {view,pending,callbacks}=timedSession();view.play?.();view.stop?.();view.ready(view.state.key);
|
||||
assert.equal(view.state.requested,false);assert.equal(pending.size,0);
|
||||
view.play?.();assert.equal(pending.size,1);const late=callbacks.at(-1);
|
||||
view.stop();late();view.ready(view.state.key);assert.equal(view.state.index,0);assert.equal(view.state.phase,'stopped');
|
||||
view.play();view.navigate(1);late();assert.equal(view.state.index,1);assert.equal(view.state.phase,'waiting-image');
|
||||
view.ready(view.state.key);assert.equal(pending.size,1);view.dispose();
|
||||
});
|
||||
|
||||
test('hidden interaction error and disposal never resume', () => {
|
||||
const {view,pending,callbacks}=timedSession();view.ready(view.state.key);view.play?.();
|
||||
assert.equal(pending.size,1);view.stop();for(const callback of callbacks)callback();view.ready(view.state.key);
|
||||
assert.equal(view.state.phase,'stopped');view.play();view.failed(view.state.key);view.ready(view.state.key);
|
||||
assert.equal(view.state.phase,'stopped');assert.equal(view.state.requested,false);assert.equal(pending.size,0);
|
||||
view.play();view.dispose();const disposed=view.state;
|
||||
for(const callback of callbacks)callback();view.play();view.stop();view.ready(view.state.key);view.failed(view.state.key);view.navigate(1);view.dispose();
|
||||
assert.equal(view.state,disposed);assert.equal(pending.size,0);
|
||||
});
|
||||
|
||||
test('slideshow cannot start on video one entry or media error', () => {
|
||||
for(const items of [[entries()[0]],[{...entries()[0],is_video:true},entries()[1]]]) {
|
||||
const {view,pending}=timedSession(items);view.play?.();assert.equal(view.state.requested,false);assert.equal(pending.size,0);view.dispose();
|
||||
}
|
||||
const {view,pending}=timedSession();view.failed(view.state.key);view.play?.();assert.equal(view.state.phase,'error');assert.equal(pending.size,0);view.dispose();
|
||||
});
|
||||
|
||||
test('explicit occurrence session retains video pause and timer invalidation with location keys',async()=>{
|
||||
const history=await import('../../web/js/workspace/history-media.mjs');
|
||||
const run='22222222-2222-2222-2222-222222222222',epoch='11111111-1111-1111-1111-111111111111',scope='a'.repeat(64);
|
||||
const items=['c','d'].map((key,index)=>{const query=new URLSearchParams({node:String(index),ordinal:'0',epoch,current_scope:scope}),base=`/prompt_manager/history/runs/${run}/output/current`;return {kind:'reported-location',occurrence:{run_id:run,output_node_id:String(index),image_ordinal:0},reference:{folder_type:'output',subfolder:'',filename:'a.png',path_style:'posix',root_key:'b'.repeat(64)},history_epoch:epoch,current_scope:scope,location_key:key.repeat(64),filename:'a.png',is_video:index===1,url:`${base}/media?${query}`,thumbnail_url:`${base}/thumbnail?${query}`,inspection_url:`${base}/inspection?${query}`};});
|
||||
const timers=[],pending=new Set();let callbacks=0;
|
||||
const view=session({adapter:history.reportedLocationAdapter,items,initialKey:'c'.repeat(64),onChange:()=>callbacks++,setTimer:fn=>{timers.push(fn);pending.add(fn);return fn;},clearTimer:fn=>pending.delete(fn)});
|
||||
view.play();view.ready('c'.repeat(64));assert.equal(pending.size,1);timers[0]();assert.equal(view.state.key,'d'.repeat(64));assert.equal(view.state.phase,'paused-video');view.stop();view.navigate(-1);view.ready('c'.repeat(64));assert.equal(view.state.requested,false);view.dispose();const count=callbacks;timers[0]();view.ready('d'.repeat(64));assert.equal(callbacks,count);
|
||||
});
|
||||
@@ -13,7 +13,7 @@ import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
_mock_server = MagicMock()
|
||||
_mock_server.PromptServer.instance.routes = MagicMock()
|
||||
@@ -1425,6 +1425,78 @@ class TestBackupRestoreContract(AdminAPITestCase):
|
||||
conn.execute("SELECT COUNT(*) FROM prompts").fetchone()[0], 1
|
||||
)
|
||||
|
||||
async def test_incompatible_wal_restore_returns_failure_without_publication(
|
||||
self,
|
||||
) -> None:
|
||||
import sqlite3
|
||||
from contextlib import closing
|
||||
|
||||
from database.execution_history import subscribe_history_restore
|
||||
|
||||
self.api.db.save_prompt(text="keep live", prompt_hash="live")
|
||||
previous = self.api.db
|
||||
live = previous.model.get_connection()
|
||||
epoch = previous.get_execution_epoch()
|
||||
self.assertEqual(live.execute("PRAGMA page_size").fetchone()[0], 4096)
|
||||
self.assertEqual(live.execute("PRAGMA journal_mode").fetchone()[0], "wal")
|
||||
compatible = self._snapshot_upload()
|
||||
source_path = Path(self.tmpdir) / "large-pages.db"
|
||||
with closing(sqlite3.connect(source_path)) as conn:
|
||||
conn.execute("PRAGMA page_size=8192")
|
||||
conn.execute("VACUUM")
|
||||
source = PromptDatabase(str(source_path))
|
||||
try:
|
||||
source.save_prompt(text="do not publish", prompt_hash="source")
|
||||
self.assertEqual(
|
||||
source.model.get_connection().execute("PRAGMA page_size").fetchone()[0],
|
||||
8192,
|
||||
)
|
||||
finally:
|
||||
source.close_all()
|
||||
upload = source_path.read_bytes()
|
||||
notices = []
|
||||
self.addCleanup(
|
||||
subscribe_history_restore(
|
||||
previous.model.db_path, lambda: notices.append(True)
|
||||
)
|
||||
)
|
||||
|
||||
resp = await self._restore("database_file", upload)
|
||||
|
||||
body = await resp.text()
|
||||
self.assertEqual(resp.status, 500, body)
|
||||
data = json.loads(body)
|
||||
self.assertFalse(data["success"])
|
||||
self.assertIn("Failed to restore database", data["error"])
|
||||
self.assertLess(len(data["error"]), 300)
|
||||
self.assertNotIn(self.tmpdir, body)
|
||||
self.assertNotIn("/tmp/", body)
|
||||
self.assertNotIn("backup_created", data)
|
||||
self.assertIs(self.api.db, previous)
|
||||
self.assertEqual(previous.get_execution_epoch(), epoch)
|
||||
self.assertEqual(notices, [])
|
||||
self.assertEqual(
|
||||
live.execute("SELECT text FROM prompts").fetchone()[0], "keep live"
|
||||
)
|
||||
previous.save_prompt(text="still writable", prompt_hash="after")
|
||||
self.assertEqual(source_path.read_bytes(), upload)
|
||||
backups = list(Path(self.tmpdir).glob("prompts.db.backup_*"))
|
||||
self.assertEqual(len(backups), 1)
|
||||
with closing(sqlite3.connect(backups[0])) as conn:
|
||||
self.assertEqual(
|
||||
conn.execute("SELECT text FROM prompts").fetchone()[0], "keep live"
|
||||
)
|
||||
self.assertEqual(conn.execute("PRAGMA integrity_check").fetchone()[0], "ok")
|
||||
|
||||
retry = await self._restore("database_file", compatible)
|
||||
self.assertEqual(retry.status, 200, await retry.text())
|
||||
self.assertTrue((await retry.json())["success"])
|
||||
self.assertEqual(notices, [True])
|
||||
self.assertNotEqual(self.api.db.get_execution_epoch(), epoch)
|
||||
self.assertEqual(
|
||||
[row["text"] for row in self.api.db.search_prompts()], ["keep live"]
|
||||
)
|
||||
|
||||
async def test_restore_backup_created_is_a_public_path(self):
|
||||
self.api.db.save_prompt(text="restored", prompt_hash="h1")
|
||||
resp = await self._restore("database_file", self._snapshot_upload())
|
||||
@@ -1434,6 +1506,159 @@ class TestBackupRestoreContract(AdminAPITestCase):
|
||||
data = json.loads(body)
|
||||
self.assertTrue(data["backup_created"].startswith("prompts.db.backup_"))
|
||||
|
||||
def _retention_upload(self) -> bytes:
|
||||
import sqlite3
|
||||
from contextlib import closing
|
||||
|
||||
path = Path(self.tmpdir) / "retention-source.db"
|
||||
with closing(sqlite3.connect(path)) as conn:
|
||||
conn.execute("PRAGMA page_size=8192")
|
||||
conn.execute("VACUUM")
|
||||
source = PromptDatabase(str(path))
|
||||
try:
|
||||
source.save_prompt(text="do not publish", prompt_hash="source")
|
||||
self.assertEqual(
|
||||
source.model.get_connection().execute("PRAGMA page_size").fetchone()[0],
|
||||
8192,
|
||||
)
|
||||
finally:
|
||||
source.close_all()
|
||||
return path.read_bytes()
|
||||
|
||||
def _seed_retention_backups(self, count: int) -> list[Path]:
|
||||
snapshot = self._snapshot_upload()
|
||||
paths = []
|
||||
for index in range(count):
|
||||
path = Path(
|
||||
f"{self.api.db.model.db_path}.backup_202001{index + 1:02d}_000000"
|
||||
)
|
||||
path.write_bytes(snapshot)
|
||||
os.utime(path, (1_000_000 + index, 1_000_000 + index))
|
||||
paths.append(path)
|
||||
return paths
|
||||
|
||||
async def test_refused_restore_retains_ten_backups_and_new_recovery_copy(
|
||||
self,
|
||||
) -> None:
|
||||
import sqlite3
|
||||
from contextlib import closing
|
||||
|
||||
from database.execution_history import subscribe_history_restore
|
||||
|
||||
previous = self.api.db
|
||||
previous.save_prompt(text="keep live", prompt_hash="live")
|
||||
live = previous.model.get_connection()
|
||||
self.assertEqual(live.execute("PRAGMA page_size").fetchone()[0], 4096)
|
||||
self.assertEqual(live.execute("PRAGMA journal_mode").fetchone()[0], "wal")
|
||||
epoch = previous.get_execution_epoch()
|
||||
old_backups = self._seed_retention_backups(10)
|
||||
upload = self._retention_upload()
|
||||
notices = []
|
||||
self.addCleanup(
|
||||
subscribe_history_restore(
|
||||
previous.model.db_path, lambda: notices.append(True)
|
||||
)
|
||||
)
|
||||
|
||||
response = await self._restore("database_file", upload)
|
||||
|
||||
data = await response.json()
|
||||
self.assertEqual(response.status, 500, data)
|
||||
self.assertFalse(data["success"])
|
||||
self.assertNotIn(self.tmpdir, data["error"])
|
||||
remaining = set(Path(self.tmpdir).glob("prompts.db.backup_*"))
|
||||
self.assertEqual(len(remaining), 10)
|
||||
self.assertEqual(remaining.intersection(old_backups), set(old_backups[1:]))
|
||||
new_backups = remaining.difference(old_backups)
|
||||
self.assertEqual(len(new_backups), 1)
|
||||
with closing(sqlite3.connect(new_backups.pop())) as conn:
|
||||
self.assertEqual(
|
||||
conn.execute("SELECT text FROM prompts").fetchone()[0], "keep live"
|
||||
)
|
||||
self.assertEqual(conn.execute("PRAGMA integrity_check").fetchone()[0], "ok")
|
||||
self.assertIs(self.api.db, previous)
|
||||
self.assertEqual(previous.get_execution_epoch(), epoch)
|
||||
self.assertEqual(notices, [])
|
||||
self.assertEqual(
|
||||
live.execute("SELECT text FROM prompts").fetchone()[0], "keep live"
|
||||
)
|
||||
previous.save_prompt(text="still writes", prompt_hash="after")
|
||||
self.assertEqual(
|
||||
(Path(self.tmpdir) / "retention-source.db").read_bytes(), upload
|
||||
)
|
||||
|
||||
async def test_prebackup_restore_failures_leave_existing_backups_untouched(
|
||||
self,
|
||||
) -> None:
|
||||
import sqlite3
|
||||
|
||||
self.api.db.save_prompt(text="keep live", prompt_hash="live")
|
||||
old_backups = self._seed_retention_backups(11)
|
||||
original = {path: path.read_bytes() for path in old_backups}
|
||||
epoch = self.api.db.get_execution_epoch()
|
||||
invalid = await self._restore("database_file", b"not a database")
|
||||
self.assertEqual(invalid.status, 400)
|
||||
self.assertFalse((await invalid.json())["success"])
|
||||
compatible = self._snapshot_upload()
|
||||
with patch(
|
||||
"database.models.rotate_history_epoch",
|
||||
side_effect=sqlite3.OperationalError("staging failed before safety backup"),
|
||||
):
|
||||
staged = await self._restore("database_file", compatible)
|
||||
self.assertEqual(staged.status, 500)
|
||||
self.assertIn("staging failed", (await staged.json())["error"])
|
||||
self.assertEqual(
|
||||
set(Path(self.tmpdir).glob("prompts.db.backup_*")), set(old_backups)
|
||||
)
|
||||
self.assertEqual({path: path.read_bytes() for path in old_backups}, original)
|
||||
self.assertEqual(self.api.db.get_execution_epoch(), epoch)
|
||||
self.assertIsNotNone(self.api.db.get_prompt_by_hash("live"))
|
||||
|
||||
async def test_retention_failure_keeps_original_restore_error_and_backup(
|
||||
self,
|
||||
) -> None:
|
||||
import sqlite3
|
||||
from contextlib import closing
|
||||
|
||||
self.api.db.save_prompt(text="keep live", prompt_hash="live")
|
||||
previous = self.api.db
|
||||
epoch = previous.get_execution_epoch()
|
||||
old_backups = self._seed_retention_backups(10)
|
||||
upload = self._retention_upload()
|
||||
with patch(
|
||||
"py.api.admin._prune_safety_backups",
|
||||
side_effect=OSError("retention cleanup failure /tmp/private"),
|
||||
):
|
||||
with self.assertLogs(self.api.logger, level="WARNING") as logs:
|
||||
response = await self._restore("database_file", upload)
|
||||
data = await response.json()
|
||||
self.assertEqual(response.status, 500, data)
|
||||
self.assertFalse(data["success"])
|
||||
self.assertIn("readonly database", data["error"])
|
||||
self.assertNotIn("retention cleanup failure", data["error"])
|
||||
self.assertNotIn("/tmp/", data["error"])
|
||||
warnings = [
|
||||
record.getMessage()
|
||||
for record in logs.records
|
||||
if record.levelname == "WARNING"
|
||||
]
|
||||
self.assertTrue(any("backup" in message.lower() for message in warnings))
|
||||
self.assertTrue(
|
||||
all("/tmp/" not in message and len(message) < 300 for message in warnings)
|
||||
)
|
||||
self.assertIs(self.api.db, previous)
|
||||
self.assertEqual(previous.get_execution_epoch(), epoch)
|
||||
self.assertIsNotNone(previous.get_prompt_by_hash("live"))
|
||||
remaining = set(Path(self.tmpdir).glob("prompts.db.backup_*"))
|
||||
self.assertEqual(len(remaining), 11)
|
||||
new_backups = remaining.difference(old_backups)
|
||||
self.assertEqual(len(new_backups), 1)
|
||||
with closing(sqlite3.connect(new_backups.pop())) as conn:
|
||||
self.assertEqual(
|
||||
conn.execute("SELECT text FROM prompts").fetchone()[0], "keep live"
|
||||
)
|
||||
self.assertEqual(conn.execute("PRAGMA integrity_check").fetchone()[0], "ok")
|
||||
|
||||
async def test_restore_rejects_database_with_a_trigger(self):
|
||||
self.api.db.save_prompt(text="keep me", prompt_hash="h1")
|
||||
upload = self._snapshot_upload(
|
||||
|
||||
@@ -11,7 +11,11 @@ import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
_mock_server = MagicMock()
|
||||
_mock_server.PromptServer.instance.routes = MagicMock()
|
||||
sys.modules.setdefault("server", _mock_server)
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from database.models import PromptModel # noqa: E402
|
||||
|
||||