Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a906176fdf | ||
|
|
9455abb17c | ||
|
|
195a1d33ac | ||
|
|
f8b55cf882 | ||
|
|
2a0ecd9b1b | ||
|
|
b7db4c35f6 | ||
|
|
2561d3b789 | ||
|
|
27ea042018 | ||
|
|
4e62be338e | ||
|
|
9b9c64f71d | ||
|
|
1b8fbdbb75 | ||
|
|
b64b50e763 | ||
|
|
0095db8cbc | ||
|
|
b4c0189225 | ||
|
|
b7cf210f0f | ||
|
|
bc14f29db7 | ||
|
|
f95f66d76a | ||
|
|
2cecfcf0ac | ||
|
|
a6f6697c50 | ||
|
|
6e9d8f81fd | ||
|
|
454ac12e1d | ||
|
|
27b4895795 | ||
|
|
e9996996e2 | ||
|
|
762669758b | ||
|
|
1ff2ddce05 | ||
|
|
10a4847a6b | ||
|
|
3476f8ede9 | ||
|
|
0a7b0238ca | ||
|
|
00bda8f442 | ||
|
|
728db36ac3 | ||
|
|
5ded3cfd39 | ||
|
|
2b8126b7be | ||
|
|
86d82186ee | ||
|
|
6c82098207 | ||
|
|
9d398f9862 |
@@ -8,6 +8,7 @@ on:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
# 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.
|
||||
@@ -146,6 +147,32 @@ jobs:
|
||||
python -m coverage combine
|
||||
python -m coverage report --fail-under=85
|
||||
|
||||
perf-budget:
|
||||
# The reference wall-clock measurement (TESTING.md "Performance budgets").
|
||||
# Shared-runner timing varies, so it never gates a pull request: it runs on
|
||||
# each push to main and on demand. A red run on main means fix the code.
|
||||
name: perf budget
|
||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.13"
|
||||
cache: pip
|
||||
cache-dependency-path: requirements-dev.txt
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements-dev.txt
|
||||
|
||||
- name: Workspace filter budget
|
||||
env:
|
||||
PROMPTMANAGER_PERF_BUDGET: "1"
|
||||
run: python -m unittest -v tests.test_api_workspace_images.TestWorkspaceImages.test_filter_budget
|
||||
|
||||
lint:
|
||||
name: black and flake8
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -16,9 +16,19 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
# Timeouts are the measured job time plus at least 50%, and never under 20 minutes for a browser job,
|
||||
# because runner speed varies. Before sharding (main 2cecfcf, one worker): frontend coverage 32m10s,
|
||||
# page workflows firefox 18m59s, webkit 19m26s. First sharded run (PR #274 run 39712909047, 3 workers,
|
||||
# no npm cache, where npm ci stalled for 5m in six of eight jobs): coverage shards 11m19s/10m47s/10m49s
|
||||
# (tests 5m20s/4m42s/4m46s), merge 5m52s (report and check 4s), firefox shards 7m53s/9m27s (tests
|
||||
# 4m41s/3m29s), webkit shards 12m25s+ (cancelled at the old 12-minute limit after its 267 tests passed in
|
||||
# 6m29s) and 4m53s (tests 3m08s). With the npm cache hit (run 39725176320 at cbc8a61, npm ci 0-2s):
|
||||
# coverage shards 7m34s/6m04s/5m22s (tests 6m38s/4m53s/4m04s), merge 54s, firefox 7m57s/5m21s (tests
|
||||
# 7m08s/4m34s), webkit 8m16s/5m03s (tests 7m26s/3m58s); a cache miss adds up to 5m to npm ci, which
|
||||
# the 20-minute limits absorb.
|
||||
jobs:
|
||||
frontend-coverage:
|
||||
name: frontend coverage (chromium and touch)
|
||||
name: frontend coverage (shard ${{ matrix.shard }}/3)
|
||||
runs-on: ubuntu-latest
|
||||
# Layout comparisons depend on the fonts each browser falls back to, so browser jobs run in the
|
||||
# same pinned Playwright image as local runs (tag = @playwright/test in tools/vendor/package-lock.json;
|
||||
@@ -26,7 +36,15 @@ jobs:
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble
|
||||
options: --user 1001
|
||||
timeout-minutes: 25
|
||||
timeout-minutes: 20
|
||||
# Public-repo ubuntu-latest runners have 4 vCPUs: three Playwright workers, each with its own test
|
||||
# server, leave one CPU for the servers and the runner.
|
||||
env:
|
||||
PM_TEST_WORKERS: 3
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
# The image's own Python 3.12 and Node 24: setup-python/setup-node install the runner's toolcache
|
||||
@@ -45,24 +63,107 @@ jobs:
|
||||
"$venv/bin/python" -m pip install --no-cache-dir -r requirements-dev.txt
|
||||
echo "$venv/bin" >> "$GITHUB_PATH"
|
||||
- run: python --version && node --version && node --version | grep -q '^v24\.'
|
||||
# npm's download cache, keyed on the lockfile; inside the container ~ is the job's HOME (/github/home),
|
||||
# which npm also uses. node_modules itself is still built fresh by npm ci.
|
||||
- name: Cache npm downloads
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: npm-${{ runner.os }}-playwright-v1.64.1-${{ hashFiles('tools/vendor/package-lock.json') }}
|
||||
- run: npm ci --prefix tools/vendor --ignore-scripts
|
||||
- run: npm run --prefix tools/vendor check
|
||||
- run: npm run --prefix tools/vendor test
|
||||
- name: Frontend unit, viewer, workflow, and coverage checks
|
||||
run: npm run --prefix tools/vendor test:coverage
|
||||
- name: Upload frontend coverage and Chromium traces
|
||||
# Shard 1 also runs the node units and the viewer suite, so the vendor pins and the tooling tests
|
||||
# run on the lightest shard instead.
|
||||
- if: matrix.shard == 3
|
||||
run: npm run --prefix tools/vendor check
|
||||
- if: matrix.shard == 3
|
||||
run: npm run --prefix tools/vendor test
|
||||
- name: Page workflows with coverage, shard ${{ matrix.shard }}/3
|
||||
run: npm run --prefix tools/vendor test:coverage:shard -- ${{ matrix.shard }}/3
|
||||
- name: Upload raw coverage
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: coverage-raw-${{ matrix.shard }}
|
||||
path: coverage/js/raw/
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- name: Upload Chromium and touch traces
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: frontend-traces-shard-${{ matrix.shard }}
|
||||
path: tools/vendor/test-results/
|
||||
retention-days: 14
|
||||
|
||||
frontend-coverage-merge:
|
||||
name: frontend coverage (merge)
|
||||
needs: frontend-coverage
|
||||
runs-on: ubuntu-latest
|
||||
# Same image as the shards: the raw files name sources by the container's workspace path.
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble
|
||||
options: --user 1001
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- run: node --version && node --version | grep -q '^v24\.'
|
||||
# npm's download cache, keyed on the lockfile; inside the container ~ is the job's HOME (/github/home),
|
||||
# which npm also uses. node_modules itself is still built fresh by npm ci.
|
||||
- name: Cache npm downloads
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: npm-${{ runner.os }}-playwright-v1.64.1-${{ hashFiles('tools/vendor/package-lock.json') }}
|
||||
- run: npm ci --prefix tools/vendor --ignore-scripts
|
||||
# Each artifact lands in its own coverage-raw-N directory, so the next step can count them.
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: coverage-raw-*
|
||||
path: ${{ runner.temp }}/coverage-raw
|
||||
# coverage:merge only checks that raw files exist; merging two of three shards would report
|
||||
# partial coverage, so a missing or empty shard artifact fails here.
|
||||
- name: Verify every shard's raw coverage arrived
|
||||
shell: bash
|
||||
env:
|
||||
STAGED_RAW: ${{ runner.temp }}/coverage-raw
|
||||
EXPECTED_SHARDS: 3
|
||||
run: |
|
||||
found=0
|
||||
for shard in $(seq 1 "$EXPECTED_SHARDS"); do
|
||||
directory="$STAGED_RAW/coverage-raw-$shard"
|
||||
count=0
|
||||
if [ -d "$directory" ]; then count=$(find "$directory" -maxdepth 1 -type f -name '*.json' | wc -l); fi
|
||||
echo "coverage-raw-$shard: $count raw files"
|
||||
if [ "$count" -gt 0 ]; then found=$((found + 1)); fi
|
||||
done
|
||||
echo "shard artifacts with raw coverage: $found of $EXPECTED_SHARDS"
|
||||
if [ "$found" -ne "$EXPECTED_SHARDS" ]; then
|
||||
echo "::error::Expected raw coverage from $EXPECTED_SHARDS shards, got $found of $EXPECTED_SHARDS"
|
||||
exit 1
|
||||
fi
|
||||
staged=$(find "$STAGED_RAW" -mindepth 2 -maxdepth 2 -type f -name '*.json' | wc -l)
|
||||
mkdir -p coverage/js/raw
|
||||
# cp -n never overwrites; a clash shows up as fewer merged files than staged ones.
|
||||
find "$STAGED_RAW" -mindepth 2 -maxdepth 2 -type f -name '*.json' -exec cp -n -t coverage/js/raw {} + || true
|
||||
merged=$(find coverage/js/raw -maxdepth 1 -type f -name '*.json' | wc -l)
|
||||
if [ "$merged" -ne "$staged" ]; then
|
||||
echo "::error::Shards wrote clashing raw file names: $staged staged, $merged merged"
|
||||
exit 1
|
||||
fi
|
||||
echo "merged $merged raw files"
|
||||
- name: Frontend coverage report and baseline check
|
||||
run: npm run --prefix tools/vendor coverage:merge
|
||||
- name: Upload frontend coverage report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: frontend-test-reports
|
||||
path: |
|
||||
tools/vendor/test-results/
|
||||
coverage/js/
|
||||
!coverage/js/raw/**
|
||||
retention-days: 14
|
||||
|
||||
browser-workflows:
|
||||
name: page workflows (${{ matrix.browser }})
|
||||
name: page workflows (${{ matrix.browser }}, shard ${{ matrix.shard }}/2)
|
||||
runs-on: ubuntu-latest
|
||||
# Layout comparisons depend on the fonts each browser falls back to, so browser jobs run in the
|
||||
# same pinned Playwright image as local runs (tag = @playwright/test in tools/vendor/package-lock.json;
|
||||
@@ -70,11 +171,16 @@ jobs:
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble
|
||||
options: --user 1001
|
||||
timeout-minutes: 25
|
||||
timeout-minutes: 20
|
||||
# Public-repo ubuntu-latest runners have 4 vCPUs: three Playwright workers, each with its own test
|
||||
# server, leave one CPU for the servers and the runner.
|
||||
env:
|
||||
PM_TEST_WORKERS: 3
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
browser: [firefox, webkit]
|
||||
shard: [1, 2]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
# The image's own Python 3.12 and Node 24: setup-python/setup-node install the runner's toolcache
|
||||
@@ -93,22 +199,29 @@ jobs:
|
||||
"$venv/bin/python" -m pip install --no-cache-dir -r requirements-dev.txt
|
||||
echo "$venv/bin" >> "$GITHUB_PATH"
|
||||
- run: python --version && node --version && node --version | grep -q '^v24\.'
|
||||
# npm's download cache, keyed on the lockfile; inside the container ~ is the job's HOME (/github/home),
|
||||
# which npm also uses. node_modules itself is still built fresh by npm ci.
|
||||
- name: Cache npm downloads
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: npm-${{ runner.os }}-playwright-v1.64.1-${{ hashFiles('tools/vendor/package-lock.json') }}
|
||||
- run: npm ci --prefix tools/vendor --ignore-scripts
|
||||
- name: Page workflows in ${{ matrix.browser }}
|
||||
run: npm run --prefix tools/vendor test:workflows -- --project ${{ matrix.browser }}
|
||||
- name: Page workflows in ${{ matrix.browser }}, shard ${{ matrix.shard }}/2
|
||||
run: npm run --prefix tools/vendor test:workflows -- --project ${{ matrix.browser }} --shard ${{ matrix.shard }}/2
|
||||
- name: Upload browser traces
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: browser-traces-${{ matrix.browser }}
|
||||
name: browser-traces-${{ matrix.browser }}-shard-${{ matrix.shard }}
|
||||
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.
|
||||
# name and fails unless every frontend job above succeeded (every shard, the
|
||||
# coverage merge and every browser shard), including when one was cancelled or skipped.
|
||||
viewerjs:
|
||||
needs: [frontend-coverage, browser-workflows]
|
||||
needs: [frontend-coverage, frontend-coverage-merge, browser-workflows]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
@@ -116,8 +229,10 @@ jobs:
|
||||
- name: Require every frontend job to succeed
|
||||
env:
|
||||
COVERAGE_RESULT: ${{ needs.frontend-coverage.result }}
|
||||
MERGE_RESULT: ${{ needs.frontend-coverage-merge.result }}
|
||||
WORKFLOWS_RESULT: ${{ needs.browser-workflows.result }}
|
||||
run: |
|
||||
echo "frontend-coverage: $COVERAGE_RESULT"
|
||||
echo "frontend-coverage-merge: $MERGE_RESULT"
|
||||
echo "browser-workflows: $WORKFLOWS_RESULT"
|
||||
test "$COVERAGE_RESULT" = success && test "$WORKFLOWS_RESULT" = success
|
||||
test "$COVERAGE_RESULT" = success && test "$MERGE_RESULT" = success && test "$WORKFLOWS_RESULT" = success
|
||||
|
||||
+1504
-39
File diff suppressed because it is too large
Load Diff
@@ -90,6 +90,7 @@ try:
|
||||
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)
|
||||
host_app.on_cleanup.append(api.cleanup_annotation_captures)
|
||||
|
||||
except Exception as e:
|
||||
# Log to internal logging system without console spam
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
"""Image annotation schema group (#249): file locators, byte records, image tags.
|
||||
|
||||
The group installs like ``image_favorites`` and ``saved_views``: optional in old
|
||||
backups, required once a database is current. It owns
|
||||
|
||||
- ``media_annotation_metadata``: one row, the group's schema version and the
|
||||
image index revision;
|
||||
- ``media_annotation_files``: one private canonical locator per physical file
|
||||
(``normcase(join(realpath(root), relative))``, server-side only);
|
||||
- ``media_annotation_bytes``: one record per observed whole-file SHA-256 of a
|
||||
file, with the size and ``mtime_ns`` verified when those bytes were last seen
|
||||
and the tag set's revision;
|
||||
- ``media_image_tags``: the ordered image tag set of one byte record, joined
|
||||
into the global ``tags`` registry so vocabulary maintenance carries image
|
||||
links like prompt and pair links.
|
||||
|
||||
Installation never backfills anything from prompt or pair tags and never
|
||||
touches the filesystem. Validation reads only: the catalog objects are compared
|
||||
after whitespace compaction, columns, foreign keys and indexes through the
|
||||
PRAGMAs, and persisted values in single bounded SQL passes.
|
||||
"""
|
||||
|
||||
import re
|
||||
import sqlite3
|
||||
from typing import Optional
|
||||
|
||||
from .image_favorites import MAX_KEY_LENGTH
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
METADATA_TABLE = "media_annotation_metadata"
|
||||
FILES_TABLE = "media_annotation_files"
|
||||
BYTES_TABLE = "media_annotation_bytes"
|
||||
IMAGE_TAGS_TABLE = "media_image_tags"
|
||||
TABLES = (METADATA_TABLE, FILES_TABLE, BYTES_TABLE, IMAGE_TAGS_TABLE)
|
||||
MAX_LOCATOR_LENGTH = MAX_KEY_LENGTH
|
||||
_TIMESTAMP_BYTES = 64
|
||||
|
||||
_TABLE_DDL = {
|
||||
METADATA_TABLE: (
|
||||
f"CREATE TABLE {METADATA_TABLE} (singleton_id INTEGER PRIMARY KEY "
|
||||
"CHECK(singleton_id=1), schema_version INTEGER NOT NULL "
|
||||
f"CHECK(schema_version={SCHEMA_VERSION}), "
|
||||
"image_index_revision INTEGER NOT NULL DEFAULT 0 "
|
||||
"CHECK(image_index_revision>=0))"
|
||||
),
|
||||
FILES_TABLE: (
|
||||
f"CREATE TABLE {FILES_TABLE} (id TEXT PRIMARY KEY CHECK(length(id)=36), "
|
||||
"locator TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL)"
|
||||
),
|
||||
BYTES_TABLE: (
|
||||
f"CREATE TABLE {BYTES_TABLE} (id TEXT PRIMARY KEY CHECK(length(id)=36), "
|
||||
f"file_id TEXT NOT NULL REFERENCES {FILES_TABLE}(id) ON DELETE CASCADE, "
|
||||
"sha256 TEXT NOT NULL CHECK(length(sha256)=64 AND sha256 NOT GLOB "
|
||||
"'*[^0-9a-f]*'), size INTEGER NOT NULL CHECK(size>=0), "
|
||||
"mtime_ns INTEGER NOT NULL, verified_at TEXT NOT NULL, "
|
||||
"first_seen_at TEXT NOT NULL, tag_revision INTEGER NOT NULL DEFAULT 0 "
|
||||
"CHECK(tag_revision>=0), UNIQUE(file_id, sha256))"
|
||||
),
|
||||
IMAGE_TAGS_TABLE: (
|
||||
f"CREATE TABLE {IMAGE_TAGS_TABLE} (byte_id TEXT NOT NULL REFERENCES "
|
||||
f"{BYTES_TABLE}(id) ON DELETE CASCADE, tag_id INTEGER NOT NULL "
|
||||
"REFERENCES tags(id) ON DELETE CASCADE, position INTEGER NOT NULL "
|
||||
"CHECK(position>=0), PRIMARY KEY(byte_id, tag_id))"
|
||||
),
|
||||
}
|
||||
_INDEX_DDL = {
|
||||
"idx_media_annotation_bytes_fingerprint": (
|
||||
"CREATE INDEX idx_media_annotation_bytes_fingerprint ON "
|
||||
f"{BYTES_TABLE}(file_id, size, mtime_ns)"
|
||||
),
|
||||
"idx_media_image_tags_tag": (
|
||||
f"CREATE INDEX idx_media_image_tags_tag ON {IMAGE_TAGS_TABLE}(tag_id)"
|
||||
),
|
||||
"idx_media_image_tags_position": (
|
||||
"CREATE UNIQUE INDEX idx_media_image_tags_position ON "
|
||||
f"{IMAGE_TAGS_TABLE}(byte_id, position)"
|
||||
),
|
||||
}
|
||||
# SQLite names the indexes behind TEXT primary keys and UNIQUE constraints
|
||||
# ``sqlite_autoindex_<table>_<n>``; their catalog rows carry no SQL.
|
||||
_AUTOINDEXES = {FILES_TABLE: 2, BYTES_TABLE: 2, IMAGE_TAGS_TABLE: 1}
|
||||
# (name, declared type, not null, default, primary key position)
|
||||
_COLUMNS = {
|
||||
METADATA_TABLE: (
|
||||
("singleton_id", "INTEGER", 0, None, 1),
|
||||
("schema_version", "INTEGER", 1, None, 0),
|
||||
("image_index_revision", "INTEGER", 1, "0", 0),
|
||||
),
|
||||
FILES_TABLE: (
|
||||
("id", "TEXT", 0, None, 1),
|
||||
("locator", "TEXT", 1, None, 0),
|
||||
("created_at", "TEXT", 1, None, 0),
|
||||
),
|
||||
BYTES_TABLE: (
|
||||
("id", "TEXT", 0, None, 1),
|
||||
("file_id", "TEXT", 1, None, 0),
|
||||
("sha256", "TEXT", 1, None, 0),
|
||||
("size", "INTEGER", 1, None, 0),
|
||||
("mtime_ns", "INTEGER", 1, None, 0),
|
||||
("verified_at", "TEXT", 1, None, 0),
|
||||
("first_seen_at", "TEXT", 1, None, 0),
|
||||
("tag_revision", "INTEGER", 1, "0", 0),
|
||||
),
|
||||
IMAGE_TAGS_TABLE: (
|
||||
("byte_id", "TEXT", 1, None, 1),
|
||||
("tag_id", "INTEGER", 1, None, 2),
|
||||
("position", "INTEGER", 1, None, 0),
|
||||
),
|
||||
}
|
||||
# (column, parent table, parent column, on delete)
|
||||
_FOREIGN_KEYS = {
|
||||
METADATA_TABLE: set(),
|
||||
FILES_TABLE: set(),
|
||||
BYTES_TABLE: {("file_id", FILES_TABLE, "id", "CASCADE")},
|
||||
IMAGE_TAGS_TABLE: {
|
||||
("byte_id", BYTES_TABLE, "id", "CASCADE"),
|
||||
("tag_id", "tags", "id", "CASCADE"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _compact(sql: str) -> str:
|
||||
return re.sub(r"\s+", "", sql).lower()
|
||||
|
||||
|
||||
def _uuid(column: str) -> str:
|
||||
"""SQL: *column* is a lowercase hyphenated 36-character UUID string."""
|
||||
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 _timestamp(column: str) -> str:
|
||||
return (
|
||||
f"typeof({column}) = 'text' "
|
||||
f"AND length(CAST({column} AS BLOB)) <= {_TIMESTAMP_BYTES}"
|
||||
)
|
||||
|
||||
|
||||
def _non_negative(column: str) -> str:
|
||||
return f"typeof({column}) = 'integer' AND {column} >= 0"
|
||||
|
||||
|
||||
# Every row of a table must satisfy its predicate; one pass each, LIMIT 1.
|
||||
_ROW_PREDICATES = {
|
||||
FILES_TABLE: " AND ".join(
|
||||
(
|
||||
_uuid("id"),
|
||||
"typeof(locator) = 'text'",
|
||||
# UTF-8 is at most 4 bytes per character; the decoded length of
|
||||
# the few candidates above the byte bound is checked in Python.
|
||||
f"length(CAST(locator AS BLOB)) <= {MAX_LOCATOR_LENGTH * 4}",
|
||||
_timestamp("created_at"),
|
||||
)
|
||||
),
|
||||
BYTES_TABLE: " AND ".join(
|
||||
(
|
||||
_uuid("id"),
|
||||
_uuid("file_id"),
|
||||
"typeof(sha256) = 'text' AND length(sha256) = 64 "
|
||||
"AND sha256 NOT GLOB '*[^0-9a-f]*'",
|
||||
_non_negative("size"),
|
||||
"typeof(mtime_ns) = 'integer'",
|
||||
_timestamp("verified_at"),
|
||||
_timestamp("first_seen_at"),
|
||||
_non_negative("tag_revision"),
|
||||
)
|
||||
),
|
||||
IMAGE_TAGS_TABLE: " AND ".join(
|
||||
(_uuid("byte_id"), "typeof(tag_id) = 'integer'", _non_negative("position"))
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _expected_catalog() -> set:
|
||||
objects = {("table", name, _compact(sql)) for name, sql in _TABLE_DDL.items()}
|
||||
objects |= {("index", name, _compact(sql)) for name, sql in _INDEX_DDL.items()}
|
||||
objects |= {
|
||||
("index", f"sqlite_autoindex_{table}_{n}", "")
|
||||
for table, count in _AUTOINDEXES.items()
|
||||
for n in range(1, count + 1)
|
||||
}
|
||||
return objects
|
||||
|
||||
|
||||
def _catalog(conn: sqlite3.Connection) -> list:
|
||||
names = TABLES + tuple(_INDEX_DDL)
|
||||
marks = ",".join("?" * len(names))
|
||||
table_marks = ",".join("?" * len(TABLES))
|
||||
return conn.execute( # only "?" placeholders are interpolated
|
||||
"SELECT type,name,sql FROM sqlite_master "
|
||||
f"WHERE name IN ({marks}) OR tbl_name IN ({table_marks})", # nosec B608
|
||||
names + TABLES,
|
||||
).fetchall()
|
||||
|
||||
|
||||
def _structure_error(conn: sqlite3.Connection) -> Optional[str]:
|
||||
for table, expected in _COLUMNS.items():
|
||||
columns = tuple(
|
||||
(row[1], row[2].upper(), row[3], row[4], row[5])
|
||||
for row in conn.execute(f"PRAGMA table_info({table})")
|
||||
)
|
||||
if columns != expected:
|
||||
return f"Image annotations table {table} has invalid columns"
|
||||
foreign_keys = {
|
||||
(row[3], row[2], row[4], row[6])
|
||||
for row in conn.execute(f"PRAGMA foreign_key_list({table})")
|
||||
}
|
||||
if foreign_keys != _FOREIGN_KEYS[table]:
|
||||
return f"Image annotations table {table} has invalid foreign keys"
|
||||
if conn.execute(f"PRAGMA foreign_key_check({table})").fetchone():
|
||||
return f"Image annotations table {table} contains foreign key violations"
|
||||
return None
|
||||
|
||||
|
||||
def _value_error(conn: sqlite3.Connection) -> Optional[str]:
|
||||
# Only this module's constants are interpolated into the SQL below.
|
||||
rows = conn.execute(
|
||||
"SELECT COUNT(*), MIN(typeof(singleton_id) = 'integer' " # nosec B608
|
||||
"AND singleton_id = 1 AND typeof(schema_version) = 'integer' "
|
||||
f"AND schema_version = {SCHEMA_VERSION} "
|
||||
f"AND {_non_negative('image_index_revision')}) FROM {METADATA_TABLE}"
|
||||
).fetchone()
|
||||
if rows[0] != 1 or rows[1] != 1:
|
||||
return "Image annotations metadata is invalid"
|
||||
for table, predicate in _ROW_PREDICATES.items():
|
||||
if conn.execute(
|
||||
f"SELECT 1 FROM {table} WHERE NOT ({predicate}) LIMIT 1" # nosec B608
|
||||
).fetchone():
|
||||
return f"Image annotations table {table} contains invalid values"
|
||||
# length() stops at an embedded NUL, so the SQL pass bounds bytes and the
|
||||
# few candidates over the character bound are measured decoded here.
|
||||
for (raw,) in conn.execute(
|
||||
f"SELECT CAST(locator AS BLOB) FROM {FILES_TABLE} " # nosec B608
|
||||
f"WHERE length(CAST(locator AS BLOB)) > {MAX_LOCATOR_LENGTH}"
|
||||
):
|
||||
if len(bytes(raw).decode("utf-8", "replace")) > MAX_LOCATOR_LENGTH:
|
||||
return f"Image annotations table {FILES_TABLE} contains invalid values"
|
||||
return None
|
||||
|
||||
|
||||
def annotation_schema_error(
|
||||
conn: sqlite3.Connection, *, allow_missing: bool = True
|
||||
) -> Optional[str]:
|
||||
"""Reject partial or unsupported annotation schemas and invalid rows.
|
||||
|
||||
Reads only: nothing is ever repaired. A database without any of the
|
||||
group's objects is acceptable for backups that predate it unless
|
||||
``allow_missing`` is false.
|
||||
"""
|
||||
objects = _catalog(conn)
|
||||
if not objects:
|
||||
return None if allow_missing else "Missing image annotation schema"
|
||||
present = {name for kind, name, _sql in objects if kind == "table"}
|
||||
if present != set(TABLES):
|
||||
return "Incomplete image annotation schema"
|
||||
actual = {(kind, name, _compact(sql or "")) for kind, name, sql in objects}
|
||||
if actual != _expected_catalog():
|
||||
return "Image annotations has an invalid schema definition"
|
||||
return _structure_error(conn) or _value_error(conn)
|
||||
|
||||
|
||||
def migrate_annotation_state(conn: sqlite3.Connection) -> None:
|
||||
"""Install the group atomically; validate before and after creating it."""
|
||||
conn.execute("SAVEPOINT media_annotation_schema")
|
||||
try:
|
||||
error = annotation_schema_error(conn)
|
||||
if error:
|
||||
raise sqlite3.DatabaseError(error)
|
||||
for sql in _TABLE_DDL.values():
|
||||
conn.execute(sql.replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS", 1))
|
||||
for sql in _INDEX_DDL.values():
|
||||
conn.execute(sql.replace("INDEX", "INDEX IF NOT EXISTS", 1))
|
||||
conn.execute(
|
||||
f"INSERT OR IGNORE INTO {METADATA_TABLE} "
|
||||
"(singleton_id, schema_version, image_index_revision) VALUES (1, ?, 0)",
|
||||
(SCHEMA_VERSION,),
|
||||
)
|
||||
error = annotation_schema_error(conn, allow_missing=False)
|
||||
if error:
|
||||
raise sqlite3.DatabaseError(error)
|
||||
conn.execute("RELEASE media_annotation_schema")
|
||||
except BaseException:
|
||||
conn.execute("ROLLBACK TO media_annotation_schema")
|
||||
conn.execute("RELEASE media_annotation_schema")
|
||||
raise
|
||||
@@ -0,0 +1,461 @@
|
||||
"""Per-image tag sets (#249): byte-exact storage in the shared vocabulary.
|
||||
|
||||
A tag set belongs to one byte record (``media_annotation_bytes``): the
|
||||
whole-file SHA-256 of one physical file, owned by the file's private locator
|
||||
(``media_annotation_files``). Callers pass verified facts (digest, size,
|
||||
``mtime_ns``); nothing here reads the filesystem or resolves an owner through
|
||||
prompt links, so tagging one image never changes another image, a prompt or a
|
||||
pair.
|
||||
|
||||
Commands run in one ``_pair_transaction`` fenced by the storage epoch, like the
|
||||
native Library and vocabulary commands. A save is a full replacement guarded
|
||||
by the set's ``tag_revision``; the identical set sent again (a lost-response
|
||||
retry, or the current set at its own revision) answers ``replayed: true`` and
|
||||
writes nothing. ``add_image_tags`` unions into the existing set, keeping the
|
||||
stored spelling. Names enter the global ``tags`` registry exactly as spelled,
|
||||
so vocabulary rename, merge and delete carry image links with prompt and pair
|
||||
links (``PromptDatabase._move_tag_links``).
|
||||
|
||||
Cheap reads (spec amendment S1) treat a byte record as *current* only while
|
||||
the file's present ``(size, mtime_ns)`` equals the record's verified
|
||||
fingerprint; a file without a current record shows no image tags.
|
||||
"""
|
||||
|
||||
import re
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
from typing import Iterator
|
||||
from uuid import uuid4
|
||||
|
||||
from .annotation_schema import (
|
||||
BYTES_TABLE,
|
||||
FILES_TABLE,
|
||||
IMAGE_TAGS_TABLE,
|
||||
MAX_LOCATOR_LENGTH,
|
||||
METADATA_TABLE,
|
||||
)
|
||||
from .library_commands import _UUID, LibraryCommandError, _validate_epoch
|
||||
from .models import utc_now_iso
|
||||
from .vocabulary import validate_vocabulary_tag_name
|
||||
|
||||
MAX_IMAGE_TAGS = 100
|
||||
_LOOKUP_CHUNK = 500
|
||||
_DIGEST = re.compile(r"[0-9a-f]{64}")
|
||||
_MAX_INT = 2**63 - 1
|
||||
_MIN_INT = -(2**63)
|
||||
# Only this module's table constants are interpolated into the SQL below.
|
||||
_OWNER = f"SELECT id FROM {FILES_TABLE} WHERE locator=?" # nosec B608
|
||||
_INSERT_OWNER = (
|
||||
f"INSERT INTO {FILES_TABLE}(id,locator,created_at) VALUES(?,?,?)" # nosec B608
|
||||
)
|
||||
_BYTE = (
|
||||
f"SELECT id,tag_revision,size,mtime_ns FROM {BYTES_TABLE} " # nosec B608
|
||||
"WHERE file_id=? AND sha256=?"
|
||||
)
|
||||
_INSERT_BYTE = (
|
||||
f"INSERT INTO {BYTES_TABLE}(id,file_id,sha256,size,mtime_ns," # nosec B608
|
||||
"verified_at,first_seen_at,tag_revision) VALUES(?,?,?,?,?,?,?,0)"
|
||||
)
|
||||
_REFRESH_BYTE = (
|
||||
f"UPDATE {BYTES_TABLE} SET size=?,mtime_ns=?,verified_at=? WHERE id=?" # nosec B608
|
||||
)
|
||||
_REFRESH_BY_LOCATOR = (
|
||||
f"UPDATE {BYTES_TABLE} SET size=?,mtime_ns=?,verified_at=? " # nosec B608
|
||||
f"WHERE sha256=? AND file_id=({_OWNER})"
|
||||
)
|
||||
_BYTE_TAGS = (
|
||||
f"SELECT t.name FROM {IMAGE_TAGS_TABLE} mit " # nosec B608
|
||||
"JOIN tags t ON t.id=mit.tag_id WHERE mit.byte_id=? ORDER BY mit.position"
|
||||
)
|
||||
_CLEAR_TAGS = f"DELETE FROM {IMAGE_TAGS_TABLE} WHERE byte_id=?" # nosec B608
|
||||
_INSERT_TAG = (
|
||||
f"INSERT INTO {IMAGE_TAGS_TABLE}(byte_id,tag_id,position) " # nosec B608
|
||||
"VALUES(?,?,?)"
|
||||
)
|
||||
_BUMP_REVISION = (
|
||||
f"UPDATE {BYTES_TABLE} SET tag_revision=tag_revision+1 WHERE id=?" # nosec B608
|
||||
)
|
||||
_REVISION = f"SELECT tag_revision FROM {BYTES_TABLE} WHERE id=?" # nosec B608
|
||||
_BUMP_INDEX = (
|
||||
f"UPDATE {METADATA_TABLE} SET " # nosec B608
|
||||
"image_index_revision=image_index_revision+1 WHERE singleton_id=1"
|
||||
)
|
||||
_READ = (
|
||||
"SELECT b.id,b.tag_revision," # nosec B608
|
||||
f"(SELECT COUNT(*) FROM {BYTES_TABLE} o WHERE o.file_id=f.id AND o.sha256!=?) "
|
||||
f"FROM {FILES_TABLE} f LEFT JOIN {BYTES_TABLE} b "
|
||||
"ON b.file_id=f.id AND b.sha256=? WHERE f.locator=?"
|
||||
)
|
||||
_FINGERPRINTS = (
|
||||
"SELECT f.locator,b.id,b.size,b.mtime_ns,b.verified_at " # nosec B608
|
||||
f"FROM {FILES_TABLE} f JOIN {BYTES_TABLE} b ON b.file_id=f.id "
|
||||
"WHERE f.locator IN ({marks})"
|
||||
)
|
||||
_TAGS_OF = (
|
||||
f"SELECT mit.byte_id,t.name FROM {IMAGE_TAGS_TABLE} mit " # nosec B608
|
||||
"JOIN tags t ON t.id=mit.tag_id WHERE mit.byte_id IN ({marks}) "
|
||||
"ORDER BY mit.byte_id,mit.position"
|
||||
)
|
||||
_RECORDS = (
|
||||
"SELECT f.locator,b.id,b.size,b.mtime_ns,t.name " # nosec B608
|
||||
f"FROM {FILES_TABLE} f JOIN {BYTES_TABLE} b ON b.file_id=f.id "
|
||||
f"LEFT JOIN {IMAGE_TAGS_TABLE} mit ON mit.byte_id=b.id "
|
||||
"LEFT JOIN tags t ON t.id=mit.tag_id "
|
||||
"ORDER BY f.locator,b.first_seen_at,b.id,mit.position"
|
||||
)
|
||||
|
||||
|
||||
class AnnotationCommandError(LibraryCommandError):
|
||||
"""An image tag command lost a race or hit the set's bound.
|
||||
|
||||
``code`` is ``tag-conflict`` (the set changed since it was read) or
|
||||
``tag-limit`` (the union would exceed ``MAX_IMAGE_TAGS``); ``current`` is
|
||||
the stored set ``{sha256, tags, tag_revision}`` read inside the
|
||||
transaction, so the caller can show it.
|
||||
"""
|
||||
|
||||
def __init__(self, code: str, epoch: str, current: dict | None = None):
|
||||
super().__init__(code, epoch)
|
||||
self.current = current
|
||||
|
||||
|
||||
class ImageTagLimitError(ValueError):
|
||||
"""More than ``MAX_IMAGE_TAGS`` distinct names in one set."""
|
||||
|
||||
|
||||
def normalize_image_tags(names: object) -> list[str]:
|
||||
"""Trim and validate each name, fold case-insensitive repeats, cap at 100.
|
||||
|
||||
The first occurrence keeps its spelling and position. Raises ``ValueError``
|
||||
for anything but a list of valid vocabulary names, and its subclass
|
||||
``ImageTagLimitError`` for more than ``MAX_IMAGE_TAGS`` distinct names.
|
||||
"""
|
||||
if not isinstance(names, list):
|
||||
raise ValueError("tags must be a list of strings")
|
||||
result: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for value in names:
|
||||
name = validate_vocabulary_tag_name(value)
|
||||
key = name.casefold()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
result.append(name)
|
||||
if len(result) > MAX_IMAGE_TAGS:
|
||||
raise ImageTagLimitError(f"An image can carry at most {MAX_IMAGE_TAGS} tags")
|
||||
return result
|
||||
|
||||
|
||||
def _validate_locator(locator: object) -> None:
|
||||
if not isinstance(locator, str) or not 0 < len(locator) <= MAX_LOCATOR_LENGTH:
|
||||
raise ValueError(
|
||||
f"locator must be a nonblank path of at most {MAX_LOCATOR_LENGTH} chars"
|
||||
)
|
||||
|
||||
|
||||
def _validate_digest(sha256: object) -> None:
|
||||
if not isinstance(sha256, str) or not _DIGEST.fullmatch(sha256):
|
||||
raise ValueError("sha256 must be 64 lowercase hexadecimal characters")
|
||||
|
||||
|
||||
def _validate_int(value: object, name: str, minimum: int) -> None:
|
||||
if type(value) is not int or not minimum <= value <= _MAX_INT:
|
||||
raise ValueError(f"{name} must be an integer not below {minimum}")
|
||||
|
||||
|
||||
def _validate_target(locator: object, sha256: object, size: object, mtime_ns: object):
|
||||
_validate_locator(locator)
|
||||
_validate_digest(sha256)
|
||||
_validate_int(size, "size", 0)
|
||||
_validate_int(mtime_ns, "mtime_ns", _MIN_INT)
|
||||
|
||||
|
||||
def _validate_fingerprint(locator: object, fingerprint: object) -> None:
|
||||
_validate_locator(locator)
|
||||
if (
|
||||
not isinstance(fingerprint, tuple)
|
||||
or len(fingerprint) != 2
|
||||
or any(type(value) is not int for value in fingerprint)
|
||||
):
|
||||
raise ValueError("stats must map locators to (size, mtime_ns) integer pairs")
|
||||
|
||||
|
||||
def _new_id() -> str:
|
||||
"""A canonical UUID; the schema's CHECK lets NULL through, this does not."""
|
||||
value = str(uuid4())
|
||||
if not _UUID.fullmatch(value):
|
||||
raise ValueError("Annotation ids must be canonical UUIDs")
|
||||
return value
|
||||
|
||||
|
||||
def _receipt(epoch: str, sha256: str, tags: list, revision: int, replayed: bool):
|
||||
return {
|
||||
"library_epoch": epoch,
|
||||
"sha256": sha256,
|
||||
"tags": list(tags),
|
||||
"tag_revision": revision,
|
||||
"replayed": replayed,
|
||||
}
|
||||
|
||||
|
||||
class AnnotationOperations:
|
||||
"""PromptDatabase mixin: image tag sets, fingerprints and cheap reads.
|
||||
|
||||
``_`` helpers take the open ``_pair_transaction`` connection.
|
||||
"""
|
||||
|
||||
@contextmanager
|
||||
def _annotation_read(self) -> Iterator[sqlite3.Connection]:
|
||||
"""One deferred read transaction, so paired SELECTs see one snapshot."""
|
||||
conn = self.model.get_connection()
|
||||
if conn.in_transaction:
|
||||
raise sqlite3.ProgrammingError(
|
||||
"Annotation reads require their own snapshot"
|
||||
)
|
||||
with conn:
|
||||
conn.execute("BEGIN")
|
||||
yield conn
|
||||
|
||||
@staticmethod
|
||||
def _annotation_owner(conn: sqlite3.Connection, locator: str, now: str) -> str:
|
||||
row = conn.execute(_OWNER, (locator,)).fetchone()
|
||||
if row is not None:
|
||||
return row[0]
|
||||
file_id = _new_id()
|
||||
conn.execute(_INSERT_OWNER, (file_id, locator, now))
|
||||
return file_id
|
||||
|
||||
@staticmethod
|
||||
def _annotation_byte(
|
||||
conn: sqlite3.Connection,
|
||||
file_id: str,
|
||||
sha256: str,
|
||||
size: int,
|
||||
mtime_ns: int,
|
||||
now: str,
|
||||
) -> tuple[str, int, bool, bool]:
|
||||
"""The byte record for these bytes, refreshed or created.
|
||||
|
||||
Returns ``(id, tag_revision, created, refingerprinted)``; the last is
|
||||
True when an existing record's verified ``(size, mtime_ns)`` changed,
|
||||
which moves its currency for cheap reads (the caller bumps the index
|
||||
revision when nothing else does).
|
||||
"""
|
||||
row = conn.execute(_BYTE, (file_id, sha256)).fetchone()
|
||||
if row is None:
|
||||
byte_id = _new_id()
|
||||
conn.execute(
|
||||
_INSERT_BYTE, (byte_id, file_id, sha256, size, mtime_ns, now, now)
|
||||
)
|
||||
return byte_id, 0, True, False
|
||||
conn.execute(_REFRESH_BYTE, (size, mtime_ns, now, row[0]))
|
||||
return row[0], row[1], False, (row[2], row[3]) != (size, mtime_ns)
|
||||
|
||||
@staticmethod
|
||||
def _byte_tags(conn: sqlite3.Connection, byte_id: str) -> list[str]:
|
||||
return [row[0] for row in conn.execute(_BYTE_TAGS, (byte_id,))]
|
||||
|
||||
def _write_image_tags(
|
||||
self, conn: sqlite3.Connection, byte_id: str, tags: list[str]
|
||||
) -> int:
|
||||
"""Rewrite the junction in order, bump both revisions; return the new one."""
|
||||
tag_ids = self._ensure_tags(conn, tags)
|
||||
conn.execute(_CLEAR_TAGS, (byte_id,))
|
||||
conn.executemany(
|
||||
_INSERT_TAG,
|
||||
[(byte_id, tag_ids[name], position) for position, name in enumerate(tags)],
|
||||
)
|
||||
conn.execute(_BUMP_REVISION, (byte_id,))
|
||||
conn.execute(_BUMP_INDEX)
|
||||
return conn.execute(_REVISION, (byte_id,)).fetchone()[0]
|
||||
|
||||
def _current_set(self, conn, sha256, byte_id, revision) -> dict:
|
||||
return {
|
||||
"sha256": sha256,
|
||||
"tags": self._byte_tags(conn, byte_id),
|
||||
"tag_revision": revision,
|
||||
}
|
||||
|
||||
def read_image_tags(self, locator: str, sha256: str) -> dict:
|
||||
"""The stored set of these exact bytes, or an empty set at revision 0.
|
||||
|
||||
``prior_count`` is the number of other byte records at the locator
|
||||
(earlier or later bytes of the same file).
|
||||
"""
|
||||
_validate_locator(locator)
|
||||
_validate_digest(sha256)
|
||||
with self._annotation_read() as conn:
|
||||
row = conn.execute(_READ, (sha256, sha256, locator)).fetchone()
|
||||
if row is None:
|
||||
return {"tags": [], "tag_revision": 0, "prior_count": 0}
|
||||
tags = self._byte_tags(conn, row[0]) if row[0] is not None else []
|
||||
return {"tags": tags, "tag_revision": row[1] or 0, "prior_count": row[2]}
|
||||
|
||||
def replace_image_tags(
|
||||
self,
|
||||
*,
|
||||
locator: str,
|
||||
sha256: str,
|
||||
size: int,
|
||||
mtime_ns: int,
|
||||
expected_epoch: str,
|
||||
expected_tag_revision: int,
|
||||
tags: list,
|
||||
) -> dict:
|
||||
"""Replace the set of these exact bytes, guarded by epoch and revision.
|
||||
|
||||
Creates the owner and byte record when the bytes are new (an explicit
|
||||
empty set is a real save). The identical set at the current revision,
|
||||
or one behind it (a lost-response retry), is ``replayed`` and writes
|
||||
nothing; any other revision mismatch is ``tag-conflict`` with the
|
||||
current set and no writes.
|
||||
"""
|
||||
_validate_target(locator, sha256, size, mtime_ns)
|
||||
_validate_epoch(expected_epoch)
|
||||
_validate_int(expected_tag_revision, "expected_tag_revision", 0)
|
||||
tags = normalize_image_tags(tags)
|
||||
with self._pair_transaction() as conn:
|
||||
epoch = self._library_epoch(conn, expected_epoch)
|
||||
now = utc_now_iso()
|
||||
file_id = self._annotation_owner(conn, locator, now)
|
||||
byte_id, revision, created, refingerprinted = self._annotation_byte(
|
||||
conn, file_id, sha256, size, mtime_ns, now
|
||||
)
|
||||
current = self._byte_tags(conn, byte_id)
|
||||
if (
|
||||
not created
|
||||
and current == tags
|
||||
and expected_tag_revision in (revision, revision - 1)
|
||||
):
|
||||
if refingerprinted:
|
||||
conn.execute(_BUMP_INDEX)
|
||||
return _receipt(epoch, sha256, current, revision, True)
|
||||
if expected_tag_revision != revision:
|
||||
raise AnnotationCommandError(
|
||||
"tag-conflict",
|
||||
epoch,
|
||||
{"sha256": sha256, "tags": current, "tag_revision": revision},
|
||||
)
|
||||
revision = self._write_image_tags(conn, byte_id, tags)
|
||||
return _receipt(epoch, sha256, tags, revision, False)
|
||||
|
||||
def add_image_tags(
|
||||
self,
|
||||
*,
|
||||
locator: str,
|
||||
sha256: str,
|
||||
size: int,
|
||||
mtime_ns: int,
|
||||
expected_epoch: str,
|
||||
tags: list,
|
||||
) -> dict:
|
||||
"""Union ``tags`` into the set of these exact bytes, keeping stored spelling.
|
||||
|
||||
``added`` counts the names that were new (case-insensitively). A union
|
||||
over ``MAX_IMAGE_TAGS`` is ``tag-limit`` with the current set and no
|
||||
writes; a union that adds nothing to an existing record is ``replayed``.
|
||||
"""
|
||||
_validate_target(locator, sha256, size, mtime_ns)
|
||||
_validate_epoch(expected_epoch)
|
||||
tags = normalize_image_tags(tags)
|
||||
with self._pair_transaction() as conn:
|
||||
epoch = self._library_epoch(conn, expected_epoch)
|
||||
now = utc_now_iso()
|
||||
file_id = self._annotation_owner(conn, locator, now)
|
||||
byte_id, revision, created, refingerprinted = self._annotation_byte(
|
||||
conn, file_id, sha256, size, mtime_ns, now
|
||||
)
|
||||
current = self._byte_tags(conn, byte_id)
|
||||
present = {name.casefold() for name in current}
|
||||
added = [name for name in tags if name.casefold() not in present]
|
||||
merged = current + added
|
||||
if len(merged) > MAX_IMAGE_TAGS:
|
||||
raise AnnotationCommandError(
|
||||
"tag-limit",
|
||||
epoch,
|
||||
{"sha256": sha256, "tags": current, "tag_revision": revision},
|
||||
)
|
||||
if not created and not added:
|
||||
if refingerprinted:
|
||||
conn.execute(_BUMP_INDEX)
|
||||
return {**_receipt(epoch, sha256, current, revision, True), "added": 0}
|
||||
revision = self._write_image_tags(conn, byte_id, merged)
|
||||
return {
|
||||
**_receipt(epoch, sha256, merged, revision, False),
|
||||
"added": len(added),
|
||||
}
|
||||
|
||||
def refresh_image_fingerprint(
|
||||
self, locator: str, sha256: str, size: int, mtime_ns: int
|
||||
) -> bool:
|
||||
"""Record that these exact bytes were just seen with this stat.
|
||||
|
||||
True when a byte record was refreshed; nothing is created and no tag
|
||||
or tag revision changes. The image index revision moves because the
|
||||
record's currency for cheap reads may have changed.
|
||||
"""
|
||||
_validate_target(locator, sha256, size, mtime_ns)
|
||||
with self.model.get_connection() as conn:
|
||||
cursor = conn.execute(
|
||||
_REFRESH_BY_LOCATOR, (size, mtime_ns, utc_now_iso(), sha256, locator)
|
||||
)
|
||||
if cursor.rowcount > 0:
|
||||
conn.execute(_BUMP_INDEX)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def current_image_tags(self, stats: dict) -> dict:
|
||||
"""Image tags of the files whose current ``(size, mtime_ns)`` is verified.
|
||||
|
||||
``stats`` maps locators to their present fingerprint. A locator is
|
||||
answered (possibly with an empty set) only when a byte record carries
|
||||
exactly that verified fingerprint; the most recently verified record
|
||||
wins when several do. Lookups go in chunks of 500 locators.
|
||||
"""
|
||||
if not isinstance(stats, dict):
|
||||
raise ValueError(
|
||||
"stats must map locators to (size, mtime_ns) integer pairs"
|
||||
)
|
||||
for locator, fingerprint in stats.items():
|
||||
_validate_fingerprint(locator, fingerprint)
|
||||
locators = list(stats)
|
||||
chosen: dict[str, tuple[tuple[str, str], str]] = {}
|
||||
result: dict[str, list[str]] = {}
|
||||
with self._annotation_read() as conn:
|
||||
for start in range(0, len(locators), _LOOKUP_CHUNK):
|
||||
chunk = locators[start : start + _LOOKUP_CHUNK]
|
||||
marks = ",".join("?" * len(chunk))
|
||||
for locator, byte_id, size, mtime_ns, verified_at in conn.execute(
|
||||
_FINGERPRINTS.format(marks=marks), chunk
|
||||
):
|
||||
if (size, mtime_ns) != stats[locator]:
|
||||
continue
|
||||
key = (verified_at, byte_id)
|
||||
if locator not in chosen or key > chosen[locator][0]:
|
||||
chosen[locator] = (key, byte_id)
|
||||
owners = {byte_id: locator for locator, (_key, byte_id) in chosen.items()}
|
||||
result = {locator: [] for locator in chosen}
|
||||
byte_ids = list(owners)
|
||||
for start in range(0, len(byte_ids), _LOOKUP_CHUNK):
|
||||
chunk = byte_ids[start : start + _LOOKUP_CHUNK]
|
||||
marks = ",".join("?" * len(chunk))
|
||||
for byte_id, name in conn.execute(_TAGS_OF.format(marks=marks), chunk):
|
||||
result[owners[byte_id]].append(name)
|
||||
return result
|
||||
|
||||
def iter_image_tag_records(self) -> Iterator[tuple[str, int, int, list[str]]]:
|
||||
"""Stream every byte record as ``(locator, size, mtime_ns, tags)``.
|
||||
|
||||
One SELECT ordered by locator, then by the bytes' first sighting, with
|
||||
each set's names in position order.
|
||||
"""
|
||||
conn = self.model.get_connection()
|
||||
current: tuple | None = None
|
||||
for locator, byte_id, size, mtime_ns, name in conn.execute(_RECORDS):
|
||||
if current is None or current[0] != byte_id:
|
||||
if current is not None:
|
||||
yield current[1]
|
||||
current = (byte_id, (locator, size, mtime_ns, []))
|
||||
if name is not None:
|
||||
current[1][3].append(name)
|
||||
if current is not None:
|
||||
yield current[1]
|
||||
+24
-11
@@ -4,9 +4,17 @@ import os
|
||||
import sqlite3
|
||||
|
||||
try:
|
||||
from ..utils.inspection_response import MAX_LIBRARY_BYTES, _library_projection
|
||||
from ..utils.inspection_response import (
|
||||
MAX_LIBRARY_BYTES,
|
||||
_library_projection,
|
||||
library_metadata,
|
||||
)
|
||||
except ImportError:
|
||||
from utils.inspection_response import MAX_LIBRARY_BYTES, _library_projection
|
||||
from utils.inspection_response import (
|
||||
MAX_LIBRARY_BYTES,
|
||||
_library_projection,
|
||||
library_metadata,
|
||||
)
|
||||
|
||||
|
||||
def _display_bytes(value: bytes | None) -> str | None:
|
||||
@@ -52,15 +60,21 @@ def _saved_projection(conn: sqlite3.Connection, row: sqlite3.Row) -> tuple[dict,
|
||||
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,
|
||||
recipe_tags, limited = (
|
||||
_tags(conn, pair["id"], pair=True) if pair is not None else ([], False)
|
||||
)
|
||||
positive_tags = []
|
||||
if not recipe_tags and not limited:
|
||||
# D39: only read the positive's tags when they are what the panel shows.
|
||||
positive_tags, limited = _tags(conn, row["prompt_id"], pair=False)
|
||||
metadata = library_metadata(
|
||||
row["category"],
|
||||
positive_tags,
|
||||
(pair["category"], recipe_tags) if pair is not None else None,
|
||||
)
|
||||
limited = limited or any(
|
||||
value is not None and len(value) > MAX_LIBRARY_BYTES
|
||||
for value in (title, category)
|
||||
for value in (title, metadata["category"])
|
||||
)
|
||||
if pair is not None:
|
||||
saved["pair"] = {
|
||||
@@ -73,9 +87,8 @@ def _saved_projection(conn: sqlite3.Connection, row: sqlite3.Row) -> tuple[dict,
|
||||
"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",
|
||||
**metadata,
|
||||
"category": _display_bytes(metadata["category"]),
|
||||
}
|
||||
)
|
||||
if limited:
|
||||
|
||||
@@ -10,7 +10,7 @@ import re
|
||||
import sqlite3
|
||||
from typing import Iterator
|
||||
|
||||
from .execution_contexts import content_digest
|
||||
from .execution_contexts import _hints, _values, content_digest
|
||||
from .execution_history import HistoryEpochChanged
|
||||
from .execution_outputs import _capture, _REFERENCE_COLUMNS
|
||||
from .library import _ELIGIBLE
|
||||
@@ -98,6 +98,8 @@ _ORDER = (
|
||||
"r.observed_dequeue_at DESC,r.session_id COLLATE BINARY DESC,"
|
||||
"r.queue_item_id DESC,r.run_id COLLATE BINARY DESC"
|
||||
)
|
||||
# Stale-hint rows a newest-pair lookup inspects before it reports none.
|
||||
_PAIR_RUN_CANDIDATES = 16
|
||||
_MATCHING = (
|
||||
"SELECT DISTINCT rc.run_id FROM execution_prompt_snapshots s "
|
||||
"JOIN execution_contexts c ON c.positive_snapshot_id=s.snapshot_id "
|
||||
@@ -410,6 +412,62 @@ class HistoryReadOperations:
|
||||
result["context_summaries"] = list(ordered.values())
|
||||
return result
|
||||
|
||||
def get_newest_pair_run(self, positive_id: int, pair_id: int) -> dict | None:
|
||||
"""The newest matching run whose current saved-pair hint names `pair_id`.
|
||||
|
||||
None when the positive is not an eligible saved prompt or the pair is
|
||||
not one of its pairs; ``{"run": None}`` when no recorded run names it.
|
||||
Only a hint captured in this epoch and still resolving to the same pair
|
||||
counts, as in ``get_history_run``.
|
||||
"""
|
||||
conn = self._history_connection()
|
||||
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 or (
|
||||
conn.execute(
|
||||
"SELECT 1 FROM prompt_pairs WHERE id=? AND positive_prompt_id=?",
|
||||
(pair_id, positive_id),
|
||||
).fetchone()
|
||||
is None
|
||||
):
|
||||
return None
|
||||
if saved["text"] is None:
|
||||
return {"run": None}
|
||||
epoch = self.get_execution_epoch()
|
||||
candidates = conn.execute(
|
||||
"SELECT r.run_id,rc.anchor_id,rc.context_id,"
|
||||
"c.positive_snapshot_id,c.negative_snapshot_id "
|
||||
"FROM execution_runs r JOIN execution_run_contexts rc "
|
||||
"ON rc.run_id=r.run_id JOIN execution_contexts c "
|
||||
"ON c.context_id=rc.context_id JOIN execution_prompt_snapshots s "
|
||||
"ON s.snapshot_id=c.positive_snapshot_id "
|
||||
"WHERE s.raw_text_id=? AND rc.pair_id=? AND rc.reference_epoch=? "
|
||||
f"ORDER BY {_ORDER},rc.anchor_id COLLATE BINARY LIMIT ?",
|
||||
(
|
||||
content_digest("text", (saved["text"],)),
|
||||
pair_id,
|
||||
epoch,
|
||||
_PAIR_RUN_CANDIDATES,
|
||||
),
|
||||
).fetchall()
|
||||
for row in candidates:
|
||||
positive = _values(conn, row["positive_snapshot_id"])
|
||||
negative = _values(conn, row["negative_snapshot_id"])
|
||||
if positive is None or positive["raw"] != saved["text"]:
|
||||
continue
|
||||
hints = _hints(conn, positive["raw"], negative["raw"] if negative else None)
|
||||
if hints[2] == pair_id:
|
||||
return {
|
||||
"run": {
|
||||
key: row[key] for key in ("run_id", "anchor_id", "context_id")
|
||||
}
|
||||
}
|
||||
return {"run": None}
|
||||
|
||||
def get_history_run(self, run_id: str) -> dict | None:
|
||||
conn = self._history_connection()
|
||||
run = self.get_execution_run(run_id)
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Persisted image favorites, keyed by gallery root + relative path.
|
||||
|
||||
The root key is the root's real, case-normalized path and the relative key is
|
||||
case-normalized the same way (see ``py/api/images.py``); both stay
|
||||
server-side. Folder names alone are not unique across roots, so a favorite
|
||||
follows the directory itself when the configured roots are reordered.
|
||||
Optional in old backups, required once a database is current (see
|
||||
``library_schema.py``).
|
||||
"""
|
||||
|
||||
import re
|
||||
import sqlite3
|
||||
from typing import Iterable
|
||||
|
||||
TABLE = "image_favorites"
|
||||
MAX_KEY_LENGTH = 4096
|
||||
_LOOKUP_CHUNK = 500
|
||||
_DDL = (
|
||||
f"CREATE TABLE {TABLE} (root_key TEXT NOT NULL, relative_path TEXT NOT NULL, "
|
||||
"created_at TEXT NOT NULL, PRIMARY KEY(root_key, relative_path))"
|
||||
)
|
||||
_AUTOINDEX = f"sqlite_autoindex_{TABLE}_1"
|
||||
_COLUMNS = (
|
||||
("root_key", "TEXT", 1, 1),
|
||||
("relative_path", "TEXT", 1, 2),
|
||||
("created_at", "TEXT", 1, 0),
|
||||
)
|
||||
|
||||
|
||||
def _compact(sql: str) -> str:
|
||||
return re.sub(r"\s+", "", sql).lower()
|
||||
|
||||
|
||||
def image_favorites_schema_error(
|
||||
conn: sqlite3.Connection, *, allow_missing: bool = True
|
||||
) -> str | None:
|
||||
"""Reject unsupported favorites schemas and invalid persisted values.
|
||||
|
||||
Reads only. A missing table is acceptable for backups that predate it
|
||||
unless ``allow_missing`` is false.
|
||||
"""
|
||||
objects = conn.execute(
|
||||
"SELECT type,name,sql FROM sqlite_master WHERE name=? "
|
||||
"OR (type IN ('index','trigger') AND tbl_name=?)",
|
||||
(TABLE, TABLE),
|
||||
).fetchall()
|
||||
if not objects:
|
||||
return None if allow_missing else "Missing image favorites schema"
|
||||
tables = [sql for kind, _name, sql in objects if kind == "table"]
|
||||
others = {(kind, name, sql) for kind, name, sql in objects if kind != "table"}
|
||||
if len(tables) != 1 or others != {("index", _AUTOINDEX, None)}:
|
||||
return "Image favorites name is reserved by an unsupported schema object"
|
||||
if _compact(tables[0] or "") != _compact(_DDL):
|
||||
return "Image favorites has an invalid table definition"
|
||||
columns = tuple(
|
||||
(row[1], row[2].upper(), row[3], row[5])
|
||||
for row in conn.execute(f"PRAGMA table_info({TABLE})")
|
||||
)
|
||||
if columns != _COLUMNS:
|
||||
return "Image favorites has invalid columns"
|
||||
# Bytes bound characters from above (UTF-8 is at most 4 bytes each).
|
||||
limit = MAX_KEY_LENGTH * 4
|
||||
if conn.execute(
|
||||
f"SELECT 1 FROM {TABLE} WHERE typeof(root_key) != 'text' "
|
||||
"OR typeof(relative_path) != 'text' OR typeof(created_at) != 'text' "
|
||||
f"OR length(CAST(root_key AS BLOB)) > {limit} "
|
||||
f"OR length(CAST(relative_path AS BLOB)) > {limit} "
|
||||
"OR length(CAST(created_at AS BLOB)) > 64 LIMIT 1"
|
||||
).fetchone():
|
||||
return "Image favorites contains invalid values"
|
||||
for root, path in conn.execute(
|
||||
f"SELECT CAST(root_key AS BLOB), CAST(relative_path AS BLOB) FROM {TABLE} "
|
||||
f"WHERE length(CAST(root_key AS BLOB)) > {MAX_KEY_LENGTH} "
|
||||
f"OR length(CAST(relative_path AS BLOB)) > {MAX_KEY_LENGTH}"
|
||||
):
|
||||
for raw in (root, path):
|
||||
if len(bytes(raw).decode("utf-8", "replace")) > MAX_KEY_LENGTH:
|
||||
return "Image favorites contains invalid values"
|
||||
return None
|
||||
|
||||
|
||||
def migrate_image_favorites(conn: sqlite3.Connection) -> None:
|
||||
"""Install the table atomically; validate before and after creating it."""
|
||||
conn.execute("SAVEPOINT image_favorites_schema")
|
||||
try:
|
||||
error = image_favorites_schema_error(conn)
|
||||
if error:
|
||||
raise sqlite3.DatabaseError(error)
|
||||
conn.execute(_DDL.replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS", 1))
|
||||
error = image_favorites_schema_error(conn, allow_missing=False)
|
||||
if error:
|
||||
raise sqlite3.DatabaseError(error)
|
||||
conn.execute("RELEASE image_favorites_schema")
|
||||
except BaseException:
|
||||
conn.execute("ROLLBACK TO image_favorites_schema")
|
||||
conn.execute("RELEASE image_favorites_schema")
|
||||
raise
|
||||
|
||||
|
||||
class ImageFavoriteOperations:
|
||||
"""PromptDatabase mixin: set, clear and look up image favorites."""
|
||||
|
||||
def set_image_favorite(self, root_key: str, relative_path: str) -> bool:
|
||||
"""Mark one file as a favorite; repeating it is a no-op. Returns True."""
|
||||
from .models import utc_now_iso # models imports this module's schema
|
||||
|
||||
with self.model.get_connection() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO image_favorites VALUES (?, ?, ?)",
|
||||
(root_key, relative_path, utc_now_iso()),
|
||||
)
|
||||
return True
|
||||
|
||||
def clear_image_favorite(self, root_key: str, relative_path: str) -> bool:
|
||||
"""Remove one favorite; True when a row was removed."""
|
||||
with self.model.get_connection() as conn:
|
||||
cursor = conn.execute(
|
||||
"DELETE FROM image_favorites WHERE root_key=? AND relative_path=?",
|
||||
(root_key, relative_path),
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def get_image_favorites(
|
||||
self, root_key: str, relative_paths: Iterable[str]
|
||||
) -> set[str]:
|
||||
"""The subset of *relative_paths* favorited under *root_key*."""
|
||||
wanted = list(dict.fromkeys(relative_paths))
|
||||
found: set[str] = set()
|
||||
conn = self.model.get_connection()
|
||||
for start in range(0, len(wanted), _LOOKUP_CHUNK):
|
||||
chunk = wanted[start : start + _LOOKUP_CHUNK]
|
||||
marks = ",".join("?" * len(chunk))
|
||||
rows = conn.execute( # only "?" placeholders are interpolated
|
||||
"SELECT relative_path FROM image_favorites WHERE root_key=? "
|
||||
f"AND relative_path IN ({marks})", # nosec B608
|
||||
(root_key, *chunk),
|
||||
)
|
||||
found.update(path for (path,) in rows)
|
||||
return found
|
||||
|
||||
def get_root_favorites(self, root_key: str) -> set[str]:
|
||||
"""Every relative key favorited under *root_key* (a primary-key range).
|
||||
|
||||
The Favorites listing (``favorites=1``) intersects these with the
|
||||
root's scan, so its size follows the root's favorites, not its files.
|
||||
"""
|
||||
conn = self.model.get_connection()
|
||||
rows = conn.execute(
|
||||
"SELECT relative_path FROM image_favorites WHERE root_key=?",
|
||||
(root_key,),
|
||||
)
|
||||
return {path for (path,) in rows}
|
||||
+91
-8
@@ -1,5 +1,6 @@
|
||||
"""Read-only positive-parent views over existing prompt and pair tables."""
|
||||
|
||||
import datetime
|
||||
import sqlite3
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
@@ -13,10 +14,79 @@ _ELIGIBLE = """(EXISTS(SELECT 1 FROM prompt_pairs ep WHERE ep.positive_prompt_id
|
||||
_FROM = "prompts p LEFT JOIN prompt_library_state ls ON ls.prompt_id=p.id"
|
||||
# The characters JavaScript's String.prototype.trim() removes, so a title the
|
||||
# display treats as blank also falls back to the prompt text for name sorting.
|
||||
_BLANK = (
|
||||
"char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,"
|
||||
"8200,8201,8202,8232,8233,8239,8287,12288,65279)"
|
||||
)
|
||||
_TRIM_CODES = (9, 10, 11, 12, 13, 32, 160, 5760, *range(8192, 8203))
|
||||
_TRIM_CODES += (8232, 8233, 8239, 8287, 12288, 65279)
|
||||
_BLANK = f"char({','.join(map(str, _TRIM_CODES))})"
|
||||
_JS_TRIM = "".join(map(chr, _TRIM_CODES))
|
||||
# The Library's display title: a stored title, else the prompt's first line.
|
||||
DISPLAY_TITLE_LIMIT = 80
|
||||
|
||||
|
||||
def _utf16_units(text: str) -> int:
|
||||
"""JavaScript ``length``: characters beyond the BMP count as two units."""
|
||||
return len(text) + sum(1 for ch in text if ord(ch) > 0xFFFF)
|
||||
|
||||
|
||||
def _utf16_prefix(text: str, units: int) -> str:
|
||||
"""The longest prefix within ``units`` UTF-16 units, never splitting a pair."""
|
||||
used = 0
|
||||
for index, ch in enumerate(text):
|
||||
used += 2 if ord(ch) > 0xFFFF else 1
|
||||
if used > units:
|
||||
return text[:index]
|
||||
return text
|
||||
|
||||
|
||||
def titled(stored_titles: tuple, text: object) -> tuple[str | None, str | None]:
|
||||
"""Return ``(title, source)``: a stored title, else the text's first line.
|
||||
|
||||
``source`` is ``"stored"`` for the first non-blank stored title (blank as
|
||||
in JavaScript's trim, like the Library view), ``"text"`` for the first line,
|
||||
and None when both are empty. The first line is trimmed and, past
|
||||
DISPLAY_TITLE_LIMIT UTF-16 units, cut with an ellipsis like the Library's
|
||||
``libraryTitle()`` (never inside a surrogate pair).
|
||||
"""
|
||||
for title in stored_titles:
|
||||
if isinstance(title, str) and title.strip(_JS_TRIM):
|
||||
return title, "stored"
|
||||
if not isinstance(text, str):
|
||||
return None, None
|
||||
first = text.split("\n")[0].strip(_JS_TRIM)
|
||||
if _utf16_units(first) > DISPLAY_TITLE_LIMIT:
|
||||
first = _utf16_prefix(first, DISPLAY_TITLE_LIMIT) + "\u2026"
|
||||
return (first, "text") if first else (None, None)
|
||||
|
||||
|
||||
def display_title(stored_titles: tuple, text: object) -> str | None:
|
||||
"""The Library display title of ``titled``, without its source."""
|
||||
return titled(stored_titles, text)[0]
|
||||
|
||||
|
||||
def utc_iso(value: object) -> str | None:
|
||||
"""A stored timestamp as UTC ISO text, or None when absent or unreadable.
|
||||
|
||||
Usage writes are already UTC ISO; a naive value (SQLite's CURRENT_TIMESTAMP
|
||||
form) is read as UTC, and an offset is converted to UTC. One bad row must
|
||||
not fail a whole Library read, so a value past the datetime range after
|
||||
conversion (OverflowError) is unreadable too. Python 3.11+ fromisoformat
|
||||
accepts more ISO forms than 3.10 (e.g. a 2-digit fraction); every form the
|
||||
app writes (utc_now_iso, the 3.2.4 backfill, CURRENT_TIMESTAMP, Z) parses
|
||||
the same on both, so only hand-edited values can differ by version.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
text = value.strip()
|
||||
if text.endswith(("Z", "z")):
|
||||
text = text[:-1] + "+00:00"
|
||||
try:
|
||||
stamp = datetime.datetime.fromisoformat(text)
|
||||
if stamp.tzinfo is None:
|
||||
stamp = stamp.replace(tzinfo=datetime.timezone.utc)
|
||||
return stamp.astimezone(datetime.timezone.utc).isoformat()
|
||||
except (ValueError, OverflowError):
|
||||
return None
|
||||
|
||||
|
||||
_NAME = (
|
||||
f"LOWER(CASE WHEN TRIM(COALESCE(ls.title,''),{_BLANK})!='' "
|
||||
"THEN ls.title ELSE p.text END)"
|
||||
@@ -145,6 +215,7 @@ class LibraryOperations:
|
||||
) -> list[dict]:
|
||||
rows = conn.execute(
|
||||
"SELECT p.id,p.text,ls.title,p.category,p.rating,p.notes,p.created_at,"
|
||||
"p.run_count,p.last_used_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,"
|
||||
@@ -165,6 +236,9 @@ class LibraryOperations:
|
||||
):
|
||||
by_id[row["prompt_id"]]["tags"].append(row["name"])
|
||||
for row in result:
|
||||
# Usage is shown, never compared: the token hashes owner fields only.
|
||||
row["run_count"] = row["run_count"] if type(row["run_count"]) is int else 0
|
||||
row["last_used_at"] = utc_iso(row["last_used_at"])
|
||||
row["edit_token"] = library_edit_token(epoch, row)
|
||||
return result
|
||||
|
||||
@@ -196,9 +270,15 @@ class LibraryOperations:
|
||||
ceiling = after["ceiling"]
|
||||
where += " AND p.id<=?"
|
||||
params.append(ceiling)
|
||||
total = conn.execute(
|
||||
"SELECT COUNT(*) FROM " + _FROM + " WHERE " + where, params
|
||||
).fetchone()[0]
|
||||
# One SELECT counts the filtered parents and their active variants.
|
||||
total, variant_total = conn.execute(
|
||||
"SELECT COUNT(*),COALESCE(SUM((SELECT COUNT(*) FROM prompt_pairs v "
|
||||
"WHERE v.positive_prompt_id=p.id AND v.archived=0)),0) FROM "
|
||||
+ _FROM
|
||||
+ " WHERE "
|
||||
+ where,
|
||||
params,
|
||||
).fetchone()
|
||||
expression, direction = _ORDERS[sort]
|
||||
if after is not None:
|
||||
operator = ">" if direction == "ASC" else "<"
|
||||
@@ -230,6 +310,7 @@ class LibraryOperations:
|
||||
"library_epoch": epoch,
|
||||
"items": rows,
|
||||
"total": total,
|
||||
"variant_total": variant_total,
|
||||
"has_more": has_more,
|
||||
"next_key": next_key,
|
||||
}
|
||||
@@ -307,7 +388,7 @@ class LibraryOperations:
|
||||
for row in conn.execute(
|
||||
"SELECT pp.id,COALESCE(n.text,'') AS negative_text,"
|
||||
"pp.category,pp.title,pp.archived,"
|
||||
"pp.negative_prompt_id IS NULL AS explicit_empty,"
|
||||
"pp.negative_prompt_id IS NULL AS explicit_empty,pp.run_count,"
|
||||
"(SELECT COUNT(DISTINCT image_id) FROM image_prompt_pairs ip "
|
||||
"WHERE ip.pair_id=pp.id) AS image_count "
|
||||
"FROM prompt_pairs pp "
|
||||
@@ -323,6 +404,8 @@ class LibraryOperations:
|
||||
row["tags"] = []
|
||||
row["archived"] = bool(row["archived"])
|
||||
row["explicit_empty"] = bool(row["explicit_empty"])
|
||||
# The history lane's "N runs" (#249): queue-hook pair uses, 0 when unset.
|
||||
row["run_count"] = row["run_count"] if type(row["run_count"]) is int else 0
|
||||
if by_id:
|
||||
marks = ",".join("?" for _ in by_id)
|
||||
for row in conn.execute(
|
||||
|
||||
@@ -16,6 +16,9 @@ from pathlib import Path
|
||||
from typing import Optional, Set, Tuple
|
||||
|
||||
from .library_schema import library_schema_error, migrate_library_state
|
||||
from .image_favorites import image_favorites_schema_error, migrate_image_favorites
|
||||
from .annotation_schema import annotation_schema_error, migrate_annotation_state
|
||||
from .saved_views import migrate_saved_views, saved_views_schema_error
|
||||
from .execution_history_schema import (
|
||||
history_schema_error,
|
||||
migrate_execution_history,
|
||||
@@ -298,6 +301,9 @@ class PromptModel:
|
||||
self._migrate_prompt_pairs(conn)
|
||||
migrate_execution_history(conn)
|
||||
migrate_library_state(conn)
|
||||
migrate_image_favorites(conn)
|
||||
migrate_saved_views(conn)
|
||||
migrate_annotation_state(conn)
|
||||
|
||||
def _migrate_prompt_pairs(self, conn: sqlite3.Connection) -> None:
|
||||
conn.execute("SAVEPOINT prompt_pair_schema")
|
||||
@@ -1016,6 +1022,10 @@ class PromptModel:
|
||||
|
||||
Args:
|
||||
path: File to inspect
|
||||
require_library_state: Also require the tables every current
|
||||
database has (Library state, image favorites, saved views
|
||||
and image annotations), as a migrated restore stage must;
|
||||
old backups may lack them.
|
||||
|
||||
Returns:
|
||||
(ok, reason): reason is "ok" when the file is acceptable, otherwise
|
||||
@@ -1070,6 +1080,21 @@ class PromptModel:
|
||||
)
|
||||
if library_error:
|
||||
return False, library_error
|
||||
favorites_error = image_favorites_schema_error(
|
||||
conn, allow_missing=not require_library_state
|
||||
)
|
||||
if favorites_error:
|
||||
return False, favorites_error
|
||||
views_error = saved_views_schema_error(
|
||||
conn, allow_missing=not require_library_state
|
||||
)
|
||||
if views_error:
|
||||
return False, views_error
|
||||
annotation_error = annotation_schema_error(
|
||||
conn, allow_missing=not require_library_state
|
||||
)
|
||||
if annotation_error:
|
||||
return False, annotation_error
|
||||
return True, "ok"
|
||||
except sqlite3.Error as e:
|
||||
return False, f"Invalid SQLite database: {e}"
|
||||
|
||||
+371
-8
@@ -6,11 +6,14 @@ import sqlite3
|
||||
import json
|
||||
import datetime
|
||||
import os
|
||||
from typing import Optional, List, Dict, Any
|
||||
from typing import Optional, List, Dict, Any, Set, Tuple
|
||||
|
||||
from .models import PromptModel, normalize_image_path, utc_now_iso
|
||||
from .pairs import PairDependencyError, PairOperations
|
||||
from .library import LibraryOperations
|
||||
from .library import LibraryOperations, titled
|
||||
from .image_favorites import ImageFavoriteOperations
|
||||
from .annotations import AnnotationOperations
|
||||
from .saved_views import SavedViewOperations
|
||||
from .library_commands import LibraryCommandOperations
|
||||
from .vocabulary import VocabularyOperations
|
||||
from .execution_history import ExecutionHistoryOperations
|
||||
@@ -179,6 +182,154 @@ AND p.id NOT IN (SELECT prompt_id FROM live_hint_prompts)
|
||||
"""
|
||||
|
||||
|
||||
# The scan's known set (#249 Activity-2): every image row, with its stored file
|
||||
# size and whether its pair link is resolved: exactly one distinct pair, the rule
|
||||
# _LINK_SUMMARY_SQL and get_image_prompt_info use. A file is known and resolved
|
||||
# when it has exactly one image row, that row is resolved, and the row's stored
|
||||
# size equals the file's size now; several rows (one file linked to more than
|
||||
# one prompt) are ambiguous, and an unknown (NULL) stored size is not known. The
|
||||
# rows are streamed in fetches of KNOWN_PATHS_FETCH_SIZE and only the scanned
|
||||
# paths are kept, so memory follows the scan (at most MAX_SCAN_FILES paths), not
|
||||
# the table.
|
||||
_KNOWN_PATHS_SQL = """SELECT gi.file_path, gi.image_path, gi.file_size,
|
||||
(SELECT COUNT(DISTINCT ip.pair_id) FROM image_prompt_pairs ip
|
||||
WHERE ip.image_id = gi.id) = 1
|
||||
FROM generated_images gi"""
|
||||
KNOWN_PATHS_FETCH_SIZE = 1000
|
||||
|
||||
# One SELECT reads the link summaries of at most this many paths: three bound
|
||||
# parameters per path stays under SQLite's historical 999-variable limit.
|
||||
LINK_SUMMARY_BATCH = 200
|
||||
# A pair link counts only when an image has exactly one distinct pair, as in
|
||||
# get_image_prompt_info; its positive (text, Library title, category, rating)
|
||||
# then describes the card. The pair's own title names its negative variant, so
|
||||
# it is not read (D10).
|
||||
_LINK_SUMMARY_SQL = """SELECT gi.image_path, gi.file_path, gi.prompt_id,
|
||||
p.text, p.category, p.rating, ls.title,
|
||||
pp.positive_prompt_id, pt.text, pt.category, pt.rating, pls.title
|
||||
FROM generated_images gi
|
||||
JOIN prompts p ON gi.prompt_id = p.id
|
||||
LEFT JOIN prompt_library_state ls ON ls.prompt_id = p.id
|
||||
LEFT JOIN prompt_pairs pp ON pp.id = (
|
||||
SELECT CASE WHEN COUNT(DISTINCT ip.pair_id) = 1 THEN MIN(ip.pair_id) END
|
||||
FROM image_prompt_pairs ip WHERE ip.image_id = gi.id)
|
||||
LEFT JOIN prompts pt ON pt.id = pp.positive_prompt_id
|
||||
LEFT JOIN prompt_library_state pls ON pls.prompt_id = pp.positive_prompt_id
|
||||
WHERE gi.image_path IN ({marks}) OR gi.file_path IN ({marks}){fallback}"""
|
||||
# One image row describes a card only when no other row claims the same file
|
||||
# (as in get_image_link_summaries, several rows are ambiguous). The pair scope
|
||||
# and the linked positive follow _LINK_SUMMARY_SQL; category, rating, tags and
|
||||
# the searchable text and titles come from that positive (spec D12/D13). Every
|
||||
# writer since the 3.2.4 migration stores file_path, so it is the path key.
|
||||
# The prompt-level conditions are uncorrelated IN sets SQLite builds once, and
|
||||
# the CASE evaluates the per-row ambiguity probe only for admitted rows.
|
||||
_LINK_FILTER_SQL = """SELECT gi.file_path, p.rating, {hit}, p.id
|
||||
FROM generated_images gi
|
||||
LEFT JOIN prompt_pairs pp ON pp.id = (
|
||||
SELECT CASE WHEN COUNT(DISTINCT ip.pair_id) = 1 THEN MIN(ip.pair_id) END
|
||||
FROM image_prompt_pairs ip WHERE ip.image_id = gi.id)
|
||||
JOIN prompts p ON p.id = COALESCE(pp.positive_prompt_id, gi.prompt_id)
|
||||
WHERE gi.file_path IS NOT NULL AND CASE WHEN {admit} THEN NOT EXISTS (
|
||||
SELECT 1 FROM generated_images other
|
||||
WHERE other.file_path = gi.file_path AND other.id <> gi.id) ELSE 0 END"""
|
||||
# The linked positive of each given path (one unambiguous link, pair scope as
|
||||
# in _LINK_FILTER_SQL) with its category; {hit} as in link_filter_query. Used
|
||||
# for the few files whose current image tags meet a Tags filter (#249 B3).
|
||||
_LINK_FACTS_SQL = """SELECT gi.file_path, p.rating, {hit}, p.id, p.category
|
||||
FROM generated_images gi
|
||||
LEFT JOIN prompt_pairs pp ON pp.id = (
|
||||
SELECT CASE WHEN COUNT(DISTINCT ip.pair_id) = 1 THEN MIN(ip.pair_id) END
|
||||
FROM image_prompt_pairs ip WHERE ip.image_id = gi.id)
|
||||
JOIN prompts p ON p.id = COALESCE(pp.positive_prompt_id, gi.prompt_id)
|
||||
WHERE gi.file_path IN ({marks}) AND NOT EXISTS (
|
||||
SELECT 1 FROM generated_images other
|
||||
WHERE other.file_path = gi.file_path AND other.id <> gi.id)"""
|
||||
_POSITIVE_TAG_NAMES_SQL = (
|
||||
"SELECT pt.prompt_id, t.name FROM prompt_tags pt JOIN tags t "
|
||||
"ON t.id = pt.tag_id WHERE pt.prompt_id IN ({marks})"
|
||||
)
|
||||
_FACTS_CHUNK = 500
|
||||
# The positive's text, its Library title or one of its tag names contains
|
||||
# :pattern (LIKE, so ASCII case is ignored as in Library search). A pair's own
|
||||
# title names its negative variant and is never searched (#249 D12).
|
||||
_LINK_FILTER_HIT = """p.id IN (
|
||||
SELECT hp.id FROM prompts hp
|
||||
LEFT JOIN prompt_library_state hls ON hls.prompt_id = hp.id
|
||||
WHERE hp.text LIKE :pattern ESCAPE '\\' OR hls.title LIKE :pattern ESCAPE '\\'
|
||||
OR hp.id IN (SELECT ht.prompt_id FROM prompt_tags ht JOIN tags hn
|
||||
ON hn.id = ht.tag_id WHERE hn.name LIKE :pattern ESCAPE '\\'))"""
|
||||
_LINK_FILTER_TAG = (
|
||||
"p.id IN (SELECT ft.prompt_id FROM prompt_tags ft JOIN tags fn "
|
||||
"ON fn.id = ft.tag_id WHERE fn.name = :tag{index})"
|
||||
)
|
||||
|
||||
|
||||
def link_filter_query(
|
||||
q: str, category: Optional[str], tags: List[str], *, every_link: bool = False
|
||||
) -> Tuple[str, Dict[str, Any]]:
|
||||
"""The workspace filter SELECT and its named parameters (all bound).
|
||||
|
||||
Only code constants and enumerate indices are formatted into the SQL; see
|
||||
PromptDatabase.get_image_link_matches for the semantics.
|
||||
"""
|
||||
params: Dict[str, Any] = {}
|
||||
hit = "1"
|
||||
if q:
|
||||
hit = _LINK_FILTER_HIT
|
||||
params["pattern"] = f"%{escape_like(q)}%"
|
||||
admitted = []
|
||||
if category is not None:
|
||||
admitted.append("p.category = :category")
|
||||
params["category"] = category
|
||||
for index, tag in enumerate(tags):
|
||||
admitted.append(_LINK_FILTER_TAG.format(index=index))
|
||||
params[f"tag{index}"] = tag
|
||||
if q and not admitted and not every_link:
|
||||
admitted.append(hit)
|
||||
sql = _LINK_FILTER_SQL.format(hit=hit, admit=" AND ".join(admitted) or "1")
|
||||
return sql, params
|
||||
|
||||
|
||||
_ASCII_FOLD = str.maketrans("ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz")
|
||||
|
||||
|
||||
def _like_suffix(value: object, suffix: str) -> bool:
|
||||
"""Python form of SQLite's ``value LIKE '%' || suffix``: ASCII-only case."""
|
||||
return isinstance(value, str) and value.translate(_ASCII_FOLD).endswith(
|
||||
suffix.translate(_ASCII_FOLD)
|
||||
)
|
||||
|
||||
|
||||
def _empty_link_summary(status: str) -> Dict[str, Any]:
|
||||
return {
|
||||
"status": status,
|
||||
"positive_id": None,
|
||||
"title": None,
|
||||
"title_source": None,
|
||||
"category": None,
|
||||
"rating": None,
|
||||
}
|
||||
|
||||
|
||||
def _link_summary(row: tuple) -> Dict[str, Any]:
|
||||
if row[7] is not None:
|
||||
positive, text, category, rating = row[7], row[8], row[9], row[10]
|
||||
# The linked positive's Library title (D10/D20).
|
||||
titles = (row[11],)
|
||||
else:
|
||||
positive, text, category, rating = row[2], row[3], row[4], row[5]
|
||||
titles = (row[6],)
|
||||
title, source = titled(titles, text)
|
||||
return {
|
||||
"status": "linked",
|
||||
"positive_id": positive,
|
||||
"title": title,
|
||||
"title_source": source,
|
||||
"category": category,
|
||||
"rating": rating,
|
||||
}
|
||||
|
||||
|
||||
class PromptDatabase(
|
||||
PairOperations,
|
||||
LibraryOperations,
|
||||
@@ -188,6 +339,9 @@ class PromptDatabase(
|
||||
ExecutionContextOperations,
|
||||
ExecutionOutputOperations,
|
||||
HistoryReadOperations,
|
||||
ImageFavoriteOperations,
|
||||
AnnotationOperations,
|
||||
SavedViewOperations,
|
||||
):
|
||||
"""Database operations class for managing prompts."""
|
||||
|
||||
@@ -949,16 +1103,39 @@ class PromptDatabase(
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _bump_image_tag_bytes(conn: sqlite3.Connection, tag_id: int) -> int:
|
||||
"""Move the tag revision of every image set that carries *tag_id*.
|
||||
|
||||
Vocabulary rename, merge and delete change what an image's set reads
|
||||
as, so a Tag dialog opened before the change must see ``tag-conflict``
|
||||
on Save instead of recreating the old name. Bumps the image index
|
||||
revision once when any set was affected; returns that count.
|
||||
"""
|
||||
cursor = conn.execute(
|
||||
"UPDATE media_annotation_bytes SET tag_revision = tag_revision + 1 "
|
||||
"WHERE id IN (SELECT byte_id FROM media_image_tags WHERE tag_id = ?)",
|
||||
(tag_id,),
|
||||
)
|
||||
if cursor.rowcount > 0:
|
||||
conn.execute(
|
||||
"UPDATE media_annotation_metadata SET "
|
||||
"image_index_revision = image_index_revision + 1 WHERE singleton_id = 1"
|
||||
)
|
||||
return cursor.rowcount
|
||||
|
||||
@classmethod
|
||||
def _move_tag_links(
|
||||
conn: sqlite3.Connection, source_id: int, target_id: int
|
||||
cls, conn: sqlite3.Connection, source_id: int, target_id: int
|
||||
) -> None:
|
||||
"""Repoint component and pair links from source to target.
|
||||
"""Repoint component, pair and image links from source to target.
|
||||
|
||||
Owners that already carry the target keep that one link; the source's
|
||||
leftover duplicates are removed. The source registry row is left for the
|
||||
caller to delete.
|
||||
leftover duplicates are removed (an image set keeps the target's
|
||||
position). The source registry row is left for the caller to delete.
|
||||
Image sets that carried the source get a new tag revision first.
|
||||
"""
|
||||
for junction in ("prompt_tags", "prompt_pair_tags"):
|
||||
cls._bump_image_tag_bytes(conn, source_id)
|
||||
for junction in ("prompt_tags", "prompt_pair_tags", "media_image_tags"):
|
||||
conn.execute(
|
||||
f"UPDATE OR IGNORE {junction} SET tag_id = ? WHERE tag_id = ?",
|
||||
(target_id, source_id),
|
||||
@@ -1011,6 +1188,7 @@ class PromptDatabase(
|
||||
conn.execute("DELETE FROM tags WHERE id = ?", (old_tag_id,))
|
||||
else:
|
||||
# Simple rename
|
||||
self._bump_image_tag_bytes(conn, old_tag_id)
|
||||
conn.execute(
|
||||
"UPDATE tags SET name = ? WHERE id = ?", (new_name, old_tag_id)
|
||||
)
|
||||
@@ -1052,6 +1230,7 @@ class PromptDatabase(
|
||||
affected = cursor.fetchone()["c"]
|
||||
|
||||
# CASCADE delete handles prompt_tags entries
|
||||
self._bump_image_tag_bytes(conn, tag_id)
|
||||
conn.execute("DELETE FROM tags WHERE id = ?", (tag_id,))
|
||||
conn.commit()
|
||||
|
||||
@@ -1104,7 +1283,10 @@ class PromptDatabase(
|
||||
pair_count = conn.execute(
|
||||
"SELECT COUNT(*) FROM prompt_pair_tags WHERE tag_id = ?", (src_id,)
|
||||
).fetchone()[0]
|
||||
if src_count or pair_count:
|
||||
image_count = conn.execute(
|
||||
"SELECT COUNT(*) FROM media_image_tags WHERE tag_id = ?", (src_id,)
|
||||
).fetchone()[0]
|
||||
if src_count or pair_count or image_count:
|
||||
self._move_tag_links(conn, src_id, target_id)
|
||||
tags_merged += 1
|
||||
affected += src_count
|
||||
@@ -2160,6 +2342,187 @@ class PromptDatabase(
|
||||
"parameters": row[8] if len(row) > 8 else None,
|
||||
}
|
||||
|
||||
def get_known_resolved_paths(self, files: Dict[str, Optional[int]]) -> Set[str]:
|
||||
"""The paths in ``files`` the Library already links with a resolved link.
|
||||
|
||||
``files`` maps each scanned path to its size on disk (None when it could
|
||||
not be read). A path qualifies when exactly one ``generated_images`` row
|
||||
claims the file, that row has exactly one pair link
|
||||
(``image_prompt_pairs``, written only by resolved ingestion), and its
|
||||
stored ``file_size`` is not NULL and equals the given size, so a file
|
||||
replaced at the same path is read again. Files linked without a pair
|
||||
(the monitor's fallback and FIFO links, the legacy positive-only
|
||||
import), linked to several prompts or pairs, or not linked at all do
|
||||
not qualify. Both sides go through ``normalize_image_path`` (absolute,
|
||||
normalized, ``os.path.normcase``), so a Windows row stored in another
|
||||
case or spelling still matches. One query, streamed; returns the
|
||||
matching keys of ``files``.
|
||||
"""
|
||||
wanted: Dict[str, List[Tuple[str, Optional[int]]]] = {}
|
||||
for path, size in files.items():
|
||||
key = normalize_image_path(str(path))
|
||||
wanted.setdefault(key, []).append((str(path), size))
|
||||
if not wanted:
|
||||
return set()
|
||||
rows_seen: Dict[str, int] = {}
|
||||
stored_sizes: Dict[str, int] = {}
|
||||
with self.model.get_connection() as conn:
|
||||
cursor = conn.execute(_KNOWN_PATHS_SQL)
|
||||
while True:
|
||||
rows = cursor.fetchmany(KNOWN_PATHS_FETCH_SIZE)
|
||||
if not rows:
|
||||
break
|
||||
for file_path, image_path, file_size, is_resolved in rows:
|
||||
# image_path is NOT NULL; file_path is NULL only on old rows.
|
||||
key = normalize_image_path(file_path or image_path)
|
||||
if key not in wanted:
|
||||
continue
|
||||
rows_seen[key] = rows_seen.get(key, 0) + 1
|
||||
if is_resolved and file_size is not None:
|
||||
stored_sizes[key] = file_size
|
||||
return {
|
||||
path
|
||||
for key, stored_size in stored_sizes.items()
|
||||
if rows_seen[key] == 1
|
||||
for path, size in wanted[key]
|
||||
if size is not None and size == stored_size
|
||||
}
|
||||
|
||||
def get_image_link_summaries(
|
||||
self, paths: List[str], allow_filename_fallback: bool = True
|
||||
) -> Dict[str, Dict[str, Any]]:
|
||||
"""Summarize each path's linked saved positive for an image card.
|
||||
|
||||
Matching equals get_image_prompt_info: exact image_path or normalized
|
||||
file_path rows win; otherwise, with the fallback, rows whose image_path
|
||||
ends with the basename. One match is linked, several are ambiguous.
|
||||
One SELECT reads up to LINK_SUMMARY_BATCH paths.
|
||||
"""
|
||||
unique = list(dict.fromkeys(paths))
|
||||
summaries: Dict[str, Dict[str, Any]] = {}
|
||||
for start in range(0, len(unique), LINK_SUMMARY_BATCH):
|
||||
batch = unique[start : start + LINK_SUMMARY_BATCH]
|
||||
summaries.update(self._link_summary_batch(batch, allow_filename_fallback))
|
||||
return summaries
|
||||
|
||||
def _link_summary_batch(
|
||||
self, paths: List[str], allow_filename_fallback: bool
|
||||
) -> Dict[str, Dict[str, Any]]:
|
||||
normalized = {path: normalize_image_path(path) for path in paths}
|
||||
basenames = {path: os.path.basename(path) for path in paths}
|
||||
marks = ",".join("?" for _ in paths)
|
||||
params: List[Any] = [*paths, *normalized.values()]
|
||||
fallback = ""
|
||||
if allow_filename_fallback:
|
||||
fallback = "".join(" OR gi.image_path LIKE ? ESCAPE '\\'" for _ in paths)
|
||||
params.extend(f"%{escape_like(basenames[path])}" for path in paths)
|
||||
sql = _LINK_SUMMARY_SQL.format(marks=marks, fallback=fallback)
|
||||
with self.model.get_connection() as conn:
|
||||
rows = conn.execute(sql, params).fetchall()
|
||||
summaries = {}
|
||||
for path in paths:
|
||||
exact = [
|
||||
row for row in rows if row[0] == path or row[1] == normalized[path]
|
||||
]
|
||||
matches = exact or (
|
||||
[row for row in rows if _like_suffix(row[0], basenames[path])]
|
||||
if allow_filename_fallback
|
||||
else []
|
||||
)
|
||||
if len(matches) == 1:
|
||||
summaries[path] = _link_summary(matches[0])
|
||||
else:
|
||||
summaries[path] = _empty_link_summary(
|
||||
"ambiguous" if matches else "none"
|
||||
)
|
||||
return summaries
|
||||
|
||||
def get_image_link_matches(
|
||||
self,
|
||||
q: str,
|
||||
category: Optional[str],
|
||||
tags: List[str],
|
||||
*,
|
||||
every_link: bool = False,
|
||||
wanted: Optional[Set[str]] = None,
|
||||
) -> Dict[str, Tuple[bool, Optional[int], int]]:
|
||||
"""Map normalized paths of unambiguously linked images to
|
||||
(hit, rating, positive id).
|
||||
|
||||
Rows are limited to images whose linked positive has *category* and
|
||||
every tag in *tags* (exact names). ``hit`` says whether *q* occurs in
|
||||
the positive's text, Library title or a tag name; a pair's own title
|
||||
names its negative variant and is not searched.
|
||||
Without category or tags only hits are returned, unless *every_link*
|
||||
asks for every linked image (the rating sort and the positive count).
|
||||
The positive id is the card's linked positive. One SELECT whose rows
|
||||
are streamed; with *wanted* (the listing's normalized paths) only those
|
||||
paths are kept, so memory is bounded by the listing, not the database.
|
||||
"""
|
||||
sql, params = link_filter_query(q, category, tags, every_link=every_link)
|
||||
matches: Dict[str, Tuple[bool, Optional[int], int]] = {}
|
||||
with self.model.get_connection() as conn:
|
||||
for path, rating, matched, positive in conn.execute(sql, params):
|
||||
if wanted is None or path in wanted:
|
||||
matches[path] = (bool(matched), rating, positive)
|
||||
return matches
|
||||
|
||||
def get_image_link_facts(
|
||||
self, paths: List[str], q: str = ""
|
||||
) -> Dict[str, Tuple[bool, Optional[int], int, Optional[str], Tuple[str, ...]]]:
|
||||
"""Map each normalized path with one unambiguous link to its positive's
|
||||
(hit, rating, positive id, category, tag names).
|
||||
|
||||
The link is resolved as in get_image_link_matches, and ``hit`` says
|
||||
whether *q* occurs in the positive's text, Library title or a tag
|
||||
name (always true without *q*). Paths go in chunks of 500 through the
|
||||
``file_path`` index; the tag names are read for those positives only.
|
||||
"""
|
||||
hit, params = "1", {}
|
||||
if q:
|
||||
hit = _LINK_FILTER_HIT
|
||||
params["pattern"] = f"%{escape_like(q)}%"
|
||||
rows: Dict[str, Tuple[bool, Optional[int], int, Optional[str]]] = {}
|
||||
names: Dict[int, List[str]] = {}
|
||||
unique = list(dict.fromkeys(paths))
|
||||
with self.model.get_connection() as conn:
|
||||
for start in range(0, len(unique), _FACTS_CHUNK):
|
||||
chunk = unique[start : start + _FACTS_CHUNK]
|
||||
bound = {f"path{i}": path for i, path in enumerate(chunk)}
|
||||
marks = ",".join(f":{key}" for key in bound)
|
||||
sql = _LINK_FACTS_SQL.format(hit=hit, marks=marks)
|
||||
for path, rating, matched, positive, category in conn.execute(
|
||||
sql, {**params, **bound}
|
||||
):
|
||||
rows[path] = (bool(matched), rating, positive, category)
|
||||
positives = sorted({row[2] for row in rows.values()})
|
||||
for start in range(0, len(positives), _FACTS_CHUNK):
|
||||
chunk = positives[start : start + _FACTS_CHUNK]
|
||||
marks = ",".join("?" * len(chunk))
|
||||
for positive, name in conn.execute(
|
||||
_POSITIVE_TAG_NAMES_SQL.format(marks=marks), chunk
|
||||
):
|
||||
names.setdefault(positive, []).append(name)
|
||||
return {
|
||||
path: (*row, tuple(names.get(row[2], ()))) for path, row in rows.items()
|
||||
}
|
||||
|
||||
def get_image_paths_matching(
|
||||
self, q: str, category: Optional[str], tags: List[str]
|
||||
) -> Set[str]:
|
||||
"""Normalized paths whose linked positive matches *q*, *category* and *tags*.
|
||||
|
||||
See get_image_link_matches; an empty *q* matches every linked image
|
||||
the category and tags admit.
|
||||
"""
|
||||
return {
|
||||
path
|
||||
for path, (hit, _rating, _positive) in self.get_image_link_matches(
|
||||
q, category, tags
|
||||
).items()
|
||||
if hit or not q
|
||||
}
|
||||
|
||||
def get_prompt_id_for_image(self, image_path: str) -> Optional[int]:
|
||||
"""Return the prompt_id linked to an image, or None."""
|
||||
with self.model.get_connection() as conn:
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
"""Persisted saved views (D16): a named Images or Library toolbar state.
|
||||
|
||||
A view's state holds exactly its page's toolbar values and its Refine-this-view
|
||||
refinements, whitelisted per page and stored as compact JSON of at most 4 KB.
|
||||
|
||||
Writes (the API's POST and PATCH) validate strictly. Startup and restore check
|
||||
only the structure (the table, its columns and each row's types, sizes and JSON
|
||||
object), never the value rules: a row saved under older rules must not stop the
|
||||
database from opening. Startup is more tolerant still: a row whose state is not
|
||||
a JSON object is logged by id, left in place and skipped by every read, while
|
||||
restore rejects such a backup. Reads normalize instead, so a stale row opens with its
|
||||
usable values and defaults for the rest. Optional in old backups, required once
|
||||
a database is current (see ``library_schema.py``).
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sqlite3
|
||||
from typing import Optional
|
||||
|
||||
# The models logger's parent ("prompt_manager.database.models" logs there too).
|
||||
_LOGGER = logging.getLogger("prompt_manager.database.saved_views")
|
||||
|
||||
TABLE = "saved_views"
|
||||
PAGES = ("images", "library")
|
||||
# The schema check's literal SQL repeats the name and state caps (names in
|
||||
# bytes, at most 4 per character); keep them in step.
|
||||
MAX_NAME_LENGTH = 100
|
||||
MAX_STATE_BYTES = 4096
|
||||
MAX_TAGS = 20
|
||||
MAX_TAG_LENGTH = 50
|
||||
_IMAGE_SORTS = ("newest", "oldest", "name", "name-desc", "rating")
|
||||
_LIBRARY_SORTS = ("newest", "oldest", "name", "rating")
|
||||
_MEDIA = ("all", "image", "video")
|
||||
_CONTROL = re.compile(r"[\x00-\x1f\x7f]")
|
||||
_SOURCE = re.compile(r"all|0|[1-9][0-9]{0,8}")
|
||||
FALLBACK_NAME = "Saved view"
|
||||
|
||||
|
||||
class SavedViewInvalid(ValueError):
|
||||
"""A request's page, name or state breaks the write rules (a 400)."""
|
||||
|
||||
|
||||
# (kind, limit-or-choices, default) per state key; "text" values are
|
||||
# length-capped. The defaults match the client's (saved-views.mjs FIELDS).
|
||||
_STATE_FIELDS = {
|
||||
"images": {
|
||||
"q": ("text", 500, ""),
|
||||
"category": ("text", 100, ""),
|
||||
"sort": ("choice", _IMAGE_SORTS, "newest"),
|
||||
"source": ("source", None, "all"),
|
||||
"folder": ("text", 1024, ""),
|
||||
"media": ("choice", _MEDIA, "all"),
|
||||
"tags": ("tags", None, ()),
|
||||
# The Favorites page's filter (#249 D51): a view saved there reopens it.
|
||||
"favorites": ("bool", None, False),
|
||||
},
|
||||
"library": {
|
||||
"q": ("text", 500, ""),
|
||||
"category": ("text", 100, ""),
|
||||
"sort": ("choice", _LIBRARY_SORTS, "newest"),
|
||||
"tags": ("tags", None, ()),
|
||||
},
|
||||
}
|
||||
|
||||
_DDL = (
|
||||
f"CREATE TABLE {TABLE} (id INTEGER PRIMARY KEY AUTOINCREMENT, "
|
||||
"page TEXT NOT NULL CHECK(page IN ('images','library')), "
|
||||
"name TEXT NOT NULL, state TEXT NOT NULL, position INTEGER NOT NULL, "
|
||||
"created_at TEXT NOT NULL, updated_at TEXT NOT NULL)"
|
||||
)
|
||||
_COLUMNS = (
|
||||
("id", "INTEGER", 0, 1),
|
||||
("page", "TEXT", 1, 0),
|
||||
("name", "TEXT", 1, 0),
|
||||
("state", "TEXT", 1, 0),
|
||||
("position", "INTEGER", 1, 0),
|
||||
("created_at", "TEXT", 1, 0),
|
||||
("updated_at", "TEXT", 1, 0),
|
||||
)
|
||||
_SELECT = "SELECT id, page, name, state FROM saved_views"
|
||||
|
||||
|
||||
def _compact(sql: str) -> str:
|
||||
return re.sub(r"\s+", "", sql).lower()
|
||||
|
||||
|
||||
def validate_name(name: object) -> str:
|
||||
"""The trimmed name, 1 to 100 characters without control characters."""
|
||||
if not isinstance(name, str):
|
||||
raise SavedViewInvalid("Name must be a string")
|
||||
trimmed = name.strip()
|
||||
if not 1 <= len(trimmed) <= MAX_NAME_LENGTH:
|
||||
raise SavedViewInvalid(f"Name must be 1 to {MAX_NAME_LENGTH} characters")
|
||||
if _CONTROL.search(trimmed):
|
||||
raise SavedViewInvalid("Name contains control characters")
|
||||
return trimmed
|
||||
|
||||
|
||||
def _validate_value(key: str, kind: str, limit, value: object) -> None:
|
||||
"""Raise ``SavedViewInvalid`` when *value* breaks *key*'s write rule."""
|
||||
if kind == "tags":
|
||||
if not isinstance(value, list) or len(value) > MAX_TAGS:
|
||||
raise SavedViewInvalid(
|
||||
f"State '{key}' must be a list of at most {MAX_TAGS}"
|
||||
)
|
||||
for tag in value:
|
||||
if (
|
||||
not isinstance(tag, str)
|
||||
or not tag.strip()
|
||||
or len(tag) > MAX_TAG_LENGTH
|
||||
or "," in tag
|
||||
or _CONTROL.search(tag)
|
||||
):
|
||||
raise SavedViewInvalid(f"State '{key}' holds an invalid tag")
|
||||
return
|
||||
if kind == "bool":
|
||||
if not isinstance(value, bool):
|
||||
raise SavedViewInvalid(f"State '{key}' must be true or false")
|
||||
return
|
||||
if not isinstance(value, str):
|
||||
raise SavedViewInvalid(f"State '{key}' must be a string")
|
||||
if kind == "choice" and value not in limit:
|
||||
raise SavedViewInvalid(f"State '{key}' is not a supported value")
|
||||
if kind == "source" and not _SOURCE.fullmatch(value):
|
||||
raise SavedViewInvalid(f"State '{key}' must be 'all' or a source index")
|
||||
if kind == "text" and (len(value) > limit or _CONTROL.search(value)):
|
||||
raise SavedViewInvalid(f"State '{key}' is too long or has control characters")
|
||||
|
||||
|
||||
def encode_state(page: object, state: object) -> str:
|
||||
"""Validate *state* for *page* and return its compact JSON (at most 4 KB)."""
|
||||
if page not in PAGES:
|
||||
raise SavedViewInvalid("Page must be 'images' or 'library'")
|
||||
fields = _STATE_FIELDS[page]
|
||||
if not isinstance(state, dict):
|
||||
raise SavedViewInvalid("State must be an object")
|
||||
if set(state) != set(fields):
|
||||
raise SavedViewInvalid(
|
||||
f"State for {page} must contain exactly " + ", ".join(sorted(fields))
|
||||
)
|
||||
for key, (kind, limit, _default) in fields.items():
|
||||
_validate_value(key, kind, limit, state[key])
|
||||
encoded = json.dumps(
|
||||
{key: state[key] for key in fields}, ensure_ascii=False, separators=(",", ":")
|
||||
)
|
||||
if len(encoded.encode("utf-8")) > MAX_STATE_BYTES:
|
||||
raise SavedViewInvalid("State must encode to at most 4 KB")
|
||||
return encoded
|
||||
|
||||
|
||||
def _decode_object(raw: object) -> Optional[dict]:
|
||||
"""The JSON object stored in *raw*, or None when it holds no object."""
|
||||
if isinstance(raw, dict):
|
||||
return raw
|
||||
if not isinstance(raw, str):
|
||||
return None
|
||||
try:
|
||||
decoded = json.loads(raw)
|
||||
except (ValueError, RecursionError):
|
||||
return None
|
||||
return decoded if isinstance(decoded, dict) else None
|
||||
|
||||
|
||||
def normalize_state(page: str, raw: object) -> dict:
|
||||
"""The page's full state from a stored row, never raising.
|
||||
|
||||
Every key of the page's current whitelist is present: a stored value that
|
||||
passes today's write rule is kept, anything else (missing, invalid, or a
|
||||
row that is not a JSON object) takes the key's default, and keys the
|
||||
whitelist no longer has are dropped. An unknown page has no keys.
|
||||
"""
|
||||
fields = _STATE_FIELDS.get(page, {})
|
||||
stored = _decode_object(raw) or {}
|
||||
state = {}
|
||||
for key, (kind, limit, default) in fields.items():
|
||||
value = stored.get(key, default)
|
||||
try:
|
||||
_validate_value(key, kind, limit, value)
|
||||
except SavedViewInvalid:
|
||||
value = default
|
||||
state[key] = list(value) if kind == "tags" else value
|
||||
return state
|
||||
|
||||
|
||||
def normalize_name(name: object) -> str:
|
||||
"""A stored name made displayable: trimmed, capped, never empty."""
|
||||
text = _CONTROL.sub("", name) if isinstance(name, str) else ""
|
||||
trimmed = text.strip()[:MAX_NAME_LENGTH].strip()
|
||||
return trimmed or FALLBACK_NAME
|
||||
|
||||
|
||||
def saved_views_schema_error(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
allow_missing: bool = True,
|
||||
check_states: bool = True,
|
||||
) -> Optional[str]:
|
||||
"""Reject unsupported saved-views schemas and structurally invalid rows.
|
||||
|
||||
Reads only. Checks the table, its columns, each row's types and sizes and
|
||||
that each state is a JSON object; never the value rules, which change
|
||||
between versions and are normalized on read instead. A missing table is
|
||||
acceptable for backups that predate it unless ``allow_missing`` is false.
|
||||
``check_states=False`` (live startup) skips the JSON-object check; such
|
||||
rows are reported by ``unusable_view_ids`` and skipped on read.
|
||||
"""
|
||||
objects = conn.execute(
|
||||
"SELECT type,name,sql FROM sqlite_master WHERE name=? "
|
||||
"OR (type IN ('index','trigger') AND tbl_name=?)",
|
||||
(TABLE, TABLE),
|
||||
).fetchall()
|
||||
if not objects:
|
||||
return None if allow_missing else "Missing saved views schema"
|
||||
tables = [sql for kind, _name, sql in objects if kind == "table"]
|
||||
if len(tables) != 1 or len(objects) != 1:
|
||||
return "Saved views name is reserved by an unsupported schema object"
|
||||
if _compact(tables[0] or "") != _compact(_DDL):
|
||||
return "Saved views has an invalid table definition"
|
||||
columns = tuple(
|
||||
(row[1], row[2].upper(), row[3], row[5])
|
||||
for row in conn.execute(f"PRAGMA table_info({TABLE})")
|
||||
)
|
||||
if columns != _COLUMNS:
|
||||
return "Saved views has invalid columns"
|
||||
# Literal SQL: the table name and limits are constants of this module.
|
||||
if conn.execute(
|
||||
"SELECT 1 FROM saved_views WHERE typeof(page) != 'text' "
|
||||
"OR page NOT IN ('images','library') "
|
||||
"OR typeof(name) != 'text' OR typeof(state) != 'text' "
|
||||
"OR typeof(position) != 'integer' OR typeof(created_at) != 'text' "
|
||||
"OR typeof(updated_at) != 'text' "
|
||||
"OR length(CAST(name AS BLOB)) > 400 "
|
||||
"OR length(CAST(state AS BLOB)) > 4096 "
|
||||
"OR length(CAST(created_at AS BLOB)) > 64 "
|
||||
"OR length(CAST(updated_at AS BLOB)) > 64 LIMIT 1"
|
||||
).fetchone():
|
||||
return "Saved views contains invalid values"
|
||||
if check_states and unusable_view_ids(conn):
|
||||
return "Saved views contains invalid values"
|
||||
return None
|
||||
|
||||
|
||||
def unusable_view_ids(conn: sqlite3.Connection) -> list[int]:
|
||||
"""Ids of rows whose state is not a JSON object (never listed)."""
|
||||
return [
|
||||
view_id
|
||||
for view_id, state in conn.execute("SELECT id, state FROM saved_views")
|
||||
if _decode_object(state) is None
|
||||
]
|
||||
|
||||
|
||||
def migrate_saved_views(conn: sqlite3.Connection) -> None:
|
||||
"""Install the table atomically; validate before and after creating it."""
|
||||
conn.execute("SAVEPOINT saved_views_schema")
|
||||
try:
|
||||
error = saved_views_schema_error(conn, check_states=False)
|
||||
if error:
|
||||
raise sqlite3.DatabaseError(error)
|
||||
conn.execute(_DDL.replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS", 1))
|
||||
error = saved_views_schema_error(conn, allow_missing=False, check_states=False)
|
||||
if error:
|
||||
raise sqlite3.DatabaseError(error)
|
||||
# A corrupt state never stops the live database from opening: the row
|
||||
# stays (no data is destroyed) and reads skip it. Only its id is logged.
|
||||
for view_id in unusable_view_ids(conn):
|
||||
_LOGGER.warning(
|
||||
"Saved view id %d has an unreadable state and is hidden", view_id
|
||||
)
|
||||
conn.execute("RELEASE saved_views_schema")
|
||||
except BaseException:
|
||||
conn.execute("ROLLBACK TO saved_views_schema")
|
||||
conn.execute("RELEASE saved_views_schema")
|
||||
raise
|
||||
|
||||
|
||||
def _view(row) -> dict:
|
||||
view_id, page, name, state = row
|
||||
return {
|
||||
"id": view_id,
|
||||
"page": page,
|
||||
"name": normalize_name(name),
|
||||
"state": normalize_state(page, state),
|
||||
}
|
||||
|
||||
|
||||
class SavedViewOperations:
|
||||
"""PromptDatabase mixin: list, create, rename and delete saved views."""
|
||||
|
||||
def list_saved_views(self) -> list[dict]:
|
||||
"""Every view, in list order (position, then creation)."""
|
||||
rows = self.model.get_connection().execute(_SELECT + " ORDER BY position, id")
|
||||
# A row whose state is no JSON object (logged at startup) is never listed.
|
||||
return [_view(row) for row in rows if _decode_object(row[3]) is not None]
|
||||
|
||||
def create_saved_view(self, page: object, name: object, state: object) -> dict:
|
||||
"""Validate and append one view at the end of the list."""
|
||||
from .models import utc_now_iso # models imports this module's schema
|
||||
|
||||
encoded = encode_state(page, state)
|
||||
trimmed = validate_name(name)
|
||||
now = utc_now_iso()
|
||||
with self.model.get_connection() as conn:
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO saved_views (page, name, state, position, "
|
||||
"created_at, updated_at) SELECT ?, ?, ?, "
|
||||
"COALESCE(MAX(position), -1) + 1, ?, ? FROM saved_views",
|
||||
(page, trimmed, encoded, now, now),
|
||||
)
|
||||
row = conn.execute(
|
||||
_SELECT + " WHERE id = ?", (cursor.lastrowid,)
|
||||
).fetchone()
|
||||
return _view(row)
|
||||
|
||||
def rename_saved_view(self, view_id: int, name: object) -> Optional[dict]:
|
||||
"""The renamed view, or None when no view has *view_id*."""
|
||||
from .models import utc_now_iso
|
||||
|
||||
trimmed = validate_name(name)
|
||||
with self.model.get_connection() as conn:
|
||||
conn.execute(
|
||||
"UPDATE saved_views SET name = ?, updated_at = ? WHERE id = ?",
|
||||
(trimmed, utc_now_iso(), view_id),
|
||||
)
|
||||
row = conn.execute(_SELECT + " WHERE id = ?", (view_id,)).fetchone()
|
||||
# A hidden (unreadable) view is not found, as in the listing.
|
||||
return _view(row) if row and _decode_object(row[3]) is not None else None
|
||||
|
||||
def delete_saved_view(self, view_id: int) -> bool:
|
||||
"""True when a view was removed."""
|
||||
with self.model.get_connection() as conn:
|
||||
cursor = conn.execute("DELETE FROM saved_views WHERE id = ?", (view_id,))
|
||||
return cursor.rowcount > 0
|
||||
+30
-4
@@ -3,6 +3,9 @@
|
||||
A count is the number of eligible Library parents that carry the value
|
||||
directly or through one active saved pair, which is exactly what
|
||||
``list_library(tags=[name])`` / ``list_library(category=value)`` return.
|
||||
A tag row's ``image_count`` is the number of files (private locators) with any
|
||||
byte record carrying the tag (#249), so a file counts once however many of its
|
||||
byte versions carry it, and equal bytes at two paths count twice.
|
||||
|
||||
Tag commands run in one ``_pair_transaction``: the storage epoch is checked
|
||||
first, then the exact ``{id, name}`` identity, and the receipt is read on the
|
||||
@@ -26,6 +29,11 @@ except ImportError:
|
||||
from utils.validators import validate_category, validate_tags
|
||||
|
||||
_CATEGORY_DOMAIN = "prompt-manager-vocabulary-category:v1\n"
|
||||
# Files carrying a tag through any of their byte records (#249 image tags).
|
||||
_IMAGE_FILES = (
|
||||
"SELECT mit.tag_id,COUNT(DISTINCT b.file_id) FROM media_image_tags mit "
|
||||
"JOIN media_annotation_bytes b ON b.id=mit.byte_id"
|
||||
)
|
||||
|
||||
|
||||
def _tag_rows(page: str) -> str:
|
||||
@@ -40,9 +48,13 @@ def _tag_rows(page: str) -> str:
|
||||
"WHERE pt.tag_id IN (SELECT id FROM page) AND " + _ELIGIBLE + " "
|
||||
"UNION SELECT ppt.tag_id,pp.positive_prompt_id FROM prompt_pairs pp "
|
||||
"JOIN prompt_pair_tags ppt ON ppt.pair_id=pp.id "
|
||||
"WHERE pp.archived=0 AND ppt.tag_id IN (SELECT id FROM page)) "
|
||||
"SELECT page.id,page.name,COUNT(DISTINCT owners.pid) AS prompt_count "
|
||||
"WHERE pp.archived=0 AND ppt.tag_id IN (SELECT id FROM page)),"
|
||||
"images(tag_id,n) AS (" + _IMAGE_FILES + " "
|
||||
"WHERE mit.tag_id IN (SELECT id FROM page) GROUP BY mit.tag_id) "
|
||||
"SELECT page.id,page.name,COUNT(DISTINCT owners.pid) AS prompt_count,"
|
||||
"COALESCE(MAX(images.n),0) AS image_count "
|
||||
"FROM page LEFT JOIN owners ON owners.tag_id=page.id "
|
||||
"LEFT JOIN images ON images.tag_id=page.id "
|
||||
"GROUP BY page.id ORDER BY page.id"
|
||||
)
|
||||
|
||||
@@ -61,7 +73,10 @@ _TAG_IMPACT = (
|
||||
"AS negative_variants,"
|
||||
"(SELECT COUNT(*) FROM prompt_pairs pp JOIN prompt_pair_tags ppt "
|
||||
"ON ppt.pair_id=pp.id WHERE ppt.tag_id=:tag AND pp.archived!=0) "
|
||||
"AS archived_negative_variants"
|
||||
"AS archived_negative_variants,"
|
||||
"(SELECT COUNT(DISTINCT b.file_id) FROM media_image_tags mit "
|
||||
"JOIN media_annotation_bytes b ON b.id=mit.byte_id WHERE mit.tag_id=:tag) "
|
||||
"AS images"
|
||||
)
|
||||
_MAX_ID = 2**63 - 1
|
||||
|
||||
@@ -205,7 +220,16 @@ class VocabularyOperations:
|
||||
"SELECT COUNT(DISTINCT pair_id) FROM prompt_pair_tags WHERE tag_id=?",
|
||||
(tag_id,),
|
||||
).fetchone()[0]
|
||||
return {"components_changed": components, "pairs_changed": pairs}
|
||||
images = conn.execute(
|
||||
"SELECT COUNT(DISTINCT b.file_id) FROM media_image_tags mit "
|
||||
"JOIN media_annotation_bytes b ON b.id=mit.byte_id WHERE mit.tag_id=?",
|
||||
(tag_id,),
|
||||
).fetchone()[0]
|
||||
return {
|
||||
"components_changed": components,
|
||||
"pairs_changed": pairs,
|
||||
"images_changed": images,
|
||||
}
|
||||
|
||||
def create_vocabulary_tag(self, name: str, *, expected_epoch: str) -> dict:
|
||||
"""Insert an unused registry row, or reuse the exact existing name."""
|
||||
@@ -250,6 +274,7 @@ class VocabularyOperations:
|
||||
"SELECT id FROM tags WHERE name=?", (new_name,)
|
||||
).fetchone()
|
||||
if target is None:
|
||||
self._bump_image_tag_bytes(conn, tag_id)
|
||||
conn.execute("UPDATE tags SET name=? WHERE id=?", (new_name, tag_id))
|
||||
result_id = tag_id
|
||||
else:
|
||||
@@ -275,6 +300,7 @@ class VocabularyOperations:
|
||||
epoch = self._library_epoch(conn, expected_epoch)
|
||||
self._vocabulary_identity(conn, epoch, tag_id, name)
|
||||
counts = self._tag_owner_counts(conn, tag_id)
|
||||
self._bump_image_tag_bytes(conn, tag_id)
|
||||
conn.execute("DELETE FROM tags WHERE id=?", (tag_id,))
|
||||
return {"library_epoch": epoch, "deleted": True, **counts}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ code. This plan proposes implementation order and acceptance gates.
|
||||
- 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.
|
||||
- Local Inspect (#249, PRs L1–L2) replaces the `#/inspect` handoff with the concept's native "Inspect a file" page: one local image or video previews inline and opens the shared viewer and full inspector on a record read entirely in the browser, with nothing uploaded, imported or stored. The legacy metadata page (`web/metadata.html`, served at `/prompt_manager/gallery.html`) stays reachable from Overview; its capabilities map to the shared inspector, except its one-click COPY ALL summary, whose ruled replacement ("Copy settings summary") is pending. Recorded deviations D60–D66 are in [TESTING.md](../TESTING.md). Local roles equal to the server's resolver follow in PR L3.
|
||||
|
||||
## Scope and compatibility
|
||||
|
||||
|
||||
@@ -29,6 +29,12 @@ Each role has `status` (`resolved`, `absent`, `unresolved`, or `ambiguous`), `te
|
||||
(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.
|
||||
unresolved rather than becoming sendable placeholder text. Videos are read from
|
||||
one container carrier: the per-key tags written by core SaveVideo/SaveWEBM and
|
||||
current VideoHelperSuite (`prompt`, `workflow`, `parameters`,
|
||||
`comfyassets_prompt_pairs`), else the old VideoHelperSuite `comment` envelope,
|
||||
else `description`. The admin scan reads the same carrier, so Images and
|
||||
ingestion agree on a video's prompts (see TESTING.md for the order and budgets;
|
||||
the live monitor processes images only).
|
||||
|
||||
Python route changes require restarting ComfyUI. Browser assets require a refresh.
|
||||
|
||||
+44
-3
@@ -6,6 +6,7 @@ Split into domain-specific mixins for maintainability:
|
||||
- AdminRoutesMixin: duplicates, stats, settings, diagnostics, maintenance, backup, scan
|
||||
- LoggingRoutesMixin: log management endpoints
|
||||
- AutotagRoutesMixin: auto-tagging model management and tagging
|
||||
- JobRoutesMixin: the session job registry behind Activity (py/api/jobs.py)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -25,9 +26,13 @@ from .prompts import PromptRoutesMixin
|
||||
from .pairs import PairRoutesMixin
|
||||
from .library import LibraryRoutesMixin
|
||||
from .vocabulary import VocabularyRoutesMixin
|
||||
from .annotations import AnnotationRoutesMixin
|
||||
from .saved_views import SavedViewRoutesMixin
|
||||
from .history import HistoryRoutesMixin
|
||||
from .images import ImageRoutesMixin
|
||||
from .admin import AdminRoutesMixin
|
||||
from .job_routes import JobRoutesMixin
|
||||
from .jobs import JobRegistry
|
||||
from .logging_routes import LoggingRoutesMixin
|
||||
from .autotag_routes import AutotagRoutesMixin
|
||||
from .lora_integration import LoraIntegrationMixin
|
||||
@@ -248,10 +253,13 @@ class PromptManagerAPI(
|
||||
PairRoutesMixin,
|
||||
LibraryRoutesMixin,
|
||||
VocabularyRoutesMixin,
|
||||
AnnotationRoutesMixin,
|
||||
SavedViewRoutesMixin,
|
||||
HistoryRoutesMixin,
|
||||
PromptRoutesMixin,
|
||||
ImageRoutesMixin,
|
||||
AdminRoutesMixin,
|
||||
JobRoutesMixin,
|
||||
LoggingRoutesMixin,
|
||||
AutotagRoutesMixin,
|
||||
LoraIntegrationMixin,
|
||||
@@ -279,17 +287,34 @@ class PromptManagerAPI(
|
||||
self.db = db if db is not None else PromptDatabase()
|
||||
self._history_read_fence = None
|
||||
self._history_reads_closed = False
|
||||
# Private media captures of the annotation routes (py/api/annotations.py),
|
||||
# created on first use with their restore subscription.
|
||||
self._captures = None
|
||||
self._captures_unsubscribe = None
|
||||
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)
|
||||
self._gallery_cache_ttl = 30 # Cache TTL in seconds
|
||||
# Workspace listing values and sort orders derived from a cached scan
|
||||
# (py/api/images.py _workspace_listed/_workspace_orders).
|
||||
self._workspace_listed_cache = {}
|
||||
self._workspace_order_cache = {}
|
||||
# Card facts (dimensions, seed, positive title) per root and
|
||||
# (path, mtime), kept and pruned with the per-file values
|
||||
# (py/api/images.py _card_facts).
|
||||
self._workspace_card_cache = {}
|
||||
# Favorite keys of each root's cached scan (images._favorite_listing).
|
||||
self._favorite_listing_cache = {}
|
||||
# Single-flight locks for long jobs, keyed by job family; see
|
||||
# _acquire_long_job(). Created lazily so no lock binds to an event
|
||||
# loop before the server's loop exists.
|
||||
self._long_jobs: dict = {}
|
||||
# The current output scan (py/api/scan_job.ScanJob), if one was started.
|
||||
# This session's jobs (py/api/jobs.py): the metadata scan, and later
|
||||
# every other long operation the Activity page tracks.
|
||||
self._jobs = JobRegistry(self.logger)
|
||||
# The latest metadata scan job (also in self._jobs), if one was started.
|
||||
self._scan_job = None
|
||||
# The current duplicate scan (also a ScanJob), if one was started.
|
||||
# The current duplicate scan (a py/api/jobs.Job outside the registry).
|
||||
self._duplicate_job = None
|
||||
|
||||
# Run cleanup on initialization to remove any existing duplicates
|
||||
@@ -348,9 +373,22 @@ class PromptManagerAPI(
|
||||
def invalidate_gallery_cache(self):
|
||||
"""Invalidate the cached gallery file listing.
|
||||
|
||||
Called by the image monitor when new files are detected.
|
||||
Called by the image monitor when new files are detected. The workspace
|
||||
keeps its per-file values and card facts, so the next request rebuilds
|
||||
only new or changed files (see ImageRoutesMixin._workspace_listed and
|
||||
_card_facts); its sort orders
|
||||
are dropped, as the rescan replaces them anyway, and so is the
|
||||
favorites lookup, which holds the old scan list.
|
||||
"""
|
||||
self._gallery_cache = {}
|
||||
self._workspace_order_cache = {}
|
||||
self._favorite_listing_cache = {}
|
||||
|
||||
def reset_gallery_roots_cache(self):
|
||||
"""Drop every cached listing and derived workspace value (roots changed)."""
|
||||
self.invalidate_gallery_cache()
|
||||
self._workspace_listed_cache = {}
|
||||
self._workspace_card_cache = {}
|
||||
|
||||
def add_routes(self, routes):
|
||||
"""Register all API routes with the ComfyUI server.
|
||||
@@ -409,10 +447,13 @@ class PromptManagerAPI(
|
||||
self._register_pair_routes(routes)
|
||||
self._register_library_routes(routes)
|
||||
self._register_vocabulary_routes(routes)
|
||||
self._register_annotation_routes(routes)
|
||||
self._register_saved_view_routes(routes)
|
||||
self._register_history_routes(routes)
|
||||
self._register_prompt_routes(routes)
|
||||
self._register_image_routes(routes)
|
||||
self._register_admin_routes(routes)
|
||||
self._register_job_routes(routes)
|
||||
self._register_logging_routes(routes)
|
||||
self._register_autotag_routes(routes)
|
||||
self._register_lora_routes(routes)
|
||||
|
||||
+306
-54
@@ -6,16 +6,25 @@ from contextlib import closing
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from .prompts import thumbnail_url_for
|
||||
from .scan_job import ScanJob
|
||||
from .jobs import (
|
||||
DEFAULT_SCAN_SCOPE,
|
||||
SCAN_GROUP_NAMES,
|
||||
SCAN_GROUPS,
|
||||
SCAN_SCOPES,
|
||||
Job,
|
||||
JobBusy,
|
||||
)
|
||||
|
||||
try:
|
||||
from ...utils.parallel import map_parallel
|
||||
@@ -47,8 +56,10 @@ except ImportError:
|
||||
|
||||
|
||||
try:
|
||||
from ...database.library import display_title
|
||||
from ...utils.hashing import generate_prompt_hash
|
||||
except ImportError:
|
||||
from database.library import display_title
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff")
|
||||
@@ -138,6 +149,127 @@ def _sse(payload):
|
||||
return f"data: {json.dumps(payload)}\n\n"
|
||||
|
||||
|
||||
SCAN_JOB_KIND = "scan"
|
||||
SCAN_JOB_TITLE = "Scan for metadata"
|
||||
# The legacy final event type of a scan and the job state it ends in.
|
||||
SCAN_FINAL_STATES = {
|
||||
"complete": "finished",
|
||||
"cancelled": "cancelled",
|
||||
"error": "failed",
|
||||
}
|
||||
# The scope as the scan log's first line names it (the review dialog's words).
|
||||
SCAN_SCOPE_LOG = {"new": "new and unresolved files", "all": "all files"}
|
||||
MIN_BATCHES_FOR_ESTIMATE = 2 # "about X min left" waits for this many batches
|
||||
# Log line prefix per outcome group (spec: + added, = known, ? review, ! error,
|
||||
# - no usable metadata).
|
||||
SCAN_LOG_PREFIXES = {
|
||||
"added": "+",
|
||||
"known": "=",
|
||||
"review": "?",
|
||||
"errors": "!",
|
||||
"no_metadata": "-",
|
||||
}
|
||||
# What an outcome row says about a file: where it went (with the prompt's
|
||||
# title) or why it was skipped. "error" is a file whose ingestion raised;
|
||||
# None is a file without prompt metadata.
|
||||
SCAN_TITLED_DETAILS = {
|
||||
"added": "Added to the Library as “{title}”",
|
||||
"linked": "Linked to “{title}”",
|
||||
"enriched": "Metadata added to “{title}”",
|
||||
"unchanged": "Already linked to “{title}”",
|
||||
}
|
||||
SCAN_DETAILS = {
|
||||
"added": "Added to the Library",
|
||||
"linked": "Linked to an existing prompt",
|
||||
"enriched": "Metadata added to its prompt",
|
||||
"unchanged": "Already linked to its prompt",
|
||||
"skipped": "Skipped · already linked",
|
||||
"ambiguous": "Needs review: more than one prompt could match",
|
||||
"unresolved": "Needs review: the prompt could not be traced in the workflow",
|
||||
"conflict": "Needs review: already linked to a different prompt",
|
||||
"failed": "Error: the prompt could not be stored",
|
||||
"error": "Error: the file could not be processed (see the server log)",
|
||||
"found": "Metadata found, but no prompt text to store",
|
||||
None: "No prompt metadata in the file",
|
||||
}
|
||||
|
||||
|
||||
def _plural(count, word):
|
||||
return f"{count} {word}" if count == 1 else f"{count} {word}s"
|
||||
|
||||
|
||||
def _scan_status_line(done, total, batches, elapsed):
|
||||
""" "Processing file N of M", with an estimate once enough batches ran."""
|
||||
line = f"Processing file {done:,} of {total:,}"
|
||||
if batches < MIN_BATCHES_FOR_ESTIMATE or done <= 0 or done >= total:
|
||||
return line
|
||||
minutes = max(1, math.ceil(elapsed / done * (total - done) / 60))
|
||||
return f"{line} · about {minutes} min left"
|
||||
|
||||
|
||||
def _file_size(path):
|
||||
"""``path``'s size on disk, or None when it cannot be read."""
|
||||
try:
|
||||
return os.path.getsize(path)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _with_file_size(canonical, media_file):
|
||||
"""Record the file's size in ``file_info`` unless the metadata carries one.
|
||||
|
||||
Stored as ``generated_images.file_size``; the "new" scan scope skips a known
|
||||
file only while its size is unchanged.
|
||||
"""
|
||||
info = canonical.get("file_info")
|
||||
info = dict(info) if isinstance(info, dict) else {}
|
||||
if info.get("size") is None:
|
||||
size = _file_size(media_file)
|
||||
if size is not None:
|
||||
info["size"] = size
|
||||
if info:
|
||||
canonical["file_info"] = info
|
||||
|
||||
|
||||
def _scan_roots(output_dirs):
|
||||
"""(absolute root, public label, listing index), the most specific first.
|
||||
|
||||
The index is the root's place in ``output_dirs`` (``_get_all_output_dirs``),
|
||||
the ``root_index`` the workspace listing names it by.
|
||||
"""
|
||||
public_path, _ = _public_helpers()
|
||||
roots = [
|
||||
(os.path.abspath(str(d)), public_path(d), index)
|
||||
for index, d in enumerate(output_dirs)
|
||||
]
|
||||
return sorted(roots, key=lambda root: len(root[0]), reverse=True)
|
||||
|
||||
|
||||
def _scan_location(media_file, roots):
|
||||
"""``media_file`` relative to its output root, that root's label and index."""
|
||||
full = os.path.abspath(str(media_file))
|
||||
folded = os.path.normcase(full)
|
||||
for root, label, index in roots:
|
||||
base = os.path.normcase(root).rstrip("\\/") + os.sep
|
||||
if folded.startswith(base):
|
||||
return Path(os.path.relpath(full, root)).as_posix(), label, index
|
||||
return Path(full).name, "", None
|
||||
|
||||
|
||||
def _scan_outcome(media_file, roots, status, title=None):
|
||||
"""The (group, row, log line) one scanned file records in its job."""
|
||||
if status in SCAN_GROUPS:
|
||||
group = SCAN_GROUPS[status]
|
||||
else:
|
||||
group = "errors" if status == "error" else "no_metadata"
|
||||
template = SCAN_TITLED_DETAILS.get(status) if title else None
|
||||
detail = template.format(title=title) if template else SCAN_DETAILS[status]
|
||||
path, label, index = _scan_location(media_file, roots)
|
||||
row = {"path": path, "root": label, "root_index": index, "detail": detail}
|
||||
where = f"{label}/{path}" if label else path
|
||||
return group, row, f"{SCAN_LOG_PREFIXES[group]} {where} · {detail}"
|
||||
|
||||
|
||||
def _inspect_restore_upload(path):
|
||||
"""Checks on a restore upload beyond PromptModel.verify_database_file.
|
||||
|
||||
@@ -437,7 +569,7 @@ class AdminRoutesMixin:
|
||||
guard = self._acquire_long_job(self.LONG_JOB_DUPLICATES)
|
||||
if guard is None:
|
||||
return self._long_job_busy_response("Duplicate scan or delete")
|
||||
job = ScanJob()
|
||||
job = Job("duplicates", "Duplicate scan")
|
||||
self._duplicate_job = job
|
||||
job.task = asyncio.ensure_future(self._run_duplicate_job(job, guard))
|
||||
return await self._stream_scan_job(request, job)
|
||||
@@ -897,7 +1029,7 @@ class AdminRoutesMixin:
|
||||
return False
|
||||
GalleryConfig.MONITORING_DIRECTORIES = roots
|
||||
self._cached_output_dir = None
|
||||
self._gallery_cache = {}
|
||||
self.reset_gallery_roots_cache()
|
||||
return True
|
||||
|
||||
def _persist_settings(self):
|
||||
@@ -1319,14 +1451,18 @@ class AdminRoutesMixin:
|
||||
async def scan_images(self, request):
|
||||
"""Scan ComfyUI output images for prompt metadata and add them to the database.
|
||||
|
||||
The scan runs as a background task that outlives this request, so a
|
||||
closed tab never leaves a half-finished scan. Progress streams as
|
||||
server-sent events; while a scan is running, a new request attaches
|
||||
to it instead of starting another one.
|
||||
The scan runs as the registry's ``scan`` job, which outlives this
|
||||
request, so a closed tab never leaves a half-finished scan. Progress
|
||||
streams as the legacy server-sent events; while a scan is running
|
||||
(started here or from the workspace), a new request attaches to it
|
||||
instead of starting another one.
|
||||
"""
|
||||
job = self._scan_job
|
||||
if job is None or job.finished:
|
||||
job = self._start_scan_job()
|
||||
job = self._jobs.active(SCAN_JOB_KIND)
|
||||
if job is None:
|
||||
try:
|
||||
job = self._start_scan_job("all")
|
||||
except JobBusy as busy:
|
||||
return self._long_job_busy_response(busy.running.title)
|
||||
return await self._stream_scan_job(request, job)
|
||||
|
||||
async def cancel_scan(self, request):
|
||||
@@ -1348,19 +1484,37 @@ class AdminRoutesMixin:
|
||||
}
|
||||
)
|
||||
|
||||
def _start_scan_job(self):
|
||||
"""Create the job record synchronously, then run the scan as a task."""
|
||||
job = ScanJob()
|
||||
def _start_scan_job(self, files=DEFAULT_SCAN_SCOPE):
|
||||
"""Register the scan job synchronously; its runner starts on the loop.
|
||||
|
||||
``files`` is the scope (``SCAN_SCOPES``). Raises JobBusy while another
|
||||
record-changing job runs.
|
||||
"""
|
||||
if files not in SCAN_SCOPES:
|
||||
raise ValueError(f"Unknown scan scope: {files!r}")
|
||||
job = self._jobs.start(
|
||||
SCAN_JOB_KIND,
|
||||
SCAN_JOB_TITLE,
|
||||
record_changing=True,
|
||||
scope={"files": files},
|
||||
runner=self._run_scan_job,
|
||||
groups=SCAN_GROUP_NAMES,
|
||||
)
|
||||
self._scan_job = job
|
||||
job.task = asyncio.ensure_future(self._run_scan_job(job))
|
||||
return job
|
||||
|
||||
async def _run_scan_job(self, job):
|
||||
try:
|
||||
async for payload in self._scan_images_events():
|
||||
job.publish(payload)
|
||||
finally:
|
||||
job.finish()
|
||||
final = None
|
||||
async for payload in self._scan_images_events(job):
|
||||
job.publish(payload)
|
||||
final = payload.get("type")
|
||||
state = SCAN_FINAL_STATES.get(final, "finished")
|
||||
if state == "failed":
|
||||
job.log_line(f"Scan failed · {job.status_line}")
|
||||
else:
|
||||
verb = "cancelled" if state == "cancelled" else "finished"
|
||||
job.log_line(f"Scan {verb} · {_plural(job.processed, 'file')} processed")
|
||||
job.finish(state)
|
||||
|
||||
async def _stream_scan_job(self, request, job):
|
||||
"""Stream ``job`` as SSE; stop quietly if the client goes away."""
|
||||
@@ -1386,21 +1540,31 @@ class AdminRoutesMixin:
|
||||
)
|
||||
return response
|
||||
|
||||
async def _scan_images_events(self):
|
||||
"""Yield progress payloads while scanning every configured output directory."""
|
||||
async def _scan_images_events(self, job):
|
||||
"""Yield progress payloads while scanning every configured output directory.
|
||||
|
||||
The payloads are the legacy SSE events, unchanged; ``job`` also gets
|
||||
its status line, totals and one outcome per processed file.
|
||||
"""
|
||||
try:
|
||||
self.logger.info("Starting image scan operation")
|
||||
output_dirs = self._get_all_output_dirs()
|
||||
if not output_dirs:
|
||||
self.logger.error("No output directories found")
|
||||
yield {
|
||||
"type": "error",
|
||||
"message": "No output directories found. "
|
||||
"Configure scan directories in Settings.",
|
||||
}
|
||||
message = (
|
||||
"No output directories found. "
|
||||
"Configure scan directories in Settings."
|
||||
)
|
||||
job.update(status_line=message)
|
||||
yield {"type": "error", "message": message}
|
||||
return
|
||||
|
||||
dir_names = [self._public_path(d) for d in output_dirs]
|
||||
folders = _plural(len(output_dirs), "output folder")
|
||||
job.scope["output_folders"] = len(output_dirs)
|
||||
scope = SCAN_SCOPE_LOG[job.scope.get("files", "all")]
|
||||
job.log_line(f"Scan started · {folders} · {scope}")
|
||||
job.update(status_line=f"Looking for media files in {folders}")
|
||||
yield self._scan_progress(
|
||||
0, f"Scanning {len(output_dirs)} directory(ies) for media files..."
|
||||
)
|
||||
@@ -1409,11 +1573,21 @@ class AdminRoutesMixin:
|
||||
self._collect_output_media_sync, output_dirs
|
||||
)
|
||||
counts = dict.fromkeys(SCAN_COUNT_KEYS, 0)
|
||||
job.update(
|
||||
total=len(media_files),
|
||||
status_line=_scan_status_line(0, len(media_files), 0, 0),
|
||||
)
|
||||
if media_files:
|
||||
yield self._scan_progress(
|
||||
5, f"Found {len(media_files)} media files to process...", counts
|
||||
)
|
||||
async for event in self._scan_batches(media_files, counts):
|
||||
roots = _scan_roots(output_dirs)
|
||||
known = set()
|
||||
if media_files and job.scope.get("files") != "all":
|
||||
known = await self._known_scan_files(job, media_files)
|
||||
async for event in self._scan_batches(
|
||||
media_files, counts, job, roots, known
|
||||
):
|
||||
yield event
|
||||
|
||||
self.logger.info(
|
||||
@@ -1421,37 +1595,78 @@ class AdminRoutesMixin:
|
||||
f"found={counts['found']}, new_prompts_added={counts['added']}, "
|
||||
f"images_linked_to_existing={counts['linked']}"
|
||||
)
|
||||
job = self._scan_job
|
||||
final_type = "cancelled" if job and job.cancelled else "complete"
|
||||
final_type = "cancelled" if job.cancelled else "complete"
|
||||
if final_type == "complete":
|
||||
job.update(progress=100)
|
||||
yield {"type": final_type, **counts, "directories": dir_names}
|
||||
|
||||
except Exception:
|
||||
self.logger.exception("Scan error")
|
||||
job.update(status_line="The scan stopped on an internal error.")
|
||||
yield {
|
||||
"type": "error",
|
||||
"message": "An internal error occurred. Check server logs for details.",
|
||||
}
|
||||
|
||||
async def _scan_batches(self, media_files, counts):
|
||||
async def _known_scan_files(self, job, media_files):
|
||||
"""The "new" scope's skip set; a failed read falls back to reading all."""
|
||||
try:
|
||||
return await self._run_in_executor(self._known_scan_files_sync, media_files)
|
||||
except Exception:
|
||||
self.logger.warning(
|
||||
"Known files could not be read; the scan reads every file",
|
||||
exc_info=True,
|
||||
)
|
||||
job.log_line("Known files could not be read · reading every file")
|
||||
return set()
|
||||
|
||||
def _known_scan_files_sync(self, media_files):
|
||||
"""Stat each scanned file and ask the database which are known (blocking)."""
|
||||
sizes = {str(media_file): _file_size(media_file) for media_file in media_files}
|
||||
return self.db.get_known_resolved_paths(sizes)
|
||||
|
||||
async def _scan_batches(self, media_files, counts, job, roots=(), known=()):
|
||||
"""Process ``media_files`` in executor batches, updating ``counts`` in place.
|
||||
|
||||
Yields one progress event per batch.
|
||||
Records each file's outcome in ``job`` and yields one progress event
|
||||
per batch. Files in ``known`` (path strings) are not read: they count
|
||||
as processed and unchanged and are recorded as skipped.
|
||||
"""
|
||||
total = len(media_files)
|
||||
started = time.monotonic()
|
||||
batches = 0
|
||||
for batch_start in range(0, total, SCAN_BATCH_SIZE):
|
||||
if self._scan_job and self._scan_job.cancelled:
|
||||
if job.cancelled:
|
||||
return
|
||||
batch = media_files[batch_start : batch_start + SCAN_BATCH_SIZE]
|
||||
batch_results = await self._run_in_executor(
|
||||
self._extract_batch_metadata_sync, batch
|
||||
)
|
||||
batch_counts = await self._run_in_executor(
|
||||
self._ingest_scan_batch_sync, batch_results
|
||||
)
|
||||
for key in counts:
|
||||
counts[key] += batch_counts[key]
|
||||
skipped = [f for f in batch if str(f) in known]
|
||||
to_read = [f for f in batch if str(f) not in known]
|
||||
outcomes = [_scan_outcome(f, roots, "skipped") for f in skipped]
|
||||
counts["processed"] += len(skipped)
|
||||
counts["unchanged"] += len(skipped)
|
||||
if to_read:
|
||||
batch_results = await self._run_in_executor(
|
||||
self._extract_batch_metadata_sync, to_read
|
||||
)
|
||||
batch_counts = await self._run_in_executor(
|
||||
self._ingest_scan_batch_sync,
|
||||
batch_results,
|
||||
roots=roots,
|
||||
outcomes=outcomes,
|
||||
)
|
||||
for key in counts:
|
||||
counts[key] += batch_counts[key]
|
||||
for group, row, log_line in outcomes:
|
||||
job.record(group, row, log_line)
|
||||
|
||||
batches += 1
|
||||
done = min(batch_start + len(batch), total)
|
||||
job.update(
|
||||
processed=counts["processed"],
|
||||
status_line=_scan_status_line(
|
||||
done, total, batches, time.monotonic() - started
|
||||
),
|
||||
)
|
||||
yield self._scan_progress(
|
||||
int(done / total * 100), f"Processing file {done}/{total}...", counts
|
||||
)
|
||||
@@ -1490,9 +1705,19 @@ class AdminRoutesMixin:
|
||||
|
||||
return map_parallel(extract, file_batch, PromptManagerConfig.WORKER_THREADS)
|
||||
|
||||
def _ingest_scan_batch_sync(self, batch_results):
|
||||
"""Store the prompts found in a metadata batch (blocking DB work)."""
|
||||
def _ingest_scan_batch_sync(self, batch_results, roots=(), outcomes=None):
|
||||
"""Store the prompts found in a metadata batch (blocking DB work).
|
||||
|
||||
Returns the legacy counts. When ``outcomes`` is a list, every processed
|
||||
file appends its ``(group, row, log_line)`` for the scan job, with the
|
||||
path relative to its root in ``roots`` (see ``_scan_roots``).
|
||||
"""
|
||||
counts = dict.fromkeys(SCAN_COUNT_KEYS, 0)
|
||||
|
||||
def note(media_file, status, title=None):
|
||||
if outcomes is not None:
|
||||
outcomes.append(_scan_outcome(media_file, roots, status, title))
|
||||
|
||||
for media_file, metadata in batch_results:
|
||||
if self._scan_job and self._scan_job.cancelled:
|
||||
break
|
||||
@@ -1502,25 +1727,21 @@ class AdminRoutesMixin:
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error processing {media_file.name}: {e}")
|
||||
counts["failed"] += 1
|
||||
note(media_file, "error")
|
||||
continue
|
||||
if outcome is None:
|
||||
note(media_file, None)
|
||||
continue
|
||||
counts["found"] += 1
|
||||
status = outcome["status"] if isinstance(outcome, dict) else outcome
|
||||
if status in (
|
||||
"added",
|
||||
"linked",
|
||||
"enriched",
|
||||
"unchanged",
|
||||
"ambiguous",
|
||||
"unresolved",
|
||||
"conflict",
|
||||
"failed",
|
||||
):
|
||||
if status in SCAN_GROUPS:
|
||||
counts[status] += 1
|
||||
if isinstance(outcome, dict):
|
||||
counts["pairs_added"] += outcome.get("pairs_added", 0)
|
||||
counts["pair_links_added"] += outcome.get("links_added", 0)
|
||||
note(media_file, status, outcome.get("title"))
|
||||
else:
|
||||
note(media_file, status)
|
||||
return counts
|
||||
|
||||
@staticmethod
|
||||
@@ -1539,7 +1760,13 @@ class AdminRoutesMixin:
|
||||
return positive.strip() or None
|
||||
|
||||
def _ingest_scanned_file(self, media_file, metadata, detailed=False):
|
||||
"""Enrich exact pairs; retain legacy positive-only import for absent pairs."""
|
||||
"""Enrich exact pairs; retain legacy positive-only import for absent pairs.
|
||||
|
||||
Returns the file's scan status, or None without prompt metadata. With
|
||||
``detailed``, a stored or linked file returns a dict instead: its
|
||||
``status``, the prompt's display ``title`` and, for a resolved pair,
|
||||
the pair counts.
|
||||
"""
|
||||
if not metadata:
|
||||
return None
|
||||
canonical = dict(metadata)
|
||||
@@ -1552,12 +1779,18 @@ class AdminRoutesMixin:
|
||||
pass
|
||||
if not canonical.get("prompt") and not canonical.get("parameters"):
|
||||
return None
|
||||
_with_file_size(canonical, media_file)
|
||||
resolution = resolve_prompt_pair(canonical)
|
||||
if resolution["state"] == "resolved":
|
||||
result = ingest_resolved_pair(
|
||||
self.db, str(media_file), canonical, resolution
|
||||
)
|
||||
return result if detailed else result["status"]
|
||||
if not detailed:
|
||||
return result["status"]
|
||||
return {
|
||||
**result,
|
||||
"title": self._scan_title(media_file, resolution["positive"]["text"]),
|
||||
}
|
||||
if canonical.get("prompt") and resolution["state"] in (
|
||||
"ambiguous",
|
||||
"unresolved",
|
||||
@@ -1585,6 +1818,25 @@ class AdminRoutesMixin:
|
||||
return "found"
|
||||
prompt_text = prompt_text.strip()
|
||||
|
||||
status = self._store_scanned_prompt(media_file, canonical, prompt_text)
|
||||
if not detailed or status not in SCAN_TITLED_DETAILS:
|
||||
return status
|
||||
return {"status": status, "title": self._scan_title(media_file, prompt_text)}
|
||||
|
||||
def _scan_title(self, media_file, text):
|
||||
"""The linked prompt's Library title (its stored title, else ``text``'s
|
||||
first line), as the Library and the inspector name it."""
|
||||
try:
|
||||
path = str(media_file)
|
||||
summary = self.db.get_image_link_summaries([path], False).get(path)
|
||||
if summary and summary.get("title"):
|
||||
return summary["title"]
|
||||
except Exception:
|
||||
self.logger.warning("Scan row title could not be read; using its text")
|
||||
return display_title((), text)
|
||||
|
||||
def _store_scanned_prompt(self, media_file, canonical, prompt_text):
|
||||
"""Link ``media_file`` to ``prompt_text``'s prompt, saving it when new."""
|
||||
prompt_hash = generate_prompt_hash(prompt_text)
|
||||
existing = self.db.get_prompt_by_hash(prompt_hash)
|
||||
if existing:
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Request validation and the worker of the Tag dialog's Generate (#249 B2).
|
||||
|
||||
``POST /prompt_manager/image-annotations/suggest`` asks one of legacy's four
|
||||
engines for the top N tags of a captured image. Options are validated
|
||||
strictly, per engine: WD14 classifiers take two thresholds, the language
|
||||
models (GGUF and HuggingFace JoyCaption) take the instructions; every engine
|
||||
takes the suggestion count and the GPU switch. Nothing here touches the
|
||||
filesystem or the database.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
WD14_MODELS = frozenset({"wd14-swinv2", "wd14-vit"})
|
||||
LLM_MODELS = frozenset({"gguf", "hf"})
|
||||
MODEL_TYPES = WD14_MODELS | LLM_MODELS
|
||||
|
||||
MAX_INSTRUCTION_CHARS = 4000
|
||||
MAX_INSTRUCTION_BYTES = 16 * 1024
|
||||
|
||||
SUGGEST_FIELDS = frozenset(
|
||||
{"target", "capture_token", "expected_epoch", "model_type", "options"}
|
||||
)
|
||||
_COMMON = frozenset({"suggestion_limit", "use_gpu"})
|
||||
_WD14_REQUIRED = frozenset({"general_threshold", "character_threshold"})
|
||||
_LLM_REQUIRED = frozenset({"instructions"})
|
||||
|
||||
UNAVAILABLE_HINT = "Download it from the legacy Auto-tag page."
|
||||
|
||||
|
||||
class OptionError(ValueError):
|
||||
"""A suggest request whose model type or options are not acceptable."""
|
||||
|
||||
|
||||
def _threshold(name: str, value) -> float:
|
||||
if type(value) not in (int, float) or not math.isfinite(value):
|
||||
raise OptionError(f"{name} must be a finite number")
|
||||
if not 0 <= value <= 1:
|
||||
raise OptionError(f"{name} must be between 0 and 1")
|
||||
return float(value)
|
||||
|
||||
|
||||
def _instructions(value) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise OptionError("instructions must be non-empty text")
|
||||
if len(value) > MAX_INSTRUCTION_CHARS:
|
||||
raise OptionError(
|
||||
f"instructions must be at most {MAX_INSTRUCTION_CHARS} characters"
|
||||
)
|
||||
try:
|
||||
encoded = value.encode("utf-8")
|
||||
except UnicodeEncodeError:
|
||||
raise OptionError("instructions must be valid Unicode text") from None
|
||||
if len(encoded) > MAX_INSTRUCTION_BYTES:
|
||||
raise OptionError("instructions are too long")
|
||||
return value
|
||||
|
||||
|
||||
def validate_options(model_type, options, *, default_limit: int, max_limit: int):
|
||||
"""Normalised ``(model_type, options)`` or :class:`OptionError`.
|
||||
|
||||
The answer echoes every option the request will run with, the defaults
|
||||
(suggestion count from the settings, GPU on) filled in.
|
||||
"""
|
||||
if not isinstance(model_type, str) or model_type not in MODEL_TYPES:
|
||||
raise OptionError("model_type must be one of " + ", ".join(sorted(MODEL_TYPES)))
|
||||
if not isinstance(options, dict):
|
||||
raise OptionError("options must be an object")
|
||||
required = _WD14_REQUIRED if model_type in WD14_MODELS else _LLM_REQUIRED
|
||||
keys = set(options)
|
||||
if not required <= keys or not keys <= required | _COMMON:
|
||||
raise OptionError(
|
||||
f"options for {model_type} take exactly "
|
||||
+ ", ".join(sorted(required))
|
||||
+ " and optionally "
|
||||
+ ", ".join(sorted(_COMMON))
|
||||
)
|
||||
limit = options.get("suggestion_limit", default_limit)
|
||||
if type(limit) is not int or not 1 <= limit <= max_limit:
|
||||
raise OptionError(f"suggestion_limit must be an integer from 1 to {max_limit}")
|
||||
use_gpu = options.get("use_gpu", True)
|
||||
if not isinstance(use_gpu, bool):
|
||||
raise OptionError("use_gpu must be true or false")
|
||||
normalised = {"use_gpu": use_gpu, "suggestion_limit": limit}
|
||||
if model_type in WD14_MODELS:
|
||||
for name in sorted(_WD14_REQUIRED):
|
||||
normalised[name] = _threshold(name, options[name])
|
||||
else:
|
||||
normalised["instructions"] = _instructions(options["instructions"])
|
||||
return model_type, normalised
|
||||
|
||||
|
||||
def unavailable_message(model_name: str) -> str:
|
||||
return f"{model_name} is not downloaded. {UNAVAILABLE_HINT}"
|
||||
|
||||
|
||||
def run_suggestions(service, capture_path: str, model_type: str, options: dict):
|
||||
"""Blocking: load (or swap to) *model_type*, then suggest for the capture.
|
||||
|
||||
The caller holds the model gate for the whole call; nothing is ever
|
||||
downloaded (``load_model`` refuses a model that is not on disk).
|
||||
"""
|
||||
if not service.is_model_loaded() or service.get_loaded_model_type() != model_type:
|
||||
service.load_model(model_type, options["use_gpu"])
|
||||
return service.generate_tag_suggestions(
|
||||
capture_path,
|
||||
limit=options["suggestion_limit"],
|
||||
prompt=options.get("instructions"),
|
||||
general_threshold=options.get("general_threshold"),
|
||||
character_threshold=options.get("character_threshold"),
|
||||
)
|
||||
|
||||
|
||||
def suggestion_rows(pairs) -> list:
|
||||
"""``[{name, confidence}]``; a non-finite score is reported as unknown."""
|
||||
rows = []
|
||||
for name, score in pairs:
|
||||
confidence = None
|
||||
if score is not None and math.isfinite(score):
|
||||
confidence = float(score)
|
||||
rows.append({"name": str(name), "confidence": confidence})
|
||||
return rows
|
||||
@@ -0,0 +1,759 @@
|
||||
"""Image annotation routes: observe, capture preview and byte-exact tag saves.
|
||||
|
||||
Every route names its file through the listing identity ``{root_index,
|
||||
root_scope, path}`` and checks the scope against the current gallery roots
|
||||
before any file access. Observe copies the file into a private capture (the
|
||||
token the preview and Save refer to), Save re-hashes the current file and
|
||||
writes only when the bytes are still the captured ones. Responses carry no
|
||||
server path and no locator.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import functools
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from .annotation_suggest import (
|
||||
SUGGEST_FIELDS,
|
||||
OptionError,
|
||||
run_suggestions,
|
||||
suggestion_rows,
|
||||
unavailable_message,
|
||||
validate_options,
|
||||
)
|
||||
from .autotag_routes import model_busy_response
|
||||
from .images import _forbidden, _has_traversal, _unavailable_link, _verified_title
|
||||
from .prompts import bad_request
|
||||
from .. import model_gate
|
||||
|
||||
try:
|
||||
from ...database.annotations import (
|
||||
MAX_IMAGE_TAGS,
|
||||
AnnotationCommandError,
|
||||
ImageTagLimitError,
|
||||
normalize_image_tags,
|
||||
)
|
||||
from ...database.execution_history import subscribe_history_restore
|
||||
from ...database.library_commands import LibraryCommandError
|
||||
from ...utils.media_capture import CAPTURE_EXTENSIONS, CaptureError, CaptureStore
|
||||
except ImportError:
|
||||
from database.annotations import (
|
||||
MAX_IMAGE_TAGS,
|
||||
AnnotationCommandError,
|
||||
ImageTagLimitError,
|
||||
normalize_image_tags,
|
||||
)
|
||||
from database.execution_history import subscribe_history_restore
|
||||
from database.library_commands import LibraryCommandError
|
||||
from utils.media_capture import CAPTURE_EXTENSIONS, CaptureError, CaptureStore
|
||||
|
||||
# Request bodies of the annotation routes are small (a target plus a tag set).
|
||||
MAX_BODY_BYTES = 64 * 1024
|
||||
MAX_TARGET_PATH = 1024
|
||||
CAPTURE_URL = "/prompt_manager/image-annotations/capture/"
|
||||
_PREVIEW_CHUNK = 64 * 1024
|
||||
|
||||
_TARGET_FIELDS = frozenset({"root_index", "root_scope", "path"})
|
||||
_OBSERVE_FIELDS = frozenset({"target"})
|
||||
_SAVE_FIELDS = frozenset(
|
||||
{
|
||||
"target",
|
||||
"capture_token",
|
||||
"sha256",
|
||||
"expected_epoch",
|
||||
"expected_tag_revision",
|
||||
"tags",
|
||||
}
|
||||
)
|
||||
_HEX64 = re.compile(r"[0-9a-f]{64}")
|
||||
_UUID = re.compile(r"[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}")
|
||||
# secrets.token_urlsafe(32) is 43 characters; the bound only rejects junk.
|
||||
_TOKEN = re.compile(r"[A-Za-z0-9_-]{1,128}")
|
||||
|
||||
_PREVIEW_TYPES = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".webp": "image/webp",
|
||||
".gif": "image/gif",
|
||||
".bmp": "image/bmp",
|
||||
".tiff": "image/tiff",
|
||||
".tif": "image/tiff",
|
||||
".mp4": "video/mp4",
|
||||
".m4v": "video/mp4",
|
||||
".webm": "video/webm",
|
||||
".mov": "video/quicktime",
|
||||
".mkv": "video/x-matroska",
|
||||
".avi": "video/x-msvideo",
|
||||
".wmv": "video/x-ms-wmv",
|
||||
}
|
||||
|
||||
_STATUS = {
|
||||
"review-capacity": 429,
|
||||
"unsupported-media": 415,
|
||||
"file-changed": 409,
|
||||
"source-changed": 409,
|
||||
"storage-changed": 409,
|
||||
"tag-conflict": 409,
|
||||
"capture-expired": 409,
|
||||
"model-unavailable": 409,
|
||||
"tag-limit": 400,
|
||||
"metadata-conflict": 409,
|
||||
"vocabulary-conflict": 409,
|
||||
}
|
||||
_MESSAGES = {
|
||||
"source-changed": (
|
||||
"The source changed. Refresh the image collection before tagging this file."
|
||||
),
|
||||
"file-changed": "The file changed since it was previewed. Reopen the Tag dialog.",
|
||||
"review-capacity": "Too many previews are open. Close a Tag dialog and try again.",
|
||||
"storage-changed": "Prompt storage was restored; reopen the Tag dialog.",
|
||||
"tag-conflict": (
|
||||
"The tags of this image changed since the dialog opened; review them first."
|
||||
),
|
||||
"capture-expired": "The preview expired. Open the Tag dialog again.",
|
||||
"tag-limit": f"An image can carry at most {MAX_IMAGE_TAGS} tags.",
|
||||
}
|
||||
# Any other storage code (a conflict a later command adds) still answers a
|
||||
# coded envelope rather than a bare 500.
|
||||
_DEFAULT_STATUS = 409
|
||||
_DEFAULT_MESSAGE = "The tags could not be saved. Reopen the Tag dialog and try again."
|
||||
_OTHER_FILE = "The preview belongs to another file. Open the Tag dialog again."
|
||||
|
||||
|
||||
def preview_content_type(extension: str) -> str:
|
||||
"""The media type the preview is served as; never sniffed from bytes."""
|
||||
return _PREVIEW_TYPES.get(extension.lower(), "application/octet-stream")
|
||||
|
||||
|
||||
def _error(status: int, code: str, message: str, **extra) -> web.Response:
|
||||
return web.json_response(
|
||||
{"success": False, "code": code, "error": message, **extra},
|
||||
status=status,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
def _coded(code: str, message: str | None = None, **extra) -> web.Response:
|
||||
status = _STATUS.get(code, _DEFAULT_STATUS)
|
||||
return _error(
|
||||
status, code, message or _MESSAGES.get(code, _DEFAULT_MESSAGE), **extra
|
||||
)
|
||||
|
||||
|
||||
def _open_capture(path: str):
|
||||
"""Worker thread: the capture file opened for reading."""
|
||||
return open(path, "rb")
|
||||
|
||||
|
||||
async def _settled(future, cleanup):
|
||||
"""Await an executor *future*; a cancelled caller still cleans up after it.
|
||||
|
||||
The future is shielded, so the worker call always runs to its end. When the
|
||||
caller is cancelled first, ``cleanup(result)`` runs once the call is done
|
||||
(``cleanup(None)`` when it failed): a handle is closed, a capture released.
|
||||
"""
|
||||
try:
|
||||
return await asyncio.shield(future)
|
||||
except asyncio.CancelledError:
|
||||
future.add_done_callback(functools.partial(_cleanup_result, cleanup))
|
||||
raise
|
||||
|
||||
|
||||
def _cleanup_result(cleanup, future) -> None:
|
||||
if future.cancelled() or future.exception() is not None:
|
||||
cleanup(None)
|
||||
else:
|
||||
cleanup(future.result())
|
||||
|
||||
|
||||
def _submit(func, *args, **kwargs):
|
||||
return asyncio.get_running_loop().run_in_executor(
|
||||
None, functools.partial(func, *args, **kwargs)
|
||||
)
|
||||
|
||||
|
||||
def _capture_error(exc: CaptureError) -> web.Response:
|
||||
"""The envelope for a capture outcome; messages never name a path."""
|
||||
if exc.code not in _STATUS:
|
||||
return _error(500, exc.code, "The file could not be captured.")
|
||||
return _coded(exc.code, _MESSAGES.get(exc.code) or str(exc))
|
||||
|
||||
|
||||
def _target(data: object):
|
||||
"""``((index, scope, relative), None)`` for a target, else ``(None, error)``.
|
||||
|
||||
Only the listing identity is accepted: an int root index, the root's
|
||||
64-hex scope and a bounded root-relative POSIX media path, never an
|
||||
absolute, drive-qualified, backslash or ``..`` path. Nothing here touches
|
||||
the filesystem or the database.
|
||||
"""
|
||||
if not isinstance(data, dict) or set(data) != _TARGET_FIELDS:
|
||||
return None, bad_request("target must name root_index, root_scope and path")
|
||||
index, scope, relative = data["root_index"], data["root_scope"], data["path"]
|
||||
if type(index) is not int or index < 0:
|
||||
return None, bad_request("Invalid gallery root")
|
||||
if not isinstance(scope, str) or _HEX64.fullmatch(scope) is None:
|
||||
return None, bad_request("Invalid gallery root scope")
|
||||
if not isinstance(relative, str) or not relative or len(relative) > MAX_TARGET_PATH:
|
||||
return None, bad_request("path must be a relative media path")
|
||||
parts = relative.split("/")
|
||||
if (
|
||||
"\0" in relative
|
||||
or "\\" in relative
|
||||
or relative.startswith("/")
|
||||
or _has_traversal(relative)
|
||||
or any(part in ("", ".", "..") or ":" in part for part in parts)
|
||||
):
|
||||
return None, _forbidden()
|
||||
if os.path.splitext(parts[-1])[1].lower() not in CAPTURE_EXTENSIONS:
|
||||
return None, _forbidden("Only media files can be tagged")
|
||||
return (index, scope, relative), None
|
||||
|
||||
|
||||
def _expires_at(seconds: float | None) -> str:
|
||||
moment = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(
|
||||
seconds=max(0.0, seconds or 0.0)
|
||||
)
|
||||
return moment.replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _capture_info(store: CaptureStore, capture) -> dict:
|
||||
return {
|
||||
"token": capture.token,
|
||||
"expires_at": _expires_at(store.expires_in(capture.token)),
|
||||
"preview_url": CAPTURE_URL + capture.token,
|
||||
"sha256": capture.sha256,
|
||||
"size": capture.size,
|
||||
"width": capture.width,
|
||||
"height": capture.height,
|
||||
"media_type": capture.media_type,
|
||||
"generate": {
|
||||
"supported": capture.inferable,
|
||||
"reason": capture.inferable_reason,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class AnnotationRoutesMixin:
|
||||
"""Observe, capture preview and tag save routes over PR A's storage."""
|
||||
|
||||
def _register_annotation_routes(self, routes: web.RouteTableDef) -> None:
|
||||
routes.post("/prompt_manager/image-annotations/observe")(self.observe_image)
|
||||
routes.get(CAPTURE_URL + "{token}")(self.get_capture_preview)
|
||||
routes.delete(CAPTURE_URL + "{token}")(self.release_capture)
|
||||
routes.put("/prompt_manager/image-annotations/tags")(self.save_image_tags)
|
||||
routes.post("/prompt_manager/image-annotations/suggest")(
|
||||
self.suggest_image_tags
|
||||
)
|
||||
|
||||
# -- capture store lifetime ------------------------------------------------
|
||||
|
||||
def _capture_store(self) -> CaptureStore:
|
||||
"""The process's capture store, created on first use.
|
||||
|
||||
Created lazily so an API object that never observes a file (most tests,
|
||||
a server that never opens the Tag dialog) owns no temp directory. A
|
||||
restore of the database dooms every capture (``invalidate_all``).
|
||||
"""
|
||||
if self._captures is None:
|
||||
self._captures = CaptureStore()
|
||||
if self._captures_unsubscribe is None:
|
||||
self._captures_unsubscribe = subscribe_history_restore(
|
||||
self.db.model.db_path, self._invalidate_captures
|
||||
)
|
||||
return self._captures
|
||||
|
||||
def _invalidate_captures(self) -> None:
|
||||
store = self._captures
|
||||
if store is not None:
|
||||
store.invalidate_all()
|
||||
|
||||
def close_annotation_captures(self) -> None:
|
||||
"""Remove every capture file and the owned directory. Idempotent."""
|
||||
unsubscribe, self._captures_unsubscribe = self._captures_unsubscribe, None
|
||||
if unsubscribe is not None:
|
||||
unsubscribe()
|
||||
store, self._captures = self._captures, None
|
||||
if store is not None:
|
||||
store.close()
|
||||
|
||||
async def cleanup_annotation_captures(self, app: web.Application) -> None:
|
||||
self.close_annotation_captures()
|
||||
|
||||
# -- shared request steps --------------------------------------------------
|
||||
|
||||
async def _annotation_body(self, request: web.Request, fields: frozenset):
|
||||
data, error = await self._read_json_body(request, max_bytes=MAX_BODY_BYTES)
|
||||
if error is not None:
|
||||
return None, error
|
||||
if set(data) != fields:
|
||||
return None, bad_request(
|
||||
"Request must contain exactly " + ", ".join(sorted(fields))
|
||||
)
|
||||
return data, None
|
||||
|
||||
def _scoped_root(self, index: int, scope: str):
|
||||
"""``(root, None)`` when the scope names a current root, else an error."""
|
||||
roots = list(self._get_all_output_dirs())
|
||||
error = self._gallery_scope_error(
|
||||
{"root_scope": scope, "root": str(index)}, roots
|
||||
)
|
||||
if error is not None:
|
||||
if error.status == 409:
|
||||
return None, _coded("source-changed")
|
||||
return None, error
|
||||
return roots[index], None
|
||||
|
||||
# -- observe ---------------------------------------------------------------
|
||||
|
||||
async def observe_image(self, request: web.Request) -> web.Response:
|
||||
"""POST ``{target}``: capture the file and answer its saved tag set.
|
||||
|
||||
The capture is the byte-exact copy the preview and Save refer to; the
|
||||
tags are those of exactly these bytes (an empty set at revision 0 for
|
||||
unseen bytes). Observing never writes tags; equal bytes refresh the
|
||||
record's verified fingerprint (S1).
|
||||
"""
|
||||
data, error = await self._annotation_body(request, _OBSERVE_FIELDS)
|
||||
if error is not None:
|
||||
return error
|
||||
target, error = _target(data["target"])
|
||||
if error is not None:
|
||||
return error
|
||||
index, scope, relative = target
|
||||
root, error = self._scoped_root(index, scope)
|
||||
if error is not None:
|
||||
return error
|
||||
store = self._capture_store()
|
||||
try:
|
||||
epoch = await self._run_in_executor(self.db.get_execution_epoch)
|
||||
capture = await _settled(
|
||||
_submit(
|
||||
store.capture,
|
||||
root,
|
||||
relative,
|
||||
epoch=epoch,
|
||||
scope=scope,
|
||||
root_index=index,
|
||||
),
|
||||
# A cancelled observe never hands its capture out: release it.
|
||||
lambda made: made is not None and store.release(made.token),
|
||||
)
|
||||
except CaptureError as exc:
|
||||
return _capture_error(exc)
|
||||
except Exception:
|
||||
self.logger.exception("Image observation failed")
|
||||
return _error(500, "observe-failed", "The image could not be observed.")
|
||||
try:
|
||||
stored, title = await self._run_in_executor(
|
||||
self._observe_capture, store, capture, root, relative
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
store.release(capture.token)
|
||||
raise
|
||||
except Exception:
|
||||
store.release(capture.token)
|
||||
self.logger.exception("Image observation failed")
|
||||
return _error(500, "observe-failed", "The image could not be observed.")
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"library_epoch": capture.epoch,
|
||||
"target": {"root_index": index, "root_scope": scope, "path": relative},
|
||||
"capture": _capture_info(store, capture),
|
||||
"title": title,
|
||||
"filename": relative.rsplit("/", 1)[-1],
|
||||
"tags": stored["tags"],
|
||||
"tag_revision": stored["tag_revision"],
|
||||
},
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
def _observe_capture(self, store: CaptureStore, capture, root, relative: str):
|
||||
"""Blocking part of observe: fingerprint refresh, tag read, title."""
|
||||
self.db.refresh_image_fingerprint(
|
||||
capture.locator, capture.sha256, capture.size, capture.mtime_ns
|
||||
)
|
||||
stored = self.db.read_image_tags(capture.locator, capture.sha256)
|
||||
return stored, self._observed_title(store, capture, root, relative)
|
||||
|
||||
def _observed_title(self, store: CaptureStore, capture, root, relative: str):
|
||||
"""The dialog's title under the card and inspector rule (``_verified_title``).
|
||||
|
||||
A stored Library title always shows; a first-line title only when the
|
||||
captured bytes' own records confirm it. An ambiguous association has no
|
||||
title. Videos read no card metadata, so only a stored title shows.
|
||||
"""
|
||||
path = str(Path(root) / relative)
|
||||
summary = self._workspace_link_summaries([path]).get(path)
|
||||
if summary is None:
|
||||
summary = _unavailable_link()
|
||||
positive_title = None
|
||||
if (
|
||||
capture.media_type != "video"
|
||||
and summary.get("title_source") == "text"
|
||||
and store.acquire(capture.token) is not None
|
||||
):
|
||||
try:
|
||||
facts = self._read_card_facts(Path(capture.path), parse=True)
|
||||
finally:
|
||||
store.release_use(capture.token)
|
||||
positive_title = facts.positive_title if facts is not None else None
|
||||
return _verified_title(summary, positive_title)
|
||||
|
||||
# -- capture preview -------------------------------------------------------
|
||||
|
||||
async def get_capture_preview(self, request: web.Request) -> web.StreamResponse:
|
||||
"""GET the captured bytes; the token is pinned while they stream."""
|
||||
token = request.match_info["token"]
|
||||
store = self._captures
|
||||
if store is None or _TOKEN.fullmatch(token) is None:
|
||||
return _error(404, "capture-expired", _MESSAGES["capture-expired"])
|
||||
capture = store.acquire(token)
|
||||
if capture is None:
|
||||
return _error(404, "capture-expired", _MESSAGES["capture-expired"])
|
||||
pinned = True
|
||||
try:
|
||||
_root, error = self._scoped_root(capture.root_index, capture.scope)
|
||||
if error is not None:
|
||||
# The roots changed under the token: it is no longer usable.
|
||||
store.release(token)
|
||||
return error
|
||||
try:
|
||||
handle = await _settled(
|
||||
_submit(_open_capture, capture.path),
|
||||
functools.partial(_close_then_release, store, token),
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
# Cancelled while the file opens: the pin ends only after the
|
||||
# handle closes (Windows cannot unlink an open file).
|
||||
pinned = False
|
||||
raise
|
||||
except OSError:
|
||||
store.release(token)
|
||||
return _error(404, "capture-expired", _MESSAGES["capture-expired"])
|
||||
try:
|
||||
return await self._stream_capture(request, capture, handle)
|
||||
finally:
|
||||
handle.close()
|
||||
finally:
|
||||
if pinned:
|
||||
store.release_use(token)
|
||||
|
||||
@staticmethod
|
||||
async def _stream_capture(request, capture, handle) -> web.StreamResponse:
|
||||
"""The capture's bytes, or the one byte range a ``Range`` header asks for.
|
||||
|
||||
The response is written here, inside the caller's pin, so the handle
|
||||
is closed and the pin ended only after the last byte is sent.
|
||||
"""
|
||||
size = capture.size
|
||||
headers = {
|
||||
"Content-Type": preview_content_type(Path(capture.path).suffix),
|
||||
"Accept-Ranges": "bytes",
|
||||
"Cache-Control": "no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
}
|
||||
status, start, end = 200, 0, size
|
||||
if "Range" in request.headers:
|
||||
wanted = _byte_range(request, size)
|
||||
if wanted is None:
|
||||
return web.Response(
|
||||
status=416,
|
||||
headers={**headers, "Content-Range": f"bytes */{size}"},
|
||||
)
|
||||
status, (start, end) = 206, wanted
|
||||
headers["Content-Range"] = f"bytes {start}-{end - 1}/{size}"
|
||||
headers["Content-Length"] = str(end - start)
|
||||
response = web.StreamResponse(status=status, headers=headers)
|
||||
await response.prepare(request)
|
||||
loop = asyncio.get_running_loop()
|
||||
if start:
|
||||
await loop.run_in_executor(None, handle.seek, start)
|
||||
remaining = end - start
|
||||
while remaining > 0 and request.method != "HEAD":
|
||||
chunk = await loop.run_in_executor(
|
||||
None, handle.read, min(_PREVIEW_CHUNK, remaining)
|
||||
)
|
||||
if not chunk:
|
||||
break
|
||||
remaining -= len(chunk)
|
||||
await response.write(chunk)
|
||||
await response.write_eof()
|
||||
return response
|
||||
|
||||
async def release_capture(self, request: web.Request) -> web.Response:
|
||||
"""DELETE a capture token; releasing an unknown token is a success."""
|
||||
token = request.match_info["token"]
|
||||
store = self._captures
|
||||
if store is not None and _TOKEN.fullmatch(token) is not None:
|
||||
store.release(token)
|
||||
return web.json_response(
|
||||
{"success": True}, headers={"Cache-Control": "no-store"}
|
||||
)
|
||||
|
||||
# -- save ------------------------------------------------------------------
|
||||
|
||||
async def save_image_tags(self, request: web.Request) -> web.Response:
|
||||
"""PUT the tag set of the previewed bytes, re-hashing the current file.
|
||||
|
||||
Writes only when the file still holds the captured bytes
|
||||
(``file-changed`` with zero writes otherwise), under the storage epoch
|
||||
and the tag revision the dialog read; an identical retry is
|
||||
``replayed``. The hash never runs inside a database transaction.
|
||||
"""
|
||||
data, error = await self._annotation_body(request, _SAVE_FIELDS)
|
||||
if error is not None:
|
||||
return error
|
||||
target, error = _target(data["target"])
|
||||
if error is not None:
|
||||
return error
|
||||
guards, error = _save_guards(data)
|
||||
if error is not None:
|
||||
return error
|
||||
token, sha256, expected_epoch, revision, tags = guards
|
||||
index, scope, relative = target
|
||||
root, error = self._scoped_root(index, scope)
|
||||
if error is not None:
|
||||
return error
|
||||
store = self._captures
|
||||
capture = store.get(token) if store is not None else None
|
||||
if capture is None:
|
||||
return _coded("capture-expired")
|
||||
if capture.root_index != index or capture.scope != scope:
|
||||
return _coded("capture-expired", _OTHER_FILE)
|
||||
if sha256 != capture.sha256:
|
||||
return bad_request("sha256 does not match the preview")
|
||||
if expected_epoch != capture.epoch:
|
||||
current = await self._run_in_executor(self.db.get_execution_epoch)
|
||||
return _coded("storage-changed", library_epoch=current)
|
||||
try:
|
||||
facts = await self._run_in_executor(
|
||||
_verify_captured_file, store, capture, root, relative
|
||||
)
|
||||
except CaptureError as exc:
|
||||
return _capture_error(exc)
|
||||
if facts is None or facts.locator != capture.locator:
|
||||
return _coded("capture-expired", _OTHER_FILE)
|
||||
if facts.sha256 != capture.sha256:
|
||||
return _coded("file-changed")
|
||||
try:
|
||||
receipt = await self._run_in_executor(
|
||||
self.db.replace_image_tags,
|
||||
locator=facts.locator,
|
||||
sha256=facts.sha256,
|
||||
size=facts.size,
|
||||
mtime_ns=facts.mtime_ns,
|
||||
expected_epoch=expected_epoch,
|
||||
expected_tag_revision=revision,
|
||||
tags=tags,
|
||||
)
|
||||
except AnnotationCommandError as exc:
|
||||
current = exc.current or {}
|
||||
return _coded(
|
||||
exc.code,
|
||||
library_epoch=exc.epoch,
|
||||
tags=current.get("tags", []),
|
||||
tag_revision=current.get("tag_revision", 0),
|
||||
)
|
||||
except LibraryCommandError as exc:
|
||||
return _coded(exc.code, library_epoch=exc.epoch)
|
||||
except ValueError as exc:
|
||||
return bad_request(str(exc))
|
||||
except Exception:
|
||||
self.logger.exception("Image tag save failed")
|
||||
return _error(500, "save-failed", "The tags could not be saved.")
|
||||
return web.json_response(
|
||||
{"success": True, **receipt}, headers={"Cache-Control": "no-store"}
|
||||
)
|
||||
|
||||
# -- suggest ---------------------------------------------------------------
|
||||
|
||||
async def suggest_image_tags(self, request: web.Request) -> web.Response:
|
||||
"""POST: the top N tags of the captured bytes from one legacy engine.
|
||||
|
||||
Runs only on the private capture (pinned for the whole inference),
|
||||
under the shared model gate (``model-busy`` while anything else holds
|
||||
it, including a legacy batch), never downloads (``model-unavailable``)
|
||||
and never writes.
|
||||
"""
|
||||
from ..autotag import MODELS, get_autotag_service
|
||||
from ..config import MAX_SUGGESTION_LIMIT, AutotagConfig
|
||||
|
||||
data, error = await self._annotation_body(request, SUGGEST_FIELDS)
|
||||
if error is not None:
|
||||
return error
|
||||
target, error = _target(data["target"])
|
||||
if error is not None:
|
||||
return error
|
||||
try:
|
||||
model_type, options = validate_options(
|
||||
data["model_type"],
|
||||
data["options"],
|
||||
default_limit=AutotagConfig.suggestion_limit(),
|
||||
max_limit=MAX_SUGGESTION_LIMIT,
|
||||
)
|
||||
except OptionError as exc:
|
||||
return bad_request(str(exc))
|
||||
token, epoch = data["capture_token"], data["expected_epoch"]
|
||||
if not isinstance(token, str) or _TOKEN.fullmatch(token) is None:
|
||||
return bad_request("Invalid capture token")
|
||||
if not isinstance(epoch, str) or _UUID.fullmatch(epoch) is None:
|
||||
return bad_request("expected_epoch must be a canonical UUID")
|
||||
index, scope, relative = target
|
||||
root, error = self._scoped_root(index, scope)
|
||||
if error is not None:
|
||||
return error
|
||||
store = self._captures
|
||||
capture = store.acquire(token) if store is not None else None
|
||||
if capture is None:
|
||||
return _coded("capture-expired")
|
||||
started = False
|
||||
try:
|
||||
error = await self._suggest_guards(capture, target, root, epoch)
|
||||
if error is not None:
|
||||
return error
|
||||
service = get_autotag_service()
|
||||
status = await self._run_in_executor(service.get_models_status)
|
||||
if not status.get(model_type, {}).get("downloaded"):
|
||||
name = MODELS.get(model_type, {}).get("name", model_type)
|
||||
return _coded("model-unavailable", unavailable_message(name))
|
||||
owner = model_gate.new_owner("image-annotations")
|
||||
if not model_gate.try_acquire(owner):
|
||||
return model_busy_response()
|
||||
try:
|
||||
# From here the pin is the gate's to end: the worker ends it,
|
||||
# or ``on_skipped`` does when the worker never starts.
|
||||
started = True
|
||||
pairs = await model_gate.run(
|
||||
owner,
|
||||
_pinned_suggestions,
|
||||
store,
|
||||
token,
|
||||
service,
|
||||
capture,
|
||||
model_type,
|
||||
options,
|
||||
on_skipped=functools.partial(store.release_use, token),
|
||||
)
|
||||
except Exception:
|
||||
self.logger.exception("Tag suggestion failed")
|
||||
return _error(
|
||||
500,
|
||||
"suggest-failed",
|
||||
"Auto-tagging failed. Check server logs for details.",
|
||||
)
|
||||
finally:
|
||||
model_gate.release(owner)
|
||||
finally:
|
||||
if not started:
|
||||
store.release_use(token)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"model_type": model_type,
|
||||
"options": options,
|
||||
"suggestions": suggestion_rows(pairs),
|
||||
},
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
async def _suggest_guards(self, capture, target, root, epoch):
|
||||
"""The capture must be this target's, of this epoch, and inferable."""
|
||||
index, scope, relative = target
|
||||
expected = os.path.normcase(
|
||||
os.path.join(os.path.realpath(root), *relative.split("/"))
|
||||
)
|
||||
if (
|
||||
capture.root_index != index
|
||||
or capture.scope != scope
|
||||
or capture.locator != expected
|
||||
):
|
||||
return _coded("capture-expired", _OTHER_FILE)
|
||||
if epoch != capture.epoch:
|
||||
current = await self._run_in_executor(self.db.get_execution_epoch)
|
||||
return _coded("storage-changed", library_epoch=current)
|
||||
if not capture.inferable:
|
||||
return _coded("unsupported-media", capture.inferable_reason)
|
||||
return None
|
||||
|
||||
|
||||
def _close_then_release(store: CaptureStore, token: str, handle) -> None:
|
||||
"""Close a preview handle a cancelled request opened, then end its pin."""
|
||||
try:
|
||||
if handle is not None:
|
||||
handle.close()
|
||||
finally:
|
||||
store.release_use(token)
|
||||
|
||||
|
||||
def _byte_range(request: web.Request, size: int):
|
||||
"""``(start, end)`` of the one satisfiable byte range asked for, else None.
|
||||
|
||||
A malformed, multi-range or unsatisfiable header is None (416), as
|
||||
aiohttp's own file responses answer it.
|
||||
"""
|
||||
try:
|
||||
wanted = request.http_range
|
||||
except ValueError:
|
||||
return None
|
||||
start, stop = wanted.start, wanted.stop
|
||||
if start is None:
|
||||
return None
|
||||
if start < 0: # a suffix: the last -start bytes
|
||||
start = max(0, size + start)
|
||||
stop = size
|
||||
stop = size if stop is None else min(stop, size)
|
||||
if start >= size or stop <= start:
|
||||
return None
|
||||
return start, stop
|
||||
|
||||
|
||||
def _pinned_suggestions(store, token, service, capture, model_type, options):
|
||||
"""Worker thread: infer on the capture, then end its pin (even on error)."""
|
||||
try:
|
||||
return run_suggestions(service, capture.path, model_type, options)
|
||||
finally:
|
||||
store.release_use(token)
|
||||
|
||||
|
||||
def _verify_captured_file(store: CaptureStore, capture, root, relative: str):
|
||||
"""Re-hash the current file, or None when the token names another file.
|
||||
|
||||
The cheap locator comparison runs first, so a token sent for another
|
||||
target never costs a whole-file hash.
|
||||
"""
|
||||
expected = os.path.normcase(
|
||||
os.path.join(os.path.realpath(root), *relative.split("/"))
|
||||
)
|
||||
if expected != capture.locator:
|
||||
return None
|
||||
return store.verify(root, relative)
|
||||
|
||||
|
||||
def _save_guards(data: dict):
|
||||
"""Validated ``(token, sha256, epoch, revision, tags)`` or ``(None, error)``."""
|
||||
token = data["capture_token"]
|
||||
if not isinstance(token, str) or _TOKEN.fullmatch(token) is None:
|
||||
return None, bad_request("Invalid capture token")
|
||||
sha256 = data["sha256"]
|
||||
if not isinstance(sha256, str) or _HEX64.fullmatch(sha256) is None:
|
||||
return None, bad_request("sha256 must be 64 lowercase hexadecimal characters")
|
||||
epoch = data["expected_epoch"]
|
||||
if not isinstance(epoch, str) or _UUID.fullmatch(epoch) is None:
|
||||
return None, bad_request("expected_epoch must be a canonical UUID")
|
||||
revision = data["expected_tag_revision"]
|
||||
if type(revision) is not int or revision < 0:
|
||||
return None, bad_request("expected_tag_revision must be a non-negative integer")
|
||||
try:
|
||||
tags = normalize_image_tags(data["tags"])
|
||||
except ImageTagLimitError as exc:
|
||||
return None, _error(400, "tag-limit", str(exc))
|
||||
except ValueError as exc:
|
||||
return None, bad_request(str(exc))
|
||||
return (token, sha256, epoch, revision, tags), None
|
||||
+76
-27
@@ -9,11 +9,24 @@ from typing import Optional
|
||||
from urllib.parse import quote
|
||||
|
||||
from .prompts import thumbnail_url_for
|
||||
from .. import model_gate
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
_INTERNAL_ERROR = "An internal error occurred. Check server logs for details."
|
||||
_BUSY_ERROR = "Auto-tagging is already running"
|
||||
MODEL_BUSY_ERROR = (
|
||||
"A tagging model is busy with another operation. Try again when it finishes."
|
||||
)
|
||||
|
||||
|
||||
def model_busy_response() -> web.Response:
|
||||
"""``409 model-busy``: another operation holds the shared model gate."""
|
||||
return web.json_response(
|
||||
{"success": False, "code": "model-busy", "error": MODEL_BUSY_ERROR},
|
||||
status=409,
|
||||
)
|
||||
|
||||
|
||||
# Only these may be handed to the tagging engine or listed by the scanner.
|
||||
_MEDIA_EXTENSIONS = frozenset(
|
||||
@@ -86,6 +99,7 @@ class AutotagRoutesMixin:
|
||||
"""Get status of available AutoTag models."""
|
||||
try:
|
||||
from ..autotag import get_autotag_service
|
||||
from ..config import AutotagConfig
|
||||
|
||||
service = get_autotag_service()
|
||||
models_status = service.get_models_status()
|
||||
@@ -99,6 +113,7 @@ class AutotagRoutesMixin:
|
||||
"loaded_model_type": service.get_loaded_model_type(),
|
||||
"wd14_general_threshold": service.wd14_general_threshold,
|
||||
"wd14_character_threshold": service.wd14_character_threshold,
|
||||
"suggestion_limit": AutotagConfig.suggestion_limit(),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -138,6 +153,7 @@ class AutotagRoutesMixin:
|
||||
async def download_autotag_model(self, request):
|
||||
"""Download an AutoTag model with streaming progress (POST only)."""
|
||||
model_type = request.match_info.get("model_type")
|
||||
owner = model_gate.new_owner("legacy-download")
|
||||
|
||||
async def events():
|
||||
try:
|
||||
@@ -160,8 +176,8 @@ class AutotagRoutesMixin:
|
||||
def progress_callback(status: str, progress: float):
|
||||
pass
|
||||
|
||||
success = await self._run_in_executor(
|
||||
service.download_model, model_type, progress_callback
|
||||
success = await model_gate.run(
|
||||
owner, service.download_model, model_type, progress_callback
|
||||
)
|
||||
if success:
|
||||
yield {
|
||||
@@ -178,7 +194,12 @@ class AutotagRoutesMixin:
|
||||
"message": _INTERNAL_ERROR,
|
||||
}
|
||||
|
||||
return await self._stream_sse(request, events())
|
||||
if not model_gate.try_acquire(owner):
|
||||
return model_busy_response()
|
||||
try:
|
||||
return await self._stream_sse(request, events())
|
||||
finally:
|
||||
model_gate.release(owner)
|
||||
|
||||
async def _read_start_params(self, request) -> Optional[dict]:
|
||||
"""Merge query-string and optional JSON-body parameters for autotag/start.
|
||||
@@ -239,8 +260,9 @@ class AutotagRoutesMixin:
|
||||
return
|
||||
|
||||
yield {"type": "progress", "progress": 0, "status": "Loading model..."}
|
||||
run = self._gated_runner(opts.get("gate_owner"))
|
||||
try:
|
||||
await self._run_in_executor(service.load_model, model_type, True)
|
||||
await run(service.load_model, model_type, True)
|
||||
except Exception:
|
||||
self.logger.exception("Failed to load model")
|
||||
yield {
|
||||
@@ -338,7 +360,7 @@ class AutotagRoutesMixin:
|
||||
continue
|
||||
|
||||
try:
|
||||
tags = await self._run_in_executor(
|
||||
tags = await run(
|
||||
service.generate_tags,
|
||||
allowed_path,
|
||||
general_threshold=opts["general_threshold"],
|
||||
@@ -423,10 +445,26 @@ class AutotagRoutesMixin:
|
||||
{"success": False, "error": _BUSY_ERROR}, status=409
|
||||
)
|
||||
try:
|
||||
return await self._stream_sse(request, events())
|
||||
opts["gate_owner"] = model_gate.new_owner("legacy-batch")
|
||||
if not model_gate.try_acquire(opts["gate_owner"]):
|
||||
return model_busy_response()
|
||||
try:
|
||||
return await self._stream_sse(request, events())
|
||||
finally:
|
||||
model_gate.release(opts["gate_owner"])
|
||||
finally:
|
||||
_autotag_batch_lock.release()
|
||||
|
||||
def _gated_runner(self, owner):
|
||||
"""Executor calls under the model gate *owner* holds (plain when None)."""
|
||||
if owner is None:
|
||||
return self._run_in_executor
|
||||
|
||||
async def run(func, *args, **kwargs):
|
||||
return await model_gate.run(owner, func, *args, **kwargs)
|
||||
|
||||
return run
|
||||
|
||||
def _resolve_allowed_image_path(self, raw_path: str) -> Optional[str]:
|
||||
"""Return the real path when it is a media file inside an output directory."""
|
||||
if Path(str(raw_path)).suffix.lower() not in _MEDIA_EXTENSIONS:
|
||||
@@ -549,22 +587,28 @@ class AutotagRoutesMixin:
|
||||
from ..autotag import get_autotag_service
|
||||
|
||||
service = get_autotag_service()
|
||||
owner = model_gate.new_owner("legacy-single")
|
||||
if not model_gate.try_acquire(owner):
|
||||
return model_busy_response()
|
||||
try:
|
||||
if (
|
||||
not service.is_model_loaded()
|
||||
or service.get_loaded_model_type() != model_type
|
||||
):
|
||||
await model_gate.run(owner, service.load_model, model_type, use_gpu)
|
||||
|
||||
if (
|
||||
not service.is_model_loaded()
|
||||
or service.get_loaded_model_type() != model_type
|
||||
):
|
||||
await self._run_in_executor(service.load_model, model_type, use_gpu)
|
||||
if custom_prompt:
|
||||
service.custom_prompt = custom_prompt
|
||||
|
||||
if custom_prompt:
|
||||
service.custom_prompt = custom_prompt
|
||||
|
||||
tags = await self._run_in_executor(
|
||||
service.generate_tags,
|
||||
image_path,
|
||||
general_threshold=general_threshold,
|
||||
character_threshold=character_threshold,
|
||||
)
|
||||
tags = await model_gate.run(
|
||||
owner,
|
||||
service.generate_tags,
|
||||
image_path,
|
||||
general_threshold=general_threshold,
|
||||
character_threshold=character_threshold,
|
||||
)
|
||||
finally:
|
||||
model_gate.release(owner)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
@@ -636,14 +680,19 @@ class AutotagRoutesMixin:
|
||||
from ..autotag import get_autotag_service
|
||||
|
||||
service = get_autotag_service()
|
||||
owner = model_gate.new_owner("legacy-unload")
|
||||
if not model_gate.try_acquire(owner):
|
||||
return model_busy_response()
|
||||
try:
|
||||
if not service.is_model_loaded():
|
||||
return web.json_response(
|
||||
{"success": True, "message": "No model was loaded"}
|
||||
)
|
||||
|
||||
if not service.is_model_loaded():
|
||||
return web.json_response(
|
||||
{"success": True, "message": "No model was loaded"}
|
||||
)
|
||||
|
||||
model_type = service.get_loaded_model_type()
|
||||
service.unload_model()
|
||||
model_type = service.get_loaded_model_type()
|
||||
service.unload_model()
|
||||
finally:
|
||||
model_gate.release(owner)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
|
||||
@@ -388,6 +388,9 @@ class HistoryRoutesMixin:
|
||||
routes.get("/prompt_manager/library/{positive_id}/history")(
|
||||
self.get_library_history
|
||||
)
|
||||
routes.get(
|
||||
"/prompt_manager/library/{positive_id}/history/pairs/{pair_id}/newest"
|
||||
)(self.get_newest_pair_run)
|
||||
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
|
||||
@@ -473,6 +476,40 @@ class HistoryRoutesMixin:
|
||||
|
||||
return _success_response(await self._run_in_executor(project))
|
||||
|
||||
@_history_errors
|
||||
async def get_newest_pair_run(self, request: web.Request) -> web.Response:
|
||||
"""The pair's newest recorded run for the history run dot (#249 D26)."""
|
||||
_query(request)
|
||||
if request.query:
|
||||
raise ValueError("Invalid newest pair run query")
|
||||
positive_id = _decimal(request.match_info["positive_id"], minimum=1)
|
||||
pair_id = _decimal(request.match_info["pair_id"], minimum=1)
|
||||
fence = self._history_fence()
|
||||
db = self.db
|
||||
|
||||
def project() -> bytes:
|
||||
generation = fence.begin()
|
||||
try:
|
||||
with db.recorded_history_snapshot() as epoch:
|
||||
result = db.get_newest_pair_run(positive_id, pair_id)
|
||||
finally:
|
||||
db.model.close()
|
||||
if result is None:
|
||||
fence.validate(epoch, generation)
|
||||
raise HistoryReadError(404, "not-found")
|
||||
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)
|
||||
|
||||
+1141
-145
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
||||
import os
|
||||
import stat
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
try:
|
||||
from ...utils.prompt_inspector import (
|
||||
@@ -93,8 +94,19 @@ def _in_host_temp(path: Path) -> bool:
|
||||
|
||||
|
||||
def inspect_current_file(
|
||||
path: Path, *, source_label: str, saved: dict | None, library: dict
|
||||
path: Path,
|
||||
*,
|
||||
source_label: str,
|
||||
saved: dict | None,
|
||||
library: dict,
|
||||
image_tags: Callable[[int, int], dict] | None = None,
|
||||
) -> dict:
|
||||
"""Current-file evidence read between two equal stats of *path*.
|
||||
|
||||
*image_tags*, when given, is called once with that stable
|
||||
``(st_size, st_mtime_ns)`` and its facet is returned as ``image_tags``
|
||||
(spec amendment S1: tags of a byte record verified at exactly that stat).
|
||||
"""
|
||||
before = _current_file_facts(path)
|
||||
try:
|
||||
inspection = read_inspection_file(path)
|
||||
@@ -104,6 +116,9 @@ def inspect_current_file(
|
||||
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
|
||||
facets = {}
|
||||
if image_tags is not None:
|
||||
facets["image_tags"] = image_tags(before.st_size, before.st_mtime_ns)
|
||||
if inspection is None:
|
||||
evidence = unreadable_evidence()
|
||||
else:
|
||||
@@ -132,4 +147,5 @@ def inspect_current_file(
|
||||
**evidence,
|
||||
"inspection": inspection,
|
||||
"library": library,
|
||||
**facets,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Session job routes (Activity): list, inspect, follow and cancel jobs.
|
||||
|
||||
The registry itself is ``py/api/jobs.py``; the scan job's runner is in
|
||||
``py/api/admin.py``. Every response leaves out absolute server paths: job
|
||||
rows already carry root-relative paths and public root labels.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from .jobs import DEFAULT_SCAN_SCOPE, SCAN_SCOPES, JobBusy
|
||||
|
||||
|
||||
def _not_found():
|
||||
return web.json_response({"success": False, "error": "Job not found"}, status=404)
|
||||
|
||||
|
||||
def _bad_request(message):
|
||||
return web.json_response({"success": False, "error": message}, status=400)
|
||||
|
||||
|
||||
async def _scan_scope(request):
|
||||
"""The requested scan scope ("new" or "all"), or None when the body is invalid.
|
||||
|
||||
An empty body (also a chunked one with no bytes), or one without
|
||||
``scope``, asks for the default.
|
||||
"""
|
||||
if not request.can_read_body:
|
||||
return DEFAULT_SCAN_SCOPE
|
||||
text = await request.text()
|
||||
if not text.strip(): # e.g. a chunked POST that sent no bytes
|
||||
return DEFAULT_SCAN_SCOPE
|
||||
try:
|
||||
body = json.loads(text)
|
||||
except ValueError:
|
||||
return None
|
||||
if not isinstance(body, dict):
|
||||
return None
|
||||
scope = body.get("scope", DEFAULT_SCAN_SCOPE)
|
||||
return scope if scope in SCAN_SCOPES else None
|
||||
|
||||
|
||||
def _summary_event(job):
|
||||
return f"data: {json.dumps(job.summary())}\n\n".encode("utf-8")
|
||||
|
||||
|
||||
class JobRoutesMixin:
|
||||
"""Mixin providing the /prompt_manager/jobs endpoints."""
|
||||
|
||||
def _register_job_routes(self, routes):
|
||||
@routes.get("/prompt_manager/jobs")
|
||||
async def list_jobs_route(request):
|
||||
return await self.list_jobs(request)
|
||||
|
||||
@routes.post("/prompt_manager/jobs/scan")
|
||||
async def start_scan_job_route(request):
|
||||
return await self.start_scan_job(request)
|
||||
|
||||
@routes.get("/prompt_manager/jobs/{job_id}")
|
||||
async def job_detail_route(request):
|
||||
return await self.job_detail(request)
|
||||
|
||||
@routes.get("/prompt_manager/jobs/{job_id}/report")
|
||||
async def job_report_route(request):
|
||||
return await self.job_report(request)
|
||||
|
||||
@routes.get("/prompt_manager/jobs/{job_id}/events")
|
||||
async def job_events_route(request):
|
||||
return await self.job_events(request)
|
||||
|
||||
@routes.post("/prompt_manager/jobs/{job_id}/cancel")
|
||||
async def cancel_job_route(request):
|
||||
return await self.cancel_job(request)
|
||||
|
||||
async def list_jobs(self, request):
|
||||
"""This session's jobs: running first, then finished, newest first."""
|
||||
jobs = [job.summary() for job in self._jobs.list()]
|
||||
return web.json_response({"success": True, "jobs": jobs})
|
||||
|
||||
async def job_detail(self, request):
|
||||
job = self._jobs.get(request.match_info["job_id"])
|
||||
if job is None:
|
||||
return _not_found()
|
||||
return web.json_response({"success": True, "job": job.detail()})
|
||||
|
||||
async def job_report(self, request):
|
||||
"""The job's whole record as a JSON attachment (no absolute paths)."""
|
||||
job = self._jobs.get(request.match_info["job_id"])
|
||||
if job is None:
|
||||
return _not_found()
|
||||
body = json.dumps(job.report(), indent=2)
|
||||
disposition = f'attachment; filename="{job.report_filename()}"'
|
||||
return web.Response(
|
||||
text=body,
|
||||
content_type="application/json",
|
||||
headers={
|
||||
"Content-Disposition": disposition,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
)
|
||||
|
||||
async def cancel_job(self, request):
|
||||
"""Stop a job at its next file boundary; a finished job is a no-op."""
|
||||
job_id = request.match_info["job_id"]
|
||||
if self._jobs.get(job_id) is None:
|
||||
return _not_found()
|
||||
requested = self._jobs.cancel(job_id)
|
||||
return web.json_response({"success": True, "cancellation_requested": requested})
|
||||
|
||||
async def start_scan_job(self, request):
|
||||
"""Start the metadata scan as a job (202), or 409 naming the busy job.
|
||||
|
||||
The body's ``scope`` is "new" (the default: new and unresolved files)
|
||||
or "all"; anything else is a 400.
|
||||
"""
|
||||
files = await _scan_scope(request)
|
||||
if files is None:
|
||||
return _bad_request('The scan scope must be "new" or "all"')
|
||||
try:
|
||||
job = self._start_scan_job(files)
|
||||
except JobBusy as busy:
|
||||
running = busy.running
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"code": "busy",
|
||||
"error": str(busy),
|
||||
"running": {"id": running.id, "title": running.title},
|
||||
},
|
||||
status=409,
|
||||
)
|
||||
return web.json_response({"success": True, "job": job.summary()}, status=202)
|
||||
|
||||
async def job_events(self, request):
|
||||
"""Stream the job's summary as SSE: now, after each event, and at the end."""
|
||||
job = self._jobs.get(request.match_info["job_id"])
|
||||
if job is None:
|
||||
return _not_found()
|
||||
response = web.StreamResponse(
|
||||
status=200,
|
||||
reason="OK",
|
||||
headers={
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
)
|
||||
await response.prepare(request)
|
||||
try:
|
||||
await response.write(_summary_event(job))
|
||||
async with contextlib.aclosing(job.events()) as events:
|
||||
async for _payload in events:
|
||||
await response.write(_summary_event(job))
|
||||
# The last event is published before the job takes its final state.
|
||||
await response.write(_summary_event(job))
|
||||
await response.write_eof()
|
||||
except Exception as e: # the socket closed under us; the job goes on
|
||||
self.logger.info(f"Job progress client disconnected ({e})")
|
||||
return response
|
||||
+359
@@ -0,0 +1,359 @@
|
||||
"""The session job registry: every long operation as one tracked job.
|
||||
|
||||
A job runs as an asyncio task that outlives the HTTP request that started it,
|
||||
so an accidental tab close never abandons half-finished work. Each job keeps
|
||||
its counters, a capped list of file outcomes per outcome group and a capped
|
||||
log, and fans its progress out to SSE subscribers: the request that started
|
||||
it, a reconnecting page, a second tab. A subscriber receives the latest event
|
||||
as a snapshot and then every event published after it, until the job ends.
|
||||
|
||||
Jobs live only in server memory and are gone after a restart. The registry
|
||||
keeps the running jobs and this session's last ``MAX_FINISHED_JOBS`` finished
|
||||
ones. Record-changing jobs (the metadata scan, and later maintenance, cleanup
|
||||
and restore) never run together, and one job of a kind runs at a time.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import itertools
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import PurePosixPath, PureWindowsPath
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
# Start and finish order: wall-clock times can tie (coarse Windows clocks).
|
||||
_sequence = itertools.count()
|
||||
|
||||
JOB_STATES = ("running", "finished", "cancelled", "failed")
|
||||
FINAL_STATES = JOB_STATES[1:]
|
||||
MAX_OUTCOME_ROWS = 200 # rows listed per outcome group; counters stay exact
|
||||
MAX_LOG_LINES = 2000
|
||||
LOG_CAP_LINE = "[Log capped at 2,000 lines]"
|
||||
MAX_FINISHED_JOBS = 20 # finished jobs this session keeps
|
||||
|
||||
# The metadata scan's outcome groups, in counter-tile order of the spec's
|
||||
# mapping, and the per-file scan status each group collects. Files without
|
||||
# usable metadata have no status and go to "no_metadata".
|
||||
# Which files a scan reads (#249 Activity-2): "new" skips the files the Library
|
||||
# already links with a resolved link (get_known_resolved_paths) before reading
|
||||
# them, so new, unlinked, unresolved and ambiguous files are read; "all" reads
|
||||
# every file again. The workspace job defaults to "new"; legacy /scan is "all".
|
||||
SCAN_SCOPES = ("new", "all")
|
||||
DEFAULT_SCAN_SCOPE = "new"
|
||||
SCAN_GROUP_NAMES = ("added", "known", "review", "no_metadata", "errors")
|
||||
SCAN_GROUPS = {
|
||||
"added": "added",
|
||||
"linked": "added",
|
||||
"enriched": "added",
|
||||
"unchanged": "known",
|
||||
"skipped": "known", # "new" scope: known and resolved, not read again
|
||||
"ambiguous": "review",
|
||||
"conflict": "review",
|
||||
"unresolved": "review",
|
||||
"failed": "errors",
|
||||
}
|
||||
|
||||
|
||||
def _relative_path(path) -> str:
|
||||
"""``path`` as a root-relative POSIX path; an absolute one keeps its name."""
|
||||
text = str(path or "")
|
||||
if (
|
||||
PurePosixPath(text).is_absolute()
|
||||
or PureWindowsPath(text).is_absolute()
|
||||
or PureWindowsPath(text).drive
|
||||
):
|
||||
return re.split(r"[\\/]", text)[-1]
|
||||
return text.replace("\\", "/")
|
||||
|
||||
|
||||
class JobBusy(Exception):
|
||||
"""A job could not start because ``running`` holds its slot."""
|
||||
|
||||
def __init__(self, running):
|
||||
super().__init__(f"{running.title} is already running")
|
||||
self.running = running
|
||||
|
||||
|
||||
class Job:
|
||||
"""One tracked operation: state, counters, outcomes, log and subscribers."""
|
||||
|
||||
def __init__(
|
||||
self, kind, title, *, record_changing=False, scope=None, groups=(), clock=None
|
||||
):
|
||||
self._clock = clock or time.time
|
||||
self.id = uuid.uuid4().hex
|
||||
self.kind = kind
|
||||
self.title = title
|
||||
self.record_changing = record_changing
|
||||
self.scope = dict(scope or {})
|
||||
self.state = "running"
|
||||
self.started_at = self._clock()
|
||||
self._started_seq = next(_sequence)
|
||||
self._finished_seq = None
|
||||
self.finished_at = None
|
||||
self.progress = 0
|
||||
self.status_line = ""
|
||||
self.processed = 0
|
||||
self.total = 0
|
||||
self.counters = dict.fromkeys(groups, 0)
|
||||
self.outcomes = {group: [] for group in groups}
|
||||
self.log = []
|
||||
self.cancelled = False
|
||||
self.last_event = None
|
||||
self.task = None
|
||||
self._subscribers = set()
|
||||
|
||||
@property
|
||||
def running(self):
|
||||
return self.state == "running"
|
||||
|
||||
@property
|
||||
def finished(self):
|
||||
return not self.running
|
||||
|
||||
@property
|
||||
def subscriber_count(self):
|
||||
return len(self._subscribers)
|
||||
|
||||
# ── progress ──────────────────────────────────────────────────────
|
||||
|
||||
def update(self, *, progress=None, status_line=None, processed=None, total=None):
|
||||
"""Set the summary's progress fields; ``None`` leaves one unchanged."""
|
||||
if progress is not None:
|
||||
self.progress = max(0, min(100, int(progress)))
|
||||
if status_line is not None:
|
||||
self.status_line = status_line
|
||||
if processed is not None:
|
||||
self.processed = processed
|
||||
if total is not None:
|
||||
self.total = total
|
||||
|
||||
def publish(self, payload):
|
||||
"""Record ``payload`` as the latest event and hand it to every subscriber."""
|
||||
if isinstance(payload.get("progress"), (int, float)):
|
||||
self.update(progress=payload["progress"])
|
||||
self.last_event = payload
|
||||
for queue in list(self._subscribers):
|
||||
queue.put_nowait(payload)
|
||||
|
||||
def log_line(self, text):
|
||||
"""Append a timestamped log line, up to MAX_LOG_LINES and one cap line."""
|
||||
if len(self.log) > MAX_LOG_LINES:
|
||||
return
|
||||
if len(self.log) == MAX_LOG_LINES:
|
||||
self.log.append(LOG_CAP_LINE)
|
||||
return
|
||||
stamp = time.strftime("%H:%M:%S", time.localtime(self._clock()))
|
||||
self.log.append(f"{stamp} {text}")
|
||||
|
||||
def record(self, group, row, log_line):
|
||||
"""Count one file outcome in ``group``, list its row and log it.
|
||||
|
||||
``row`` is ``{path, root, root_index, detail}``: the path relative to
|
||||
its output root, the root's public label and its listing index (the
|
||||
identity Images opens it by; None when unknown). An absolute path is
|
||||
cut to its file name, so a row never reveals the server's layout.
|
||||
"""
|
||||
if group not in self.counters:
|
||||
raise ValueError(f"Unknown outcome group {group!r} for {self.kind}")
|
||||
self.counters[group] += 1
|
||||
raw = str(row.get("path") or "")
|
||||
path = _relative_path(raw)
|
||||
if raw and raw != path:
|
||||
log_line = log_line.replace(raw, path)
|
||||
rows = self.outcomes[group]
|
||||
index = row.get("root_index")
|
||||
if isinstance(index, bool) or not isinstance(index, int) or index < 0:
|
||||
index = None
|
||||
if len(rows) < MAX_OUTCOME_ROWS:
|
||||
rows.append(
|
||||
{
|
||||
"path": path,
|
||||
"root": str(row.get("root") or ""),
|
||||
"root_index": index,
|
||||
"detail": str(row.get("detail") or ""),
|
||||
}
|
||||
)
|
||||
self.log_line(log_line)
|
||||
|
||||
def finish(self, state="finished"):
|
||||
"""End the job in ``state`` and release every subscriber.
|
||||
|
||||
Each subscriber still gets the events queued before the release. A
|
||||
second finish is a no-op, so the first final state stands.
|
||||
"""
|
||||
if state not in FINAL_STATES:
|
||||
raise ValueError(f"{state!r} is not a final job state")
|
||||
if self.finished:
|
||||
return
|
||||
self.state = state
|
||||
self.finished_at = self._clock()
|
||||
self._finished_seq = next(_sequence)
|
||||
for queue in list(self._subscribers):
|
||||
queue.put_nowait(None)
|
||||
|
||||
async def events(self):
|
||||
"""Yield the latest event, then each later one, until the job finishes.
|
||||
|
||||
Consume it under ``contextlib.aclosing`` so a consumer that stops early
|
||||
(a closed socket) drops its queue at once instead of at garbage collection.
|
||||
"""
|
||||
queue = asyncio.Queue()
|
||||
self._subscribers.add(queue)
|
||||
try:
|
||||
if self.last_event is not None:
|
||||
yield self.last_event
|
||||
if self.finished:
|
||||
return
|
||||
while True:
|
||||
payload = await queue.get()
|
||||
if payload is None:
|
||||
return
|
||||
yield payload
|
||||
finally:
|
||||
self._subscribers.discard(queue)
|
||||
|
||||
# ── views ─────────────────────────────────────────────────────────
|
||||
|
||||
def summary(self):
|
||||
"""The job as the Activity list and pill show it."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"kind": self.kind,
|
||||
"title": self.title,
|
||||
"state": self.state,
|
||||
"started_at": self.started_at,
|
||||
"finished_at": self.finished_at,
|
||||
"progress": self.progress,
|
||||
"status_line": self.status_line,
|
||||
"processed": self.processed,
|
||||
"total": self.total,
|
||||
"counters": dict(self.counters),
|
||||
"scope": dict(self.scope),
|
||||
# What detail() lists, so a view re-reads it only when it changed.
|
||||
"listed": {group: len(rows) for group, rows in self.outcomes.items()},
|
||||
"log_length": len(self.log),
|
||||
}
|
||||
|
||||
def detail(self):
|
||||
"""The summary plus the listed outcome rows and the log."""
|
||||
return {
|
||||
**self.summary(),
|
||||
"outcomes": {
|
||||
group: [dict(row) for row in rows]
|
||||
for group, rows in self.outcomes.items()
|
||||
},
|
||||
"log": list(self.log),
|
||||
}
|
||||
|
||||
def report(self):
|
||||
"""The downloadable report: the summary, every outcome group and the log.
|
||||
|
||||
Each group carries its exact count, its listed rows (at most
|
||||
MAX_OUTCOME_ROWS) and whether the list is cut short. Rows and log lines
|
||||
already hold root-relative paths only.
|
||||
"""
|
||||
summary = self.summary()
|
||||
summary.pop("listed", None)
|
||||
summary.pop("log_length", None)
|
||||
return {
|
||||
"report_version": 1,
|
||||
"generated_at": self._clock(),
|
||||
"job": summary,
|
||||
"outcomes": {
|
||||
group: {
|
||||
"count": self.counters.get(group, len(rows)),
|
||||
"rows": [dict(row) for row in rows],
|
||||
"truncated": self.counters.get(group, len(rows)) > len(rows),
|
||||
}
|
||||
for group, rows in self.outcomes.items()
|
||||
},
|
||||
"log": list(self.log),
|
||||
}
|
||||
|
||||
def report_filename(self):
|
||||
"""The download name: prompt-manager-KIND-YYYYMMDD-HHMM.json (local time)."""
|
||||
stamp = time.strftime("%Y%m%d-%H%M", time.localtime(self._clock()))
|
||||
return f"prompt-manager-{self.kind}-{stamp}.json"
|
||||
|
||||
|
||||
class JobRegistry:
|
||||
"""Every job of this server session, running and recently finished."""
|
||||
|
||||
def __init__(self, logger_=None):
|
||||
self._logger = logger_ or logger
|
||||
self._jobs = {}
|
||||
|
||||
def start(self, kind, title, *, record_changing, scope, runner, groups=()) -> Job:
|
||||
"""Create a job and run ``runner(job)`` as a task.
|
||||
|
||||
Raises JobBusy, naming the job in the way, when a job of ``kind`` is
|
||||
running or when ``record_changing`` and another record-changing job
|
||||
runs. Must be called from the event loop.
|
||||
"""
|
||||
same_kind = self.active(kind)
|
||||
if same_kind is not None:
|
||||
raise JobBusy(same_kind)
|
||||
if record_changing:
|
||||
for job in self._jobs.values():
|
||||
if job.running and job.record_changing:
|
||||
raise JobBusy(job)
|
||||
self._prune()
|
||||
job = Job(
|
||||
kind, title, record_changing=record_changing, scope=scope, groups=groups
|
||||
)
|
||||
self._jobs[job.id] = job
|
||||
job.task = asyncio.ensure_future(self._run(job, runner))
|
||||
return job
|
||||
|
||||
async def _run(self, job, runner):
|
||||
try:
|
||||
await runner(job)
|
||||
except asyncio.CancelledError:
|
||||
job.finish("cancelled")
|
||||
raise
|
||||
except Exception:
|
||||
self._logger.exception(f"Job {job.kind} failed")
|
||||
job.finish("failed")
|
||||
finally:
|
||||
if job.running:
|
||||
job.finish("cancelled" if job.cancelled else "finished")
|
||||
|
||||
def _prune(self):
|
||||
"""Drop the oldest finished jobs so a new one keeps the cap."""
|
||||
finished = [job for job in self._jobs.values() if job.finished]
|
||||
finished.sort(key=lambda job: job._finished_seq)
|
||||
for job in finished[: max(0, len(finished) - MAX_FINISHED_JOBS + 1)]:
|
||||
del self._jobs[job.id]
|
||||
|
||||
def get(self, job_id):
|
||||
return self._jobs.get(job_id)
|
||||
|
||||
def active(self, kind):
|
||||
"""The running job of ``kind``, or None."""
|
||||
for job in self._jobs.values():
|
||||
if job.kind == kind and job.running:
|
||||
return job
|
||||
return None
|
||||
|
||||
def list(self):
|
||||
"""Running jobs first, then the last finished ones, newest first."""
|
||||
jobs = list(self._jobs.values())
|
||||
running = [job for job in jobs if job.running]
|
||||
finished = [job for job in jobs if job.finished]
|
||||
running.sort(key=lambda job: job._started_seq, reverse=True)
|
||||
finished.sort(key=lambda job: job._finished_seq, reverse=True)
|
||||
return running + finished[:MAX_FINISHED_JOBS]
|
||||
|
||||
def cancel(self, job_id) -> bool:
|
||||
"""Ask a running job to stop at its next safe boundary.
|
||||
|
||||
Returns whether a cancellation was requested: False for a finished or
|
||||
unknown job, so a cancel after the end is a no-op.
|
||||
"""
|
||||
job = self._jobs.get(job_id)
|
||||
if job is None or job.finished:
|
||||
return False
|
||||
job.cancelled = True
|
||||
return True
|
||||
+8
-3
@@ -233,7 +233,9 @@ class LibraryRoutesMixin:
|
||||
self.add_library_negative
|
||||
)
|
||||
|
||||
def _library_media(self, records: list[dict], roots: list[Path]) -> list[dict]:
|
||||
def _library_media(
|
||||
self, records: list[dict], roots: list[Path], *, linked: bool = True
|
||||
) -> list[dict]:
|
||||
page = []
|
||||
for record in records:
|
||||
try:
|
||||
@@ -251,7 +253,7 @@ class LibraryRoutesMixin:
|
||||
break
|
||||
except (OSError, ValueError, RuntimeError):
|
||||
continue
|
||||
return self._format_workspace_page(page)
|
||||
return self._format_workspace_page(page, linked=linked)
|
||||
|
||||
@pair_response
|
||||
async def get_library_options(self, request: web.Request) -> web.Response:
|
||||
@@ -296,7 +298,10 @@ class LibraryRoutesMixin:
|
||||
|
||||
def previews() -> None:
|
||||
for parent in result["items"]:
|
||||
parent["previews"] = self._library_media(records[parent["id"]], roots)
|
||||
# Previews belong to their parent; a per-parent link read is N+1.
|
||||
parent["previews"] = self._library_media(
|
||||
records[parent["id"]], roots, linked=False
|
||||
)
|
||||
|
||||
await self._run_in_executor(previews)
|
||||
return web.json_response({"success": True, **result})
|
||||
|
||||
+43
-5
@@ -22,6 +22,29 @@ MAX_PAGE_OFFSET = 10_000_000
|
||||
# for one giant result set (and silently truncating large libraries).
|
||||
EXPORT_PAGE_SIZE = 1000
|
||||
|
||||
# The most prompts one bulk category request may change.
|
||||
BULK_CATEGORY_MAX_PROMPTS = 1000
|
||||
|
||||
# The largest value SQLite's INTEGER can bind; a larger id raises OverflowError.
|
||||
MAX_SQLITE_INT = 2**63 - 1
|
||||
|
||||
|
||||
def parse_bulk_prompt_ids(value, cap=BULK_CATEGORY_MAX_PROMPTS):
|
||||
"""A bulk request's ``prompt_ids``: distinct positive ints, first-seen order.
|
||||
|
||||
Raises ``ValueError`` unless ``value`` is a non-empty list of at most
|
||||
``cap`` distinct positive integers no larger than SQLite's INTEGER
|
||||
(booleans are not integers here).
|
||||
"""
|
||||
if not isinstance(value, list) or not value:
|
||||
raise ValueError("prompt_ids must be a non-empty list")
|
||||
if any(type(pid) is not int or not 1 <= pid <= MAX_SQLITE_INT for pid in value):
|
||||
raise ValueError("prompt_ids must be positive integers")
|
||||
ids = list(dict.fromkeys(value))
|
||||
if len(ids) > cap:
|
||||
raise ValueError(f"At most {cap} prompts can be changed at once")
|
||||
return ids
|
||||
|
||||
|
||||
def parse_page_params(query, default_limit=50, max_limit=MAX_PAGE_LIMIT):
|
||||
"""Return ``(limit, offset)`` from a query mapping, clamped to safe bounds.
|
||||
@@ -1145,14 +1168,29 @@ class PromptRoutesMixin:
|
||||
async def bulk_set_category(self, request):
|
||||
"""Bulk set category for prompts."""
|
||||
try:
|
||||
data = await request.json()
|
||||
prompt_ids = data.get("prompt_ids", [])
|
||||
category = (data.get("category") or "").strip()
|
||||
|
||||
if not prompt_ids:
|
||||
try:
|
||||
data = await request.json()
|
||||
except ValueError:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Invalid JSON body"}, status=400
|
||||
)
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Expected a JSON object"}, status=400
|
||||
)
|
||||
if not data.get("prompt_ids"):
|
||||
return web.json_response(
|
||||
{"success": False, "error": "No prompt IDs provided"}, status=400
|
||||
)
|
||||
category = data.get("category")
|
||||
try:
|
||||
prompt_ids = parse_bulk_prompt_ids(data["prompt_ids"])
|
||||
validate_category(category)
|
||||
except ValueError as ve:
|
||||
return web.json_response(
|
||||
{"success": False, "error": str(ve)}, status=400
|
||||
)
|
||||
category = (category or "").strip()
|
||||
|
||||
updated_count = await self._run_in_executor(
|
||||
self.db.bulk_set_category, prompt_ids, category
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Saved views (D16): list, create, rename and delete named toolbar states.
|
||||
|
||||
The page, the name and the whitelisted per-page state are validated here
|
||||
(through ``database/saved_views.py``) before anything is written. Only that
|
||||
request validation (``SavedViewInvalid``) is a 400 carrying its message; any
|
||||
other failure, including a ``ValueError`` raised by the database layer, is a
|
||||
logged 500 with a generic message.
|
||||
"""
|
||||
|
||||
from functools import wraps
|
||||
from typing import Callable
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
try:
|
||||
from ...database.saved_views import SavedViewInvalid, encode_state, validate_name
|
||||
except ImportError:
|
||||
from database.saved_views import SavedViewInvalid, encode_state, validate_name
|
||||
|
||||
_CREATE_FIELDS = frozenset({"page", "name", "state"})
|
||||
_RENAME_FIELDS = frozenset({"name"})
|
||||
_MAX_ID = 2**63 - 1
|
||||
|
||||
|
||||
class SavedViewNotFound(LookupError):
|
||||
pass
|
||||
|
||||
|
||||
def saved_view_response(handler: Callable) -> Callable:
|
||||
"""``{success:false,error}`` with 400, 404 or 500 on every failure."""
|
||||
|
||||
@wraps(handler)
|
||||
async def wrapped(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
return await handler(self, request)
|
||||
except SavedViewNotFound:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Saved view not found"}, status=404
|
||||
)
|
||||
except SavedViewInvalid as exc:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=400)
|
||||
except Exception:
|
||||
self.logger.exception("Saved view operation failed")
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Saved view operation failed"}, status=500
|
||||
)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
async def _body(request: web.Request, fields: frozenset) -> dict:
|
||||
"""A JSON object whose keys are exactly ``fields``."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except (ValueError, UnicodeDecodeError): # JSONDecodeError is a ValueError
|
||||
raise SavedViewInvalid("Expected a JSON object") from None
|
||||
if not isinstance(body, dict):
|
||||
raise SavedViewInvalid("Expected a JSON object")
|
||||
if set(body) != fields:
|
||||
raise SavedViewInvalid(
|
||||
"Request must contain exactly " + ", ".join(sorted(fields))
|
||||
)
|
||||
return body
|
||||
|
||||
|
||||
def _view_id(request: web.Request) -> int:
|
||||
raw = request.match_info["view_id"]
|
||||
if not raw.isascii() or not raw.isdigit():
|
||||
raise SavedViewInvalid("Invalid saved view ID")
|
||||
value = int(raw)
|
||||
if not 1 <= value <= _MAX_ID:
|
||||
raise SavedViewInvalid("Invalid saved view ID")
|
||||
return value
|
||||
|
||||
|
||||
class SavedViewRoutesMixin:
|
||||
def _register_saved_view_routes(self, routes: web.RouteTableDef) -> None:
|
||||
routes.get("/prompt_manager/saved-views")(self.list_saved_views)
|
||||
routes.post("/prompt_manager/saved-views")(self.create_saved_view)
|
||||
routes.patch("/prompt_manager/saved-views/{view_id}")(self.rename_saved_view)
|
||||
routes.delete("/prompt_manager/saved-views/{view_id}")(self.delete_saved_view)
|
||||
|
||||
@saved_view_response
|
||||
async def list_saved_views(self, request: web.Request) -> web.Response:
|
||||
views = await self._run_in_executor(self.db.list_saved_views)
|
||||
return web.json_response({"success": True, "views": views})
|
||||
|
||||
@saved_view_response
|
||||
async def create_saved_view(self, request: web.Request) -> web.Response:
|
||||
body = await _body(request, _CREATE_FIELDS)
|
||||
encode_state(body["page"], body["state"])
|
||||
validate_name(body["name"])
|
||||
view = await self._run_in_executor(
|
||||
self.db.create_saved_view, body["page"], body["name"], body["state"]
|
||||
)
|
||||
return web.json_response({"success": True, "view": view}, status=201)
|
||||
|
||||
@saved_view_response
|
||||
async def rename_saved_view(self, request: web.Request) -> web.Response:
|
||||
view_id = _view_id(request)
|
||||
body = await _body(request, _RENAME_FIELDS)
|
||||
validate_name(body["name"])
|
||||
view = await self._run_in_executor(
|
||||
self.db.rename_saved_view, view_id, body["name"]
|
||||
)
|
||||
if view is None:
|
||||
raise SavedViewNotFound
|
||||
return web.json_response({"success": True, "view": view})
|
||||
|
||||
@saved_view_response
|
||||
async def delete_saved_view(self, request: web.Request) -> web.Response:
|
||||
view_id = _view_id(request)
|
||||
if not await self._run_in_executor(self.db.delete_saved_view, view_id):
|
||||
raise SavedViewNotFound
|
||||
return web.json_response({"success": True, "id": view_id})
|
||||
@@ -1,62 +0,0 @@
|
||||
"""Progress fan-out for the background output scan.
|
||||
|
||||
The scan runs as an asyncio task that outlives the HTTP request that started
|
||||
it, so an accidental tab close does not abandon a half-finished scan. Every
|
||||
SSE subscriber (the request that started the scan, a reconnecting page, a
|
||||
second tab) receives the latest event as a snapshot and then every event
|
||||
published after it, until the job finishes.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
|
||||
class ScanJob:
|
||||
"""One scan run: its latest event, completion flag and live subscribers."""
|
||||
|
||||
def __init__(self):
|
||||
self.last_event = None
|
||||
self.finished = False
|
||||
self.cancelled = False
|
||||
self.task = None
|
||||
self._subscribers = set()
|
||||
|
||||
@property
|
||||
def running(self):
|
||||
return not self.finished
|
||||
|
||||
@property
|
||||
def subscriber_count(self):
|
||||
return len(self._subscribers)
|
||||
|
||||
def publish(self, payload):
|
||||
"""Record ``payload`` as the latest event and hand it to every subscriber."""
|
||||
self.last_event = payload
|
||||
for queue in list(self._subscribers):
|
||||
queue.put_nowait(payload)
|
||||
|
||||
def finish(self):
|
||||
"""Mark the job done and release every subscriber after its queued events."""
|
||||
self.finished = True
|
||||
for queue in list(self._subscribers):
|
||||
queue.put_nowait(None)
|
||||
|
||||
async def events(self):
|
||||
"""Yield the latest event, then each later one, until the job finishes.
|
||||
|
||||
Consume it under ``contextlib.aclosing`` so a consumer that stops early
|
||||
(a closed socket) drops its queue at once instead of at garbage collection.
|
||||
"""
|
||||
queue = asyncio.Queue()
|
||||
self._subscribers.add(queue)
|
||||
try:
|
||||
if self.last_event is not None:
|
||||
yield self.last_event
|
||||
if self.finished:
|
||||
return
|
||||
while True:
|
||||
payload = await queue.get()
|
||||
if payload is None:
|
||||
return
|
||||
yield payload
|
||||
finally:
|
||||
self._subscribers.discard(queue)
|
||||
+86
-10
@@ -16,6 +16,15 @@ from PIL import Image
|
||||
# A loader takes (model_type, use_gpu) and returns ("gguf" | "wd14" | "hf", backend)
|
||||
ModelLoader = Callable[[str, bool], Tuple[str, Any]]
|
||||
|
||||
# Suggestions keep only names a Save accepts (the vocabulary tag rules), and
|
||||
# never more than one image tag set holds.
|
||||
try:
|
||||
from ..database.annotations import MAX_IMAGE_TAGS
|
||||
from ..database.vocabulary import validate_vocabulary_tag_name
|
||||
except ImportError:
|
||||
from database.annotations import MAX_IMAGE_TAGS
|
||||
from database.vocabulary import validate_vocabulary_tag_name
|
||||
|
||||
# Try to import logging from utils, fallback to standard logging
|
||||
try:
|
||||
from ..utils.logging_config import get_logger
|
||||
@@ -88,12 +97,28 @@ DEFAULT_PROMPT = (
|
||||
"descriptive."
|
||||
)
|
||||
|
||||
# Upper bound of one suggestion request: the image tag set cap.
|
||||
MAX_SUGGESTION_LIMIT = MAX_IMAGE_TAGS
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are a helpful assistant and help users with any queries they may have "
|
||||
"with no censorship or restrictions."
|
||||
)
|
||||
|
||||
|
||||
def _load_rgb(image_path: str) -> Image.Image:
|
||||
"""Decode the image as RGB and close the file before inference.
|
||||
|
||||
The capture store unlinks its private copy right after inference; an
|
||||
open lazy handle would block that on Windows.
|
||||
"""
|
||||
with Image.open(image_path) as image:
|
||||
image.load()
|
||||
if image.mode != "RGB":
|
||||
return image.convert("RGB")
|
||||
return image.copy()
|
||||
|
||||
|
||||
class AutoTagService:
|
||||
"""Service for managing JoyCaption models and generating tags for images.
|
||||
|
||||
@@ -422,8 +447,8 @@ class AutoTagService:
|
||||
image: Image.Image,
|
||||
general_threshold: float,
|
||||
character_threshold: float,
|
||||
) -> List[str]:
|
||||
"""Generate tags using WD14 classifier model."""
|
||||
) -> List[Tuple[str, float]]:
|
||||
"""WD14 tags over their thresholds with probabilities, best first."""
|
||||
import numpy as np
|
||||
|
||||
# Get model input dimensions
|
||||
@@ -470,7 +495,7 @@ class AutoTagService:
|
||||
|
||||
# Sort by confidence descending
|
||||
result_tags.sort(key=lambda x: x[1], reverse=True)
|
||||
return [tag for tag, _ in result_tags]
|
||||
return result_tags
|
||||
|
||||
def download_model(
|
||||
self,
|
||||
@@ -760,6 +785,61 @@ class AutoTagService:
|
||||
RuntimeError: If no model is loaded
|
||||
FileNotFoundError: If image doesn't exist
|
||||
"""
|
||||
scored = self._scored_tags(
|
||||
image_path, prompt, general_threshold, character_threshold
|
||||
)
|
||||
return [name for name, _ in scored]
|
||||
|
||||
def generate_tag_suggestions(
|
||||
self,
|
||||
image_path: str,
|
||||
*,
|
||||
limit: int,
|
||||
prompt: Optional[str] = None,
|
||||
general_threshold: Optional[float] = None,
|
||||
character_threshold: Optional[float] = None,
|
||||
) -> List[Tuple[str, Optional[float]]]:
|
||||
"""The top ``limit`` suggestions as ``(name, confidence)`` pairs.
|
||||
|
||||
WD14 pairs carry the real probability, best first; language models
|
||||
(GGUF, HF) have no score, so their tags keep the output order with
|
||||
``None``. Names are distinct case-insensitively (first one wins).
|
||||
A name Save would reject (over 50 characters, a control character
|
||||
such as a newline, a comma) is skipped before the dedupe and the
|
||||
``limit`` count. ``prompt`` is used for this call only;
|
||||
``custom_prompt`` is never changed.
|
||||
"""
|
||||
if type(limit) is not int or not 1 <= limit <= MAX_SUGGESTION_LIMIT:
|
||||
raise ValueError(
|
||||
f"limit must be an integer from 1 to {MAX_SUGGESTION_LIMIT}"
|
||||
)
|
||||
scored = self._scored_tags(
|
||||
image_path, prompt, general_threshold, character_threshold
|
||||
)
|
||||
suggestions: List[Tuple[str, Optional[float]]] = []
|
||||
seen = set()
|
||||
for raw, score in scored:
|
||||
try:
|
||||
name = validate_vocabulary_tag_name(raw)
|
||||
except ValueError:
|
||||
continue
|
||||
key = name.casefold()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
suggestions.append((name, score))
|
||||
if len(suggestions) == limit:
|
||||
break
|
||||
return suggestions
|
||||
|
||||
def _scored_tags(
|
||||
self,
|
||||
image_path: str,
|
||||
prompt: Optional[str],
|
||||
general_threshold: Optional[float],
|
||||
character_threshold: Optional[float],
|
||||
) -> List[Tuple[str, Optional[float]]]:
|
||||
"""Run the loaded model: WD14 ``(name, probability)``, LLM ``(name, None)``."""
|
||||
if self._tagger is None:
|
||||
raise RuntimeError("No model loaded. Call load_model() first.")
|
||||
|
||||
@@ -767,18 +847,14 @@ class AutoTagService:
|
||||
raise FileNotFoundError(errno.ENOENT, "Image not found", str(image_path))
|
||||
|
||||
use_prompt = prompt or self._custom_prompt
|
||||
|
||||
# Load image
|
||||
image = Image.open(image_path)
|
||||
if image.mode != "RGB":
|
||||
image = image.convert("RGB")
|
||||
image = _load_rgb(image_path)
|
||||
|
||||
# Generate based on model type
|
||||
model_type, tagger_obj = self._tagger
|
||||
|
||||
if model_type == "gguf":
|
||||
raw_tags = self._generate_gguf(tagger_obj, image, use_prompt)
|
||||
return self._parse_tags(raw_tags)
|
||||
return [(tag, None) for tag in self._parse_tags(raw_tags)]
|
||||
elif model_type == "wd14":
|
||||
session, tags_list = tagger_obj
|
||||
gen_thresh = (
|
||||
@@ -799,7 +875,7 @@ class AutoTagService:
|
||||
raw_tags = self._generate_hf(
|
||||
model, processor, device, compute_dtype, image, use_prompt
|
||||
)
|
||||
return self._parse_tags(raw_tags)
|
||||
return [(tag, None) for tag in self._parse_tags(raw_tags)]
|
||||
|
||||
def _generate_gguf(self, model, image: Image.Image, prompt: str) -> str:
|
||||
"""Generate tags using GGUF model."""
|
||||
|
||||
@@ -465,6 +465,59 @@ class IntegrationConfig:
|
||||
cls.CIVITAI_API_KEY = lora["civitai_api_key"]
|
||||
|
||||
|
||||
# Number of tag suggestions the Tag dialogs request by default (D68). The cap
|
||||
# is the image tag set limit: more suggestions could never all be kept.
|
||||
DEFAULT_SUGGESTION_LIMIT = 10
|
||||
MAX_SUGGESTION_LIMIT = 100 # pinned to MAX_IMAGE_TAGS by tests/test_config.py
|
||||
|
||||
|
||||
class AutotagConfig:
|
||||
"""Persisted tagging defaults under ``autotag.defaults`` in config.json.
|
||||
|
||||
``suggestion_limit`` is how many suggestions Generate returns (the top N
|
||||
by score), an integer in ``1..MAX_SUGGESTION_LIMIT``. A hand-edited value
|
||||
outside that range, or of another type, falls back to the default with a
|
||||
warning rather than failing the load.
|
||||
"""
|
||||
|
||||
SUGGESTION_LIMIT = DEFAULT_SUGGESTION_LIMIT
|
||||
|
||||
@classmethod
|
||||
def suggestion_limit(cls) -> int:
|
||||
return cls.SUGGESTION_LIMIT
|
||||
|
||||
@staticmethod
|
||||
def valid_suggestion_limit(value: Any) -> bool:
|
||||
return (
|
||||
type(value) is int and 1 <= value <= MAX_SUGGESTION_LIMIT
|
||||
) # bool, float and str are rejected
|
||||
|
||||
@classmethod
|
||||
def get_config(cls) -> Dict[str, Any]:
|
||||
return {"defaults": {"suggestion_limit": cls.SUGGESTION_LIMIT}}
|
||||
|
||||
@classmethod
|
||||
def update_config(cls, new_config: Any) -> None:
|
||||
if not isinstance(new_config, dict):
|
||||
config_logger.warning("Ignoring autotag settings that are not an object")
|
||||
return
|
||||
defaults = new_config.get("defaults", {})
|
||||
if not isinstance(defaults, dict):
|
||||
config_logger.warning("Ignoring autotag.defaults that is not an object")
|
||||
return
|
||||
if "suggestion_limit" not in defaults:
|
||||
return
|
||||
value = defaults["suggestion_limit"]
|
||||
if cls.valid_suggestion_limit(value):
|
||||
cls.SUGGESTION_LIMIT = value
|
||||
return
|
||||
config_logger.warning(
|
||||
"autotag.defaults.suggestion_limit must be an integer from 1 to "
|
||||
f"{MAX_SUGGESTION_LIMIT}; using {DEFAULT_SUGGESTION_LIMIT}"
|
||||
)
|
||||
cls.SUGGESTION_LIMIT = DEFAULT_SUGGESTION_LIMIT
|
||||
|
||||
|
||||
class PromptManagerConfig:
|
||||
"""Main configuration class for PromptManager core functionality.
|
||||
|
||||
@@ -566,6 +619,7 @@ class PromptManagerConfig:
|
||||
},
|
||||
"gallery": GalleryConfig.get_config(),
|
||||
"integrations": IntegrationConfig.get_config(),
|
||||
"autotag": AutotagConfig.get_config(),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -706,6 +760,9 @@ class PromptManagerConfig:
|
||||
if "integrations" in new_config:
|
||||
IntegrationConfig.update_config(new_config["integrations"])
|
||||
|
||||
if "autotag" in new_config:
|
||||
AutotagConfig.update_config(new_config["autotag"])
|
||||
|
||||
|
||||
# Load configuration on import
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
"""One process-wide, non-blocking gate around the tagging models (#249 B2).
|
||||
|
||||
Loading, swapping, running, downloading and unloading a tagging model all
|
||||
happen under this gate, whether the caller is a legacy Auto-tag route, the
|
||||
Tag dialog's Generate or (later) a bulk job. A caller that cannot take the
|
||||
gate is refused at once (``409 model-busy``), never queued.
|
||||
|
||||
Work that runs in a worker thread is admitted with :meth:`ModelGate.run`. A
|
||||
release requested while such work still runs (a client disconnect cancels
|
||||
the waiting coroutine, not the thread) takes effect only when the last
|
||||
admitted call really exits, so nobody can swap the model under a running
|
||||
inference.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
import uuid
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
|
||||
class GateNotHeld(RuntimeError):
|
||||
"""Raised when work is admitted for an owner that does not hold the gate."""
|
||||
|
||||
|
||||
class ModelGate:
|
||||
"""A single-owner gate whose release waits for admitted worker calls."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._holder: Optional[str] = None
|
||||
self._inflight = 0
|
||||
self._releasing = False
|
||||
|
||||
def try_acquire(self, owner: str) -> bool:
|
||||
"""Take the gate for *owner*; False at once when anyone holds it."""
|
||||
with self._lock:
|
||||
if self._holder is not None:
|
||||
return False
|
||||
self._holder = owner
|
||||
self._inflight = 0
|
||||
self._releasing = False
|
||||
return True
|
||||
|
||||
def release(self, owner: str) -> None:
|
||||
"""Let go; deferred until admitted work of *owner* has exited."""
|
||||
with self._lock:
|
||||
if self._holder != owner:
|
||||
return
|
||||
if self._inflight:
|
||||
self._releasing = True
|
||||
else:
|
||||
self._clear_locked()
|
||||
|
||||
def holder(self) -> Optional[str]:
|
||||
with self._lock:
|
||||
return self._holder
|
||||
|
||||
def begin(self, owner: str) -> None:
|
||||
"""Admit one worker call; the holder must still hold the gate."""
|
||||
with self._lock:
|
||||
if self._holder != owner or self._releasing:
|
||||
raise GateNotHeld(f"{owner} does not hold the model gate")
|
||||
self._inflight += 1
|
||||
|
||||
def end(self, owner: str) -> None:
|
||||
"""One admitted call exited; completes a deferred release."""
|
||||
with self._lock:
|
||||
self._end_locked(owner)
|
||||
|
||||
async def run(
|
||||
self,
|
||||
owner: str,
|
||||
func: Callable[..., Any],
|
||||
*args,
|
||||
on_skipped: Optional[Callable[[], None]] = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Run ``func`` in the default executor while *owner* holds the gate.
|
||||
|
||||
The executor future is shielded: cancelling the caller never cancels
|
||||
a call that has not started yet (which would leave it admitted
|
||||
forever) and never ends the admission of a call that is running.
|
||||
``on_skipped`` runs once when ``func`` never starts (the gate is not
|
||||
held, the executor refuses the call, or a loop shutdown drops it), so
|
||||
a caller can free what ``func`` would have freed.
|
||||
"""
|
||||
try:
|
||||
self.begin(owner)
|
||||
except GateNotHeld:
|
||||
if on_skipped is not None:
|
||||
on_skipped()
|
||||
raise
|
||||
state = {"started": False, "finished": False}
|
||||
|
||||
def finish() -> None:
|
||||
with self._lock:
|
||||
if not state["finished"]:
|
||||
state["finished"] = True
|
||||
self._end_locked(owner)
|
||||
|
||||
def work():
|
||||
with self._lock:
|
||||
if state["finished"]:
|
||||
# Already declared skipped (the future was cancelled as
|
||||
# the thread picked the call up): never run it twice over.
|
||||
return None
|
||||
state["started"] = True
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
finally:
|
||||
finish()
|
||||
|
||||
def never_ran(future) -> None:
|
||||
# Only a loop shutdown cancels the shielded future; a call that
|
||||
# never started must still give its admission back.
|
||||
if future.cancelled():
|
||||
with self._lock:
|
||||
skipped = not state["started"] and not state["finished"]
|
||||
if skipped:
|
||||
state["finished"] = True
|
||||
self._end_locked(owner)
|
||||
if skipped and on_skipped is not None:
|
||||
on_skipped()
|
||||
|
||||
try:
|
||||
future = asyncio.get_running_loop().run_in_executor(None, work)
|
||||
except BaseException:
|
||||
finish()
|
||||
if on_skipped is not None:
|
||||
on_skipped()
|
||||
raise
|
||||
future.add_done_callback(never_ran)
|
||||
return await asyncio.shield(future)
|
||||
|
||||
def _end_locked(self, owner: str) -> None:
|
||||
if self._holder != owner or self._inflight == 0:
|
||||
return
|
||||
self._inflight -= 1
|
||||
if self._releasing and self._inflight == 0:
|
||||
self._clear_locked()
|
||||
|
||||
def _clear_locked(self) -> None:
|
||||
self._holder = None
|
||||
self._inflight = 0
|
||||
self._releasing = False
|
||||
|
||||
|
||||
_GATE = ModelGate()
|
||||
|
||||
|
||||
def new_owner(kind: str) -> str:
|
||||
"""A unique owner name such as ``legacy-single:<uuid>``."""
|
||||
return f"{kind}:{uuid.uuid4()}"
|
||||
|
||||
|
||||
def try_acquire(owner: str) -> bool:
|
||||
return _GATE.try_acquire(owner)
|
||||
|
||||
|
||||
def release(owner: str) -> None:
|
||||
_GATE.release(owner)
|
||||
|
||||
|
||||
def holder() -> Optional[str]:
|
||||
return _GATE.holder()
|
||||
|
||||
|
||||
async def run(
|
||||
owner: str,
|
||||
func: Callable[..., Any],
|
||||
*args,
|
||||
on_skipped: Optional[Callable[[], None]] = None,
|
||||
**kwargs,
|
||||
):
|
||||
return await _GATE.run(owner, func, *args, on_skipped=on_skipped, **kwargs)
|
||||
@@ -0,0 +1,296 @@
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
import { THEMES, seedConcept, openConcept, openProduction, diffInventory, mutate } from './concept-inventory.mjs';
|
||||
|
||||
// Activity-1 Task 3. The review dialog is compared with the concept's own `scan-review` ("Review metadata scan",
|
||||
// app.js 1428–1432) and its backup box with the concept's System `.notice`; the differences are D55. The job view
|
||||
// has no concept counterpart: it is compared with the approved mockup `activity-scan.html` (a local, ignored file),
|
||||
// whose structure and CSS values are restated below (D56). Each comparison is shown to notice a mutation.
|
||||
const workspace = '/prompt_manager/workspace';
|
||||
|
||||
test.beforeEach(async ({ page, request, browserName }) => {
|
||||
await startCoverage(page, browserName);
|
||||
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
|
||||
page.conceptErrors = [];
|
||||
page.on('pageerror', error => page.conceptErrors.push(error.message));
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page, request, browserName }) => {
|
||||
await saveCoverage(page, browserName);
|
||||
expect(page.conceptErrors).toEqual([]);
|
||||
await request.post('/__test__/jobs/restart');
|
||||
});
|
||||
|
||||
/** The open review dialog: its body's element sequence, the meta grid, the sentence, the notice and the actions. */
|
||||
function measureReview() {
|
||||
const dialog = document.querySelector('dialog#modal[open]');
|
||||
const css = (node, ...keys) => node ? Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]])) : null;
|
||||
const name = node => [node.localName, ...[...node.classList].sort()].join('.');
|
||||
const grid = dialog.querySelector('#modal-body > .meta-grid');
|
||||
const sentence = dialog.querySelector('#modal-body > p.small');
|
||||
const actions = dialog.querySelector('#modal-body > .dialog-actions');
|
||||
return {
|
||||
title: dialog.querySelector('#modal-title').textContent,
|
||||
body: [...dialog.querySelector('#modal-body').children].map(name),
|
||||
grid: {
|
||||
tag: grid.localName,
|
||||
...css(grid, 'display', 'gridTemplateColumns', 'columnGap', 'rowGap', 'marginTop', 'marginBottom', 'fontSize'),
|
||||
entries: [...grid.children].map(entry => ({
|
||||
tag: entry.localName, dt: entry.querySelector('dt').textContent, dd: entry.querySelector('dd').textContent,
|
||||
dtStyle: css(entry.querySelector('dt'), 'color', 'fontSize'),
|
||||
ddStyle: css(entry.querySelector('dd'), 'marginTop', 'marginLeft', 'fontSize', 'color'),
|
||||
})),
|
||||
},
|
||||
sentence: { text: sentence.textContent, ...css(sentence, 'fontSize', 'color', 'lineHeight', 'marginTop') },
|
||||
actions: {
|
||||
...css(actions, 'display', 'justifyContent', 'columnGap', 'marginTop'),
|
||||
buttons: [...actions.children].map(button => ({ text: button.textContent, className: button.className,
|
||||
...css(button, 'backgroundColor', 'color', 'fontSize', 'paddingTop', 'paddingLeft', 'borderTopLeftRadius') })),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A `.notice` box: its frame and its text and actions columns, without the copy. */
|
||||
function measureNotice(selector) {
|
||||
const notice = document.querySelector(selector);
|
||||
const css = (node, ...keys) => node ? Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]])) : null;
|
||||
return {
|
||||
frame: css(notice, 'display', 'alignItems', 'justifyContent', 'columnGap', 'borderTopWidth', 'borderTopStyle',
|
||||
'borderTopColor', 'backgroundColor', 'borderTopLeftRadius', 'paddingTop', 'paddingLeft'),
|
||||
columns: [...notice.children].map(node => node.localName + (node.className ? `.${node.className}` : '')),
|
||||
text: css(notice.querySelector(':scope > div > p'), 'fontSize', 'color', 'marginTop'),
|
||||
actions: css(notice.querySelector(':scope > .row-actions'), 'display', 'flexShrink'),
|
||||
};
|
||||
}
|
||||
|
||||
/** A concept `.setting-row` switch: its frame, its label and hint, and its checkbox (not its place-dependent margin). */
|
||||
function measureToggle(selector) {
|
||||
const row = document.querySelector(selector);
|
||||
const css = (node, ...keys) => node ? Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]])) : null;
|
||||
return {
|
||||
children: [...row.children].map(node => [node.localName, ...node.classList].join('.')),
|
||||
frame: css(row, 'display', 'alignItems', 'justifyContent', 'columnGap', 'paddingTop', 'paddingBottom',
|
||||
'borderTopWidth', 'borderTopStyle', 'borderTopColor'),
|
||||
label: css(row.querySelector('label'), 'fontSize', 'fontWeight', 'color'),
|
||||
hint: css(row.querySelector('p'), 'fontSize', 'color', 'marginTop'),
|
||||
box: css(row.querySelector('input[type="checkbox"]'), 'accentColor', 'width', 'height', 'appearance',
|
||||
'backgroundColor', 'borderTopLeftRadius'),
|
||||
role: row.querySelector('input').getAttribute('role'),
|
||||
labelled: row.querySelector('label').htmlFor === row.querySelector('input').id,
|
||||
};
|
||||
}
|
||||
|
||||
/** D55 and D58: the concept's dialog with the production differences applied. */
|
||||
function mapReviewConcept(reference) {
|
||||
return {
|
||||
...reference,
|
||||
// No sample-plan note (prototype copy); the D58 "All files" toggle follows the grid; the backup box sits
|
||||
// before the actions.
|
||||
body: [...reference.body.filter(item => item !== 'p.demo-note').slice(0, -1), 'div.notice', reference.body.at(-1)]
|
||||
.flatMap(item => item === 'div.meta-grid' ? ['dl.meta-grid', 'div.setting-row'] : [item]),
|
||||
// A `dl`, as in the concept's maintenance plan; real sources. The scope keeps the concept's
|
||||
// "New and unresolved files" until "All files" is checked.
|
||||
grid: { ...reference.grid, tag: 'dl', entries: reference.grid.entries.map(entry => ({ ...entry,
|
||||
dd: { Sources: '2 output folders', Scope: 'New and unresolved files' }[entry.dt] })) },
|
||||
actions: { ...reference.actions, buttons: reference.actions.buttons.map(button => button.text === 'Simulate scan'
|
||||
? { ...button, text: 'Start scan' } : button) },
|
||||
};
|
||||
}
|
||||
|
||||
async function conceptReview(page, theme) {
|
||||
await openConcept(page, 'images', theme);
|
||||
await page.locator('#main .heading-actions').getByRole('button', { name: 'Import', exact: true }).click();
|
||||
await page.locator('dialog#modal[open] .operation').filter({ hasText: 'Scan an output folder' })
|
||||
.getByRole('button', { name: 'Review scan', exact: true }).click();
|
||||
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Review metadata scan');
|
||||
await page.mouse.move(0, 0);
|
||||
const review = await page.evaluate(measureReview);
|
||||
await page.keyboard.press('Escape');
|
||||
// The concept's System → Sources notice ("Bring older images into your prompt history").
|
||||
await page.goto(`/__test__/canonical/workspace#system`);
|
||||
await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption(theme);
|
||||
await page.locator('#main').getByRole('button', { name: 'Manage sources', exact: true }).click();
|
||||
await expect(page.locator('#main .notice')).toBeVisible();
|
||||
return { review, notice: await page.evaluate(measureNotice, '#main .notice'),
|
||||
toggle: await page.evaluate(measureToggle, '#main .setting-row:has(input[type="checkbox"])') };
|
||||
}
|
||||
|
||||
async function productionReview(page, theme) {
|
||||
await openProduction(page, 'images', theme);
|
||||
await page.locator('#workspace-main .heading-actions').getByRole('button', { name: 'Import', exact: true }).click();
|
||||
await page.locator('dialog#modal[open] .operation').filter({ hasText: 'Scan for metadata' })
|
||||
.getByRole('button', { name: 'Review scan', exact: true }).click();
|
||||
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Review metadata scan');
|
||||
// The swapped dialog can open under the pointer that pressed Review scan; measure it at rest.
|
||||
await page.mouse.move(0, 0);
|
||||
const review = await page.evaluate(measureReview);
|
||||
const notice = await page.evaluate(measureNotice, 'dialog#modal[open] .notice');
|
||||
// The concept's switch is on; compare ours on too, then turn it off again (the default).
|
||||
const allFiles = page.getByRole('switch', { name: 'All files', exact: true });
|
||||
await allFiles.check();
|
||||
await page.mouse.move(0, 0);
|
||||
const toggle = await page.evaluate(measureToggle, 'dialog#modal[open] .setting-row');
|
||||
await allFiles.uncheck();
|
||||
await page.mouse.move(0, 0);
|
||||
return { review, notice, toggle };
|
||||
}
|
||||
|
||||
for (const theme of THEMES) test(`review dialog matches the concept scan-review ${theme}`, async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
await seedConcept(request);
|
||||
const concept = await conceptReview(page, theme);
|
||||
expect(concept.review.body).toEqual(['p.demo-note', 'div.meta-grid', 'p.small', 'div.dialog-actions']);
|
||||
const production = await productionReview(page, theme);
|
||||
expect(diffInventory(mapReviewConcept(concept.review), production.review)).toEqual([]);
|
||||
expect(diffInventory(concept.notice, production.notice)).toEqual([]);
|
||||
expect(diffInventory(concept.toggle, production.toggle)).toEqual([]);
|
||||
await expect(page.locator('dialog#modal[open] .setting-row > div > label')).toHaveText('All files');
|
||||
await expect(page.locator('dialog#modal[open] .setting-row input')).not.toBeChecked();
|
||||
await expect(page.getByRole('switch', { name: 'All files', exact: true })).toBeVisible();
|
||||
await expect(page.locator('dialog#modal[open] .notice h3')).toHaveText('Back up the database first (recommended)');
|
||||
await expect(page.locator('dialog#modal[open] .notice .row-actions button')).toHaveText(['Back up now']);
|
||||
|
||||
// Mutation check: one CSS change and one removed element are both noticed, then undone to an exact match again.
|
||||
const undoCss = await mutate(page, { css: '#modal .meta-grid { gap: 3px; } #modal .notice { padding: 2px; } #modal .setting-row { gap: 3px; }' });
|
||||
const cssMutated = await page.evaluate(measureReview);
|
||||
const noticeMutated = await page.evaluate(measureNotice, 'dialog#modal[open] .notice');
|
||||
await page.getByRole('switch', { name: 'All files', exact: true }).check();
|
||||
await page.mouse.move(0, 0);
|
||||
const toggleMutated = await page.evaluate(measureToggle, 'dialog#modal[open] .setting-row');
|
||||
await page.getByRole('switch', { name: 'All files', exact: true }).uncheck();
|
||||
await page.mouse.move(0, 0);
|
||||
expect(diffInventory(concept.toggle, toggleMutated).map(item => item.path)).toEqual(['frame.columnGap']);
|
||||
expect(diffInventory(mapReviewConcept(concept.review), cssMutated).map(item => item.path)).toEqual(expect.arrayContaining(['grid.columnGap', 'grid.rowGap']));
|
||||
expect(diffInventory(concept.notice, noticeMutated).map(item => item.path)).toEqual(['frame.paddingTop', 'frame.paddingLeft']);
|
||||
await undoCss();
|
||||
const undoDom = await mutate(page, { remove: ['#modal-body > p.small'] });
|
||||
const domMutated = await page.evaluate(() => [...document.querySelectorAll('#modal-body > *')].map(node => node.localName));
|
||||
expect(domMutated).toEqual(['dl', 'div', 'div', 'div']);
|
||||
await undoDom();
|
||||
expect(diffInventory(mapReviewConcept(concept.review), await page.evaluate(measureReview))).toEqual([]);
|
||||
});
|
||||
|
||||
// The approved mockup's job view, restated: structure first, then the values of its CSS rules.
|
||||
const MOCKUP_TREE = ['div.heading', 'section.surface', 'section.surface', 'details.term'];
|
||||
const MOCKUP_PROGRESS = ['div.panel-heading(h2,span.small)', 'progress', 'dl.stat-row(div.added.stat,div.stat,div.stat.warn,div.stat,div.bad.stat)'];
|
||||
const MOCKUP_RESULTS = ['div.panel-heading(h2,label.search-field)', 'div.outcomes(div.outcome-list,div.outcome-files)'];
|
||||
const MOCKUP_STYLES = {
|
||||
statRow: { display: 'grid', columnGap: '10px', columns: 5, marginTop: '16px', marginBottom: '16px' },
|
||||
stat: { borderTopWidth: '1px', borderTopLeftRadius: '7px', paddingTop: '12px', paddingLeft: '14px' },
|
||||
dt: { fontSize: '11px' },
|
||||
dd: { fontSize: '20px', fontWeight: '600', marginTop: '6px', marginLeft: '0px' },
|
||||
outcomes: { display: 'grid', columnGap: '16px', first: '220px', marginTop: '6px' },
|
||||
outcomeButton: { display: 'flex', justifyContent: 'space-between', marginBottom: '6px', textAlign: 'left' },
|
||||
fileRow: { display: 'grid', columnGap: '12px', paddingTop: '9px', paddingLeft: '4px', borderBottomWidth: '1px', fontSize: '12px' },
|
||||
path: { monospace: true, overflowWrap: 'anywhere' },
|
||||
search: { maxWidth: '320px' },
|
||||
term: { marginTop: '16px', borderTopWidth: '1px', borderTopLeftRadius: '7px', overflow: 'hidden' },
|
||||
summary: { paddingTop: '11px', paddingLeft: '14px', fontSize: '12px', cursor: 'pointer' },
|
||||
pre: { marginTop: '0px', paddingTop: '12px', paddingLeft: '14px', maxHeight: '180px', fontSize: '11px', lineHeight: '18.7px', overflow: 'auto' },
|
||||
};
|
||||
|
||||
function measureJobView() {
|
||||
const main = document.querySelector('#workspace-main');
|
||||
const css = (node, ...keys) => Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]]));
|
||||
const name = node => [node.localName, ...[...node.classList].sort()].join('.');
|
||||
// The rows and sections the mockup nests are listed with their children.
|
||||
const shape = node => node.matches('.panel-heading, .outcomes, dl.stat-row')
|
||||
? `${name(node)}(${[...node.children].map(name).join(',')})` : name(node);
|
||||
const [progress, results] = main.querySelectorAll(':scope > section.surface');
|
||||
const statRow = progress.querySelector('dl.stat-row');
|
||||
const stat = statRow.querySelector('.stat');
|
||||
const outcomes = results.querySelector('.outcomes');
|
||||
const button = outcomes.querySelector('.outcome-list > button');
|
||||
const row = outcomes.querySelector('.file-row');
|
||||
const term = main.querySelector(':scope > details.term');
|
||||
const pre = term.querySelector('pre');
|
||||
const columns = getComputedStyle(statRow).gridTemplateColumns.split(' ');
|
||||
return {
|
||||
tree: [...main.children].map(name),
|
||||
progress: [...progress.children].map(shape),
|
||||
results: [...results.children].map(shape),
|
||||
h2: [...main.querySelectorAll(':scope > section.surface > .panel-heading > h2')].map(node => node.textContent),
|
||||
summary: term.querySelector('summary').textContent,
|
||||
styles: {
|
||||
statRow: { ...css(statRow, 'display', 'columnGap', 'marginTop', 'marginBottom'), columns: columns.length },
|
||||
stat: css(stat, 'borderTopWidth', 'borderTopLeftRadius', 'paddingTop', 'paddingLeft'),
|
||||
dt: css(stat.querySelector('dt'), 'fontSize'),
|
||||
dd: css(stat.querySelector('dd'), 'fontSize', 'fontWeight', 'marginTop', 'marginLeft'),
|
||||
outcomes: { ...css(outcomes, 'display', 'columnGap', 'marginTop'), first: getComputedStyle(outcomes).gridTemplateColumns.split(' ')[0] },
|
||||
outcomeButton: css(button, 'display', 'justifyContent', 'marginBottom', 'textAlign'),
|
||||
fileRow: css(row, 'display', 'columnGap', 'paddingTop', 'paddingLeft', 'borderBottomWidth', 'fontSize'),
|
||||
path: { monospace: /monospace/.test(getComputedStyle(row.querySelector('.path')).fontFamily),
|
||||
overflowWrap: getComputedStyle(row.querySelector('.path')).overflowWrap },
|
||||
search: css(results.querySelector('.search-field'), 'maxWidth'),
|
||||
term: css(term, 'marginTop', 'borderTopWidth', 'borderTopLeftRadius', 'overflow'),
|
||||
summary: css(term.querySelector('summary'), 'paddingTop', 'paddingLeft', 'fontSize', 'cursor'),
|
||||
// WebKit reports the 1.7 line height of 11px as 18.700001px; compare it to the hundredth of a pixel.
|
||||
pre: { ...css(pre, 'marginTop', 'paddingTop', 'paddingLeft', 'maxHeight', 'fontSize', 'overflow'),
|
||||
lineHeight: `${Math.round(parseFloat(getComputedStyle(pre).lineHeight) * 100) / 100}px` },
|
||||
},
|
||||
// Mockup colours: the pressed outcome takes the accent border on the soft blue, the tiles their tone.
|
||||
pressed: (() => {
|
||||
const probe = document.createElement('span');
|
||||
main.append(probe);
|
||||
const tone = name => { probe.style.color = `var(${name})`; return getComputedStyle(probe).color; };
|
||||
const values = { accent: tone('--ca-accent'), soft: tone('--soft-blue'), green: tone('--green'), red: tone('--red'), warning: tone('--warning') };
|
||||
probe.remove();
|
||||
const on = outcomes.querySelector('.outcome-list > button[aria-pressed="true"]');
|
||||
const dd = selector => getComputedStyle(statRow.querySelector(`${selector} dd`)).color;
|
||||
return { border: getComputedStyle(on).borderTopColor === values.accent, background: getComputedStyle(on).backgroundColor === values.soft,
|
||||
added: dd('.stat.added') === values.green, bad: dd('.stat.bad') === values.red, warn: dd('.stat.warn') === values.warning };
|
||||
})(),
|
||||
};
|
||||
}
|
||||
|
||||
const EXPECTED_JOB_VIEW = {
|
||||
tree: MOCKUP_TREE, progress: MOCKUP_PROGRESS, results: MOCKUP_RESULTS, h2: ['Progress', 'Results'],
|
||||
summary: '● Scan log — raw detail, click to show', styles: MOCKUP_STYLES,
|
||||
pressed: { border: true, background: true, added: true, bad: true, warn: true },
|
||||
};
|
||||
|
||||
async function openRunningJob(page, request, theme, viewport = { width: 1600, height: 1100 }) {
|
||||
await page.setViewportSize(viewport);
|
||||
const started = await request.post('/__test__/jobs/start', { data: { total: 19622 } });
|
||||
await expect(started).toBeOK();
|
||||
const { job } = await started.json();
|
||||
await expect(await request.post(`/__test__/jobs/${job.id}/step`, { data: { processed: 8240,
|
||||
counters: { known: 6511, review: 37, no_metadata: 254, errors: 18 },
|
||||
records: [{ group: 'added', path: 'CCC/mabes9sans-dataset-v1/IMG_GEN_00369_.png', detail: 'Added to the Library as “Mabes portrait”' }] } })).toBeOK();
|
||||
await openProduction(page, `activity/${job.id}`, theme);
|
||||
await expect(page.locator('#workspace-main .file-row')).toHaveCount(1);
|
||||
return job;
|
||||
}
|
||||
|
||||
for (const theme of THEMES) test(`job view follows the approved mockup ${theme}`, async ({ page, request }) => {
|
||||
await seedConcept(request);
|
||||
await openRunningJob(page, request, theme);
|
||||
expect(await page.evaluate(measureJobView)).toEqual(EXPECTED_JOB_VIEW);
|
||||
|
||||
// Mutation check: a CSS change (tile gap) and a removed element (the outcome list) are both noticed.
|
||||
const undoCss = await mutate(page, { css: '#workspace-main .stat-row { gap: 3px; }' });
|
||||
expect(diffInventory(EXPECTED_JOB_VIEW, await page.evaluate(measureJobView)).map(item => item.path)).toEqual(['styles.statRow.columnGap']);
|
||||
await undoCss();
|
||||
const undoDom = await mutate(page, { remove: ['#workspace-main details.term'] });
|
||||
expect(await page.evaluate(() => [...document.querySelector('#workspace-main').children].map(node => node.localName)))
|
||||
.toEqual(['div', 'section', 'section']);
|
||||
await undoDom();
|
||||
expect(await page.evaluate(measureJobView)).toEqual(EXPECTED_JOB_VIEW);
|
||||
});
|
||||
|
||||
test('job view fits a phone without page scroll', async ({ page, request }) => {
|
||||
await seedConcept(request);
|
||||
await openRunningJob(page, request, 'dark', { width: 390, height: 844 });
|
||||
const layout = await page.evaluate(() => {
|
||||
const main = document.querySelector('#workspace-main');
|
||||
const columns = node => getComputedStyle(node).gridTemplateColumns.split(' ').length;
|
||||
return {
|
||||
scroll: document.documentElement.scrollWidth <= document.documentElement.clientWidth,
|
||||
tiles: columns(main.querySelector('dl.stat-row')),
|
||||
outcomes: columns(main.querySelector('.outcomes')),
|
||||
inside: [...main.querySelectorAll('.stat, .outcome-list button, .file-row, .search-field')]
|
||||
.every(node => node.getBoundingClientRect().right <= main.getBoundingClientRect().right + 0.5),
|
||||
};
|
||||
});
|
||||
expect(layout).toEqual({ scroll: true, tiles: 2, outcomes: 1, inside: true });
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
|
||||
test.beforeEach(async ({page, browserName}) => {
|
||||
@@ -57,7 +57,8 @@ test('Classic keeps immediate selection, search query and metadata serialization
|
||||
});
|
||||
|
||||
test('Classic detaches native text synchronization on removal',async({page})=>{
|
||||
await page.waitForTimeout(60);
|
||||
// The page installs the text sync (serializeValue and its listeners) in a 50 ms timer; wait for it, not for time.
|
||||
await expect.poll(()=>page.evaluate(()=>typeof node.widgets.find(item=>item.name==='text').serializeValue)).toBe('function');
|
||||
const values=await page.evaluate(()=>{
|
||||
const widget=node.widgets.find(item=>item.name==='text');
|
||||
widget.inputEl.value='Before removal';widget.inputEl.dispatchEvent(new Event('input'));
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// same concept selectors in the immutable concept (/__test__/canonical/workspace) and in production, which now renders
|
||||
// the concept's classes. The concept is never rewritten: each page's map<Page>Concept(reference) applies only the
|
||||
// recorded spec-§8 deviations, each commented with its D-number, and diffInventory must then report nothing.
|
||||
import {expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {expect} from './fixtures.mjs';
|
||||
export {diffInventory} from './library-editor-canonical-comparison.mjs';
|
||||
|
||||
export const CONCEPT_URL='/__test__/canonical/workspace';
|
||||
@@ -11,6 +11,66 @@ export const SIZES=Object.freeze([[1600,1100],[390,844]]);
|
||||
export const THEMES=Object.freeze(['dark','light']);
|
||||
export const MOTIONS=Object.freeze(['no-preference','reduce']);
|
||||
|
||||
/** D35: production uses the singular for exactly one; the concept's plurals are hard-coded (app.js 447, 489, 517-521). */
|
||||
export const countCopy=text=>text.replace(/(^|\D)1 positive prompts\b/g,'$11 positive prompt')
|
||||
.replace(/(^|\D)1 more images\b/g,'$11 more image').replace(/(^|\D)1 images\b/g,'$11 image');
|
||||
|
||||
/**
|
||||
* The row's flex lines laid out again from the concept's own item boxes, in order: an item breaks to a new line when its
|
||||
* hypothetical size (`flex-basis: 100%` → the row; a growing item → its min-width; otherwise its box) no longer fits
|
||||
* after the line's items and gaps; a growing item (the search field, `flex: 1`) takes its line's free space; items pack
|
||||
* from the start and centre in their line (`align-items: center`). The test proves this model on the concept rows first.
|
||||
*/
|
||||
export function reflow(row,items) {
|
||||
const gap=parseFloat(row.columnGap)||0,rowGap=parseFloat(row.rowGap)||0;
|
||||
// The unrounded boxes (`exact`); rounding happens once, on the result.
|
||||
const rowWidth=row.exact?.width??row.width;
|
||||
const exact=item=>item.exact??{left:item.left,width:item.width};
|
||||
const grows=item=>parseFloat(item.flexGrow)>0;
|
||||
const basis=item=>item.flexBasis==='100%'?rowWidth:grows(item)?parseFloat(item.minWidth)||0:exact(item).width;
|
||||
const lines=[];
|
||||
const order=row.items.filter(entry=>entry.shown);
|
||||
for(const item of items.filter(entry=>entry.shown)) {
|
||||
const line=lines.at(-1),used=line?line.reduce((sum,entry)=>sum+basis(entry)+gap,0):0;
|
||||
// The slack is one layout unit (1/60px in Firefox), which float sums of the browser's boxes may exceed.
|
||||
if(line&&used+basis(item)<=rowWidth+1/60)line.push(item);else lines.push([item]);
|
||||
}
|
||||
const placed=new Map();let top=0;
|
||||
const sameLine=(a,b)=>a.top<b.top+b.height&&b.top<a.top+a.height;
|
||||
const freeOf=line=>rowWidth-line.reduce((sum,item)=>sum+basis(item),0)-gap*(line.length-1);
|
||||
for(const line of lines) {
|
||||
const free=freeOf(line);
|
||||
const growing=line.filter(grows);
|
||||
const height=Math.max(...line.map(item=>item.height));
|
||||
let x=0,previous=null;
|
||||
for(const item of line) {
|
||||
// A growing item keeps its concept width plus the space its line gained.
|
||||
const width=growing.includes(item)?exact(item).width+(free-freeOf(order.filter(entry=>sameLine(entry,item))))/growing.length:exact(item).width;
|
||||
// An item that followed the same neighbour on the same line in the concept keeps the concept's own spacing,
|
||||
// moved by however much that neighbour grew; otherwise it follows the previous box and the gap.
|
||||
const adjacent=previous&&order.indexOf(item)===order.indexOf(previous)+1&&sameLine(item,previous);
|
||||
const before=previous&&placed.get(previous);
|
||||
const left=line.indexOf(item)===0?0:adjacent?before.left+exact(item).left-exact(previous).left+before.width-exact(previous).width:x;
|
||||
placed.set(item,{left,width,top:Math.round(top+(height-item.height)/2)});
|
||||
x=left+width+gap;previous=item;
|
||||
}
|
||||
top+=height+rowGap;
|
||||
}
|
||||
const moved=item=>{
|
||||
const box=placed.get(item);
|
||||
if(!box)return item;
|
||||
const grown=box.width-exact(item).width;
|
||||
return {...item,left:Math.round(box.left),top:box.top,width:Math.round(box.width),
|
||||
input:item.input&&{...item.input,width:Math.round((item.input.exact?.width??item.input.width)+grown)}};
|
||||
};
|
||||
return {...row,height:Math.round(top-rowGap),items:items.map(moved)};
|
||||
}
|
||||
|
||||
/** A flex row without the items `isRemoved` picks; the remaining items flow again from the concept's boxes. */
|
||||
export function withoutItems(row,isRemoved) {
|
||||
return reflow(row,row.items.filter(item=>!isRemoved(item)));
|
||||
}
|
||||
|
||||
/** Seed the concept-shaped real fixture (tests/browser/workspace_concept_fixture.py) and return its manifest. */
|
||||
export async function seedConcept(request,data={}) {
|
||||
const response=await request.post('/__test__/concept/reset',{data});
|
||||
@@ -18,6 +78,23 @@ export async function seedConcept(request,data={}) {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Save `tags` as the current image tag set of the listed file at `path` (root 0) through the real observe and tag
|
||||
* routes (#249 PR C), releasing the capture afterwards; returns the receipt's saved names.
|
||||
*/
|
||||
export async function saveImageTags(request,path,tags) {
|
||||
const listing=await (await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();
|
||||
const target={root_index:0,root_scope:listing.images.find(item=>item.root_index===0).root_scope,path};
|
||||
const observed=await (await request.post('/prompt_manager/image-annotations/observe',{data:{target}})).json();
|
||||
expect(observed.success).toBe(true);
|
||||
try {
|
||||
const receipt=await (await request.put('/prompt_manager/image-annotations/tags',{data:{target,capture_token:observed.capture.token,
|
||||
sha256:observed.capture.sha256,expected_epoch:observed.library_epoch,expected_tag_revision:observed.tag_revision,tags}})).json();
|
||||
expect(receipt.success).toBe(true);
|
||||
return receipt.tags;
|
||||
} finally {await request.delete(`/prompt_manager/image-annotations/capture/${observed.capture.token}`);}
|
||||
}
|
||||
|
||||
async function selectTheme(page,theme) {
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
}
|
||||
@@ -37,6 +114,8 @@ export async function openProduction(page,route,theme,motion='no-preference') {
|
||||
await selectTheme(page,theme);
|
||||
await expect(page.locator('#workspace-main #page-title')).toBeVisible();
|
||||
await expect(page.locator('#primary-nav .nav-count')).toHaveCount(3);
|
||||
// SAVED VIEWS' Favorites count (D51), the concept's starred images in the concept fixture.
|
||||
await expect(page.locator('#saved-nav a[data-route="favorites"] .nav-count')).toHaveCount(1);
|
||||
}
|
||||
|
||||
/** Computed outline of the focused element, after keyboard focus so `:focus-visible` applies. */
|
||||
@@ -88,14 +167,24 @@ export async function mutate(page,{css=null,remove=[]}={}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Inventory sections: {chrome, heading, toolbar, filterRow, caption, grid, cards, panel, library, history,
|
||||
* noPageScroll}. A section is null when its element is absent. Sections whose page PR has not landed yet
|
||||
* (toolbar … history, PRs C–F) are null until that PR adds its measurer here.
|
||||
* Inventory sections: {chrome, heading, toolbar, filterRow, bulkbar, caption, grid, cards, continuation, panel, library,
|
||||
* history, noPageScroll}. A section is null when its element is absent. The Images sections (toolbar … panel, #249 C7)
|
||||
* measure the concept's Images markup; `cards` holds the first six; `bulkbar` is the selection bar's place and, while
|
||||
* anything is checked, its `.bulkbar` row. `library` (#249 E3) holds the Prompt library's toolbar,
|
||||
* caption, grid, first six cards and whatever follows the grid. `history` (#249 F5) holds the Prompt history's summary,
|
||||
* negative variants panel, comparison (while Compare negatives is pressed) and the shared gallery's heading.
|
||||
*/
|
||||
export function inventoryOf(page,scope='main') {
|
||||
return page.evaluate(measureInventory,scope);
|
||||
}
|
||||
|
||||
/** The inventory without its `exact` (unrounded) helper values, which only a re-layout reads; compare after this. */
|
||||
export function stripExact(value) {
|
||||
if(Array.isArray(value))return value.map(stripExact);
|
||||
if(value&&typeof value==='object')return Object.fromEntries(Object.entries(value).filter(([key])=>key!=='exact').map(([key,item])=>[key,stripExact(item)]));
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Runs in the page; self-contained. `scope` selects the page's main element. */
|
||||
export function measureInventory(scope) {
|
||||
const px=value=>Math.round(value);
|
||||
@@ -234,6 +323,335 @@ export function measureInventory(scope) {
|
||||
buttons:[...actions.querySelectorAll('button')].map(node=>({text:node.textContent.trim(),primary:node.classList.contains('ca-primary'),
|
||||
...font(node),...edges(node),minHeight:getComputedStyle(node).minHeight,height:px(rect(node).height)}))}))};
|
||||
}
|
||||
return {chrome:chromeOf(),heading:headingOf(),toolbar:null,filterRow:null,caption:null,grid:null,cards:null,panel:null,
|
||||
library:null,history:null,noPageScroll:document.documentElement.scrollWidth<=innerWidth+1};
|
||||
// ---- Images (#249 C7). Every box is relative to its section, every section to `main` (vertical rhythm). ----
|
||||
const classes=node=>[...node.classList].sort().join(' ');
|
||||
const sized=node=>({width:px(rect(node).width),height:px(rect(node).height)});
|
||||
/** One control or label in a toolbar-like row: what it is, says and offers, and its box in `origin`. */
|
||||
function controlOf(node,origin) {
|
||||
const base={tag:node.tagName.toLowerCase(),className:classes(node),id:node.id||null,shown:shown(node)};
|
||||
if(!base.shown)return base;
|
||||
const style=getComputedStyle(node),input=node.querySelector('input');
|
||||
// A group (the layout `.segmented`) is recorded button by button: its joined text says nothing about either.
|
||||
const buttons=[...node.querySelectorAll(':scope > button')];
|
||||
return {...base,text:buttons.length?null:textOf(node,'select, option'),label:node.getAttribute('aria-label'),pressed:node.getAttribute('aria-pressed'),
|
||||
role:buttons.length?node.getAttribute('role'):null,buttons:buttons.length?buttons.map(button=>controlOf(button,node)):null,
|
||||
primary:node.classList.contains('ca-primary'),htmlFor:node.htmlFor||null,...relative(node,origin),
|
||||
...font(node),...edges(node),...layout(node),minHeight:style.minHeight,minWidth:style.minWidth,flexGrow:style.flexGrow,flexBasis:style.flexBasis,
|
||||
icon:iconOf(node),
|
||||
options:node.tagName==='SELECT'?[...node.options].map(option=>option.textContent):null,
|
||||
input:when(input,()=>({type:input.type,id:input.id,placeholder:input.placeholder,label:input.getAttribute('aria-label'),
|
||||
...font(input),...edges(input),minHeight:getComputedStyle(input).minHeight,...relative(input,node),exact:{width:rect(input).width}})),
|
||||
// Unrounded place, for a comparison that lays a row out again; `stripExact` removes it before any diff.
|
||||
exact:{left:rect(node).left-rect(origin).left,width:rect(node).width}};
|
||||
}
|
||||
function rowOf(row) {
|
||||
return {...layout(row),...edges(row),...relative(row,main),exact:{width:rect(row).width},items:[...row.children].map(node=>controlOf(node,row))};
|
||||
}
|
||||
const gallery=main?.querySelector(':scope > .gallery-layout');
|
||||
const body=gallery?.querySelector(':scope > .gallery-body');
|
||||
const grid=body?.querySelector(':scope > .image-grid');
|
||||
function captionOf(caption) {
|
||||
return {...layout(caption),...edges(caption),...font(caption),...relative(caption,body),
|
||||
// A span's width follows its text, so only its line box is recorded (the D17 date's text differs).
|
||||
spans:[...caption.children].filter(shown).map(span=>({tag:span.tagName.toLowerCase(),id:span.id||null,text:textOf(span),...font(span),
|
||||
top:relative(span,caption).top,height:relative(span,caption).height}))};
|
||||
}
|
||||
function gridOf() {
|
||||
const style=getComputedStyle(grid);
|
||||
return {id:grid.id||null,className:classes(grid),columns:style.gridTemplateColumns.split(/\s+/).length,
|
||||
...css(grid,'columnGap','rowGap'),...relative(grid,body),cardCount:grid.querySelectorAll(':scope > .media-card').length};
|
||||
}
|
||||
function cardOf(card) {
|
||||
const art=card.querySelector(':scope > .media-art'),open=art?.querySelector('.image-open'),img=open?.querySelector('img');
|
||||
const overlays=art?.querySelector('.image-overlays'),info=card.querySelector(':scope > .media-info');
|
||||
const copy=info?.querySelector(':scope > div:not(.image-card-actions)'),actions=info?.querySelector('.image-card-actions');
|
||||
const h3=copy?.querySelector('h3'),p=copy?.querySelector('p');
|
||||
return {className:classes(card),ratio:card.style.getPropertyValue('--media-ratio').replace(/\s/g,''),...relative(card,grid),
|
||||
exact:{left:rect(card).left-rect(grid).left,top:rect(card).top-rect(grid).top,height:rect(card).height},
|
||||
...edges(card),...css(card,'boxShadow','overflow','display'),
|
||||
art:when(art,()=>({...relative(art,card),...css(art,'position')})),
|
||||
open:when(open,()=>({label:open.getAttribute('aria-label'),...relative(open,card),...edges(open),...css(open,'display','overflow')})),
|
||||
img:when(img,()=>({alt:img.alt,width:img.getAttribute('width'),height:img.getAttribute('height'),...sized(img),
|
||||
...css(img,'objectFit','aspectRatio','transform','transitionProperty','transitionDuration')})),
|
||||
// An overlay item's horizontal margin may be `auto`, whose used value is the free space beside it: it is kept
|
||||
// to the pixel, and the item's place is also recorded as its inset from the overlay's right edge.
|
||||
overlays:when(overlays,()=>!shown(overlays)?{display:getComputedStyle(overlays).display,shown:false}:({shown:true,...layout(overlays),
|
||||
...css(overlays,'display','pointerEvents'),...relative(overlays,card),
|
||||
items:[...overlays.children].map(node=>{const item=controlOf(node,card);
|
||||
return item.shown?{...item,marginLeft:`${px(parseFloat(item.marginLeft))}px`,marginRight:`${px(parseFloat(item.marginRight))}px`,
|
||||
rightInset:px(rect(overlays).right-rect(node).right)}:item;})})),
|
||||
info:when(info,()=>({...layout(info),...edges(info),...relative(info,card),
|
||||
// The text and the actions are placed in the card (what is seen), so a centred list row compares by place.
|
||||
// The text's heights are the unrounded flow (the same CSS lays them out alike in both documents), so lines
|
||||
// of 16.5px and 13.5px add up exactly.
|
||||
copy:when(copy,()=>({left:relative(copy,card).left,top:relative(copy,card).top,height:rect(copy).height,...css(copy,'minWidth')})),
|
||||
h3:when(h3,()=>({text:h3.textContent,title:h3.getAttribute('title'),...font(h3),...edges(h3),
|
||||
...css(h3,'overflowX','textOverflow','whiteSpace'),height:rect(h3).height})),
|
||||
// `maxContent` is the subtitle's one-line width (an unwrapped hidden copy beside it), so a wider text column can
|
||||
// be shown to fit it on one line.
|
||||
p:when(p,()=>({text:p.textContent,...font(p),...edges(p),height:rect(p).height,maxContent:(()=>{
|
||||
const probe=p.cloneNode(true);probe.style.cssText='position:absolute;visibility:hidden;white-space:nowrap;width:max-content';
|
||||
p.after(probe);const width=rect(probe).width;probe.remove();return px(width);})()})),
|
||||
actions:when(actions,()=>({...layout(actions),...relative(actions,card),...css(actions,'flexShrink','minWidth'),
|
||||
buttons:[...actions.children].map(node=>controlOf(node,actions))}))}))};
|
||||
}
|
||||
function continuationOf(continuation) {
|
||||
return {id:continuation.id||null,...layout(continuation),...edges(continuation),...relative(continuation,body),
|
||||
items:[...continuation.children].filter(shown).map(node=>controlOf(node,continuation))};
|
||||
}
|
||||
function panelOf() {
|
||||
const panel=gallery.children[1];
|
||||
// The panel's content (and so the layout's height) is the inspector's, PR D; its box is compared here.
|
||||
const {height,...place}=relative(gallery,main);
|
||||
return {...css(gallery,'display','gridTemplateColumns','columnGap','rowGap','alignItems'),...place,
|
||||
box:when(panel,()=>{const box=relative(panel,gallery);return {left:box.left,top:box.top,width:box.width,...css(panel,'position','top')};})};
|
||||
}
|
||||
const continuation=body?.querySelector(':scope > .infinite-continuation');
|
||||
function bulkbarOf(bar) {
|
||||
return {id:bar.id||null,...relative(bar,main),children:bar.children.length,bar:when(bar.querySelector(':scope > .bulkbar'),rowOf)};
|
||||
}
|
||||
// ---- Prompt library (#249 E3). The concept's main holds the toolbar, caption and grid; production's shelf wraps
|
||||
// them in `section.library-shelf` and windows the cards into `.library-row`s (spec §3.4: "row windowing kept"),
|
||||
// so every Library box is found by descendant and placed relative to `main` or its own card.
|
||||
// A `display: contents` node has no box: its children stand in its place (the D23 prompt-block mount point).
|
||||
const boxedChildren=node=>[...node.children].flatMap(child=>getComputedStyle(child).display==='contents'?boxedChildren(child):[child]);
|
||||
function libraryCardOf(card,origin) {
|
||||
const art=card.querySelector(':scope > .card-art'),content=card.querySelector(':scope > .card-content'),footer=card.querySelector(':scope > .card-footer');
|
||||
const title=content?.querySelector(':scope > .card-title'),meta=content?.querySelector(':scope > .card-meta'),tags=content?.querySelector(':scope > .tags');
|
||||
const section=content?.querySelector('.prompt-section'),heading=section?.querySelector(':scope > .prompt-heading');
|
||||
const text=section?.querySelector(':scope > .prompt-text'),facts=footer?.querySelector(':scope > span'),actions=footer?.querySelector(':scope > .row-actions');
|
||||
const lines=node=>Math.round(rect(node).height/parseFloat(getComputedStyle(node).lineHeight));
|
||||
const spanOf=(node,parent)=>({tag:node.tagName.toLowerCase(),className:classes(node),text:textOf(node),...font(node),...edges(node),...relative(node,parent)});
|
||||
return {tag:card.tagName.toLowerCase(),className:classes(card),...relative(card,origin),...edges(card),...css(card,'overflow','display','boxShadow'),
|
||||
children:[...card.children].map(classes),
|
||||
art:when(art,()=>({...relative(art,card),...css(art,'display','gridTemplateColumns','columnGap','rowGap','backgroundColor'),
|
||||
items:[...art.children].map(node=>({tag:node.tagName.toLowerCase(),className:classes(node),alt:node.getAttribute('alt'),...relative(node,art),
|
||||
...css(node,'objectFit')}))})),
|
||||
content:when(content,()=>({...relative(content,card),...edges(content),children:boxedChildren(content).map(node=>`${node.tagName.toLowerCase()}.${classes(node)}`)})),
|
||||
title:when(title,()=>({...layout(title),...edges(title),...relative(title,content),
|
||||
items:[...title.children].map(node=>node.tagName==='BUTTON'?controlOf(node,title):spanOf(node,title))})),
|
||||
section:when(section,()=>({className:classes(section),...relative(section,content),...edges(section),
|
||||
label:when(heading?.querySelector('.role-label'),label=>({text:label.textContent,...font(label)})),
|
||||
actions:when(heading?.querySelector('.prompt-actions'),group=>({...layout(group),...relative(group,section),
|
||||
buttons:[...group.children].map(node=>controlOf(node,group))})),
|
||||
text:when(text,()=>({text:text.textContent,...font(text),...edges(text),...relative(text,section),lines:lines(text)}))})),
|
||||
meta:when(meta,()=>({...layout(meta),...edges(meta),...font(meta),...relative(meta,content),items:[...meta.children].map(node=>spanOf(node,meta))})),
|
||||
// A chip's unrounded place too, so a re-ordered row can be laid out again (`reflow`; `stripExact` removes it).
|
||||
tags:when(tags,()=>({...layout(tags),...edges(tags),...relative(tags,content),exact:{width:rect(tags).width},
|
||||
items:[...tags.children].map(node=>({...spanOf(node,tags),shown:shown(node),exact:{left:rect(node).left-rect(tags).left,width:rect(node).width}}))})),
|
||||
footer:when(footer,()=>({...layout(footer),...edges(footer),...font(footer),...relative(footer,card),
|
||||
// What is seen (hidden-only text left out) and what is spoken (aria-hidden text left out).
|
||||
facts:when(facts,()=>({text:textOf(facts,'.ca-visually-hidden'),spoken:accessibleText(facts),...font(facts),...relative(facts,footer)})),
|
||||
actions:when(actions,()=>({...layout(actions),...relative(actions,footer),buttons:[...actions.children].map(node=>controlOf(node,actions))}))}))};
|
||||
}
|
||||
function libraryOf() {
|
||||
const grid=main?.querySelector('.library-grid');
|
||||
if(!grid)return null;
|
||||
const toolbar=main.querySelector('search.toolbar'),caption=main.querySelector('.section-caption');
|
||||
const cards=[...grid.querySelectorAll('.prompt-card')];
|
||||
// Whatever the page shows after the grid, in document order (the concept: nothing).
|
||||
const trailing=[];
|
||||
for(let node=grid;node&&node!==main;node=node.parentElement)
|
||||
for(let next=node.nextElementSibling;next;next=next.nextElementSibling)if(shown(next))trailing.push({tag:next.tagName.toLowerCase(),className:classes(next),text:next.innerText.replace(/\s+/g,' ').trim()});
|
||||
const style=getComputedStyle(grid);
|
||||
return {toolbar:when(toolbar,rowOf),
|
||||
caption:when(caption,()=>({...layout(caption),...edges(caption),...font(caption),...relative(caption,main),
|
||||
spans:[...caption.children].filter(shown).map(span=>({tag:span.tagName.toLowerCase(),text:textOf(span),...font(span),
|
||||
top:relative(span,caption).top,height:relative(span,caption).height}))})),
|
||||
grid:{columns:style.gridTemplateColumns.split(/\s+/).length,...css(grid,'columnGap','rowGap'),...relative(grid,main),cardCount:cards.length},
|
||||
cards:cards.slice(0,6).map(card=>libraryCardOf(card,grid)),trailing};
|
||||
}
|
||||
// ---- Prompt history (#249 F5): renderHistory a587–622, style.css 1059–1179 and 1546–1567. The concept's main holds
|
||||
// the summary, the variants panel, the comparison and the gallery; production's sit in `section.library-history`, so
|
||||
// each is found by descendant and placed relative to `main` (vertical rhythm) or to its own section.
|
||||
const spoken=node=>node.classList.contains('ca-visually-hidden');
|
||||
const outline=node=>css(node,'outlineStyle','outlineWidth','outlineColor','outlineOffset');
|
||||
const pseudo=(node,which)=>{const style=getComputedStyle(node,which);return Object.fromEntries(['content','position','top','left','width','height',
|
||||
'borderLeftWidth','borderBottomWidth','borderLeftColor','borderBottomColor','borderBottomLeftRadius','backgroundColor','color'].map(key=>[key,style[key]]));};
|
||||
// What a section shows, in order: each shown child as `tag.classes`, visually hidden statuses apart (`spoken`).
|
||||
const structureOf=node=>({children:boxedChildren(node).filter(child=>shown(child)&&!spoken(child)).map(child=>`${child.tagName.toLowerCase()}.${classes(child)}`),
|
||||
spoken:[...node.querySelectorAll('.ca-visually-hidden')].filter(shown).map(child=>({role:child.getAttribute('role'),text:child.textContent}))});
|
||||
function promptSectionOf(section,origin) {
|
||||
const heading=section.querySelector(':scope > .prompt-heading'),label=heading?.querySelector('.role-label');
|
||||
const actions=heading?.querySelector('.prompt-actions'),text=section.querySelector(':scope > .prompt-text');
|
||||
return {className:classes(section),...relative(section,origin),...edges(section),
|
||||
label:when(label,()=>({text:label.textContent,...font(label)})),
|
||||
actions:when(actions,()=>({...layout(actions),...relative(actions,section),buttons:[...actions.children].map(node=>controlOf(node,actions))})),
|
||||
text:when(text,()=>({text:text.textContent,...font(text),...edges(text),...relative(text,section),
|
||||
lines:Math.round(rect(text).height/parseFloat(getComputedStyle(text).lineHeight))}))};
|
||||
}
|
||||
function panelHeadingOf(heading,origin) {
|
||||
const h2=heading.querySelector('h2'),note=heading.querySelector('.small'),actions=heading.querySelector(':scope > .row-actions');
|
||||
return {...layout(heading),...edges(heading),...relative(heading,origin),
|
||||
h2:when(h2,()=>({text:h2.textContent,...font(h2),...edges(h2),...relative(h2,heading)})),
|
||||
note:when(note,()=>({tag:note.tagName.toLowerCase(),text:note.textContent,...font(note),...edges(note),...relative(note,heading)})),
|
||||
actions:when(actions,()=>({...layout(actions),...relative(actions,heading),buttons:[...actions.children].filter(shown).map(node=>controlOf(node,actions))}))};
|
||||
}
|
||||
function laneOf(lane,timeline) {
|
||||
const dot=lane.querySelector(':scope > .variant-dot'),label=lane.querySelector(':scope > span'),run=lane.querySelector(':scope > .run-dot');
|
||||
return {className:classes(lane),offset:lane.style.getPropertyValue('--lane-offset'),...relative(lane,timeline),...edges(lane),...css(lane,'minWidth','position'),
|
||||
before:pseudo(lane,'::before'),
|
||||
dot:when(dot,()=>({...controlOf(dot,lane),...outline(dot),...css(dot,'position','top','left','width','height','color')})),
|
||||
label:when(label,()=>({text:label.textContent,...font(label),...relative(label,lane),...css(label,'position','top','left')})),
|
||||
run:shown(run)?{...controlOf(run,lane),...css(run,'position','top','left','width','height')}:null};
|
||||
}
|
||||
function timelineOf(timeline,origin) {
|
||||
const positive=timeline.querySelector(':scope > .positive-lane'),span=positive?.querySelector('span');
|
||||
return {...edges(timeline),...css(timeline,'overflowX','position'),...relative(timeline,origin),
|
||||
positive:when(positive,()=>({...edges(positive),...css(positive,'height','minWidth'),...relative(positive,timeline),before:pseudo(positive,'::before'),
|
||||
span:when(span,()=>({text:span.textContent,...font(span),...relative(span,positive)}))})),
|
||||
lanes:[...timeline.querySelectorAll(':scope > .variant-lane')].map(lane=>laneOf(lane,timeline)),
|
||||
structure:structureOf(timeline)};
|
||||
}
|
||||
function variantsOf(panel) {
|
||||
const heading=panel.querySelector(':scope > .panel-heading'),timeline=panel.querySelector(':scope > .timeline'),lower=panel.querySelector(':scope > .timeline-lower');
|
||||
return {className:classes(panel),...edges(panel),...relative(panel,main),structure:structureOf(panel),
|
||||
heading:when(heading,()=>panelHeadingOf(heading,panel)),timeline:when(timeline,()=>timelineOf(timeline,panel)),
|
||||
lower:when(lower,()=>({...layout(lower),...edges(lower),...relative(lower,panel),
|
||||
items:[...lower.children].filter(shown).map(node=>({...controlOf(node,lower),...font(node)}))}))};
|
||||
}
|
||||
function compareOf(section) {
|
||||
const h2=section.querySelector(':scope > h2'),intro=section.querySelector(':scope > p:not(.small)'),grid=section.querySelector(':scope > .compare-grid');
|
||||
return {...edges(section),...relative(section,main),structure:structureOf(section),
|
||||
h2:when(h2,()=>({text:h2.textContent,...font(h2),...edges(h2)})),
|
||||
intro:when(intro,()=>({text:intro.textContent,...font(intro),...edges(intro),...relative(intro,section)})),
|
||||
grid:when(grid,()=>({columns:getComputedStyle(grid).gridTemplateColumns.split(/\s+/).length,...css(grid,'columnGap','rowGap'),...relative(grid,section),
|
||||
items:[...grid.children].map(item=>({className:classes(item),...edges(item),...relative(item,grid),
|
||||
sections:[...item.querySelectorAll('.prompt-section')].map(node=>promptSectionOf(node,item))}))})),
|
||||
notes:[...section.querySelectorAll(':scope > p.small')].map(note=>({text:note.textContent,...font(note),...edges(note),...relative(note,section)})),
|
||||
added:[...section.querySelectorAll(':scope > .diff-added')].map(chip=>({text:chip.textContent,...font(chip),...edges(chip),...css(chip,'display'),...relative(chip,section)}))};
|
||||
}
|
||||
function historyOf() {
|
||||
const panel=main?.querySelector('section.timeline-panel');
|
||||
if(!panel)return null;
|
||||
const summary=main.querySelector('article.surface'),gallery=main.querySelector('section.history-gallery');
|
||||
const compare=[...main.querySelectorAll('section.surface:not(.timeline-panel)')].find(shown);
|
||||
const chip=summary?.querySelector(':scope > .chip'),meta=summary?.querySelector(':scope > .card-meta');
|
||||
const galleryHeading=gallery?.querySelector(':scope > .panel-heading');
|
||||
return {
|
||||
summary:when(summary,()=>({...edges(summary),...relative(summary,main),structure:structureOf(summary),
|
||||
chip:when(chip,()=>({text:chip.textContent,...font(chip),...edges(chip),...relative(chip,summary)})),
|
||||
sections:[...summary.querySelectorAll('.prompt-section')].map(node=>promptSectionOf(node,summary)),
|
||||
meta:when(meta,()=>({...layout(meta),...edges(meta),...font(meta),...relative(meta,summary),
|
||||
items:[...meta.children].filter(shown).map(node=>({text:node.textContent,...font(node),...relative(node,meta)}))}))})),
|
||||
variants:variantsOf(panel),
|
||||
compare:when(compare,compareOf),
|
||||
gallery:when(gallery,()=>({...edges(gallery),...relative(gallery,main),
|
||||
heading:when(galleryHeading,()=>panelHeadingOf(galleryHeading,gallery))})),
|
||||
};
|
||||
}
|
||||
return {chrome:chromeOf(),heading:headingOf(),
|
||||
toolbar:when(main?.querySelector(':scope > search.toolbar'),rowOf),
|
||||
filterRow:when(main?.querySelector(':scope > .filter-row'),rowOf),
|
||||
bulkbar:when(main?.querySelector(':scope > #bulkbar'),bulkbarOf),
|
||||
caption:when(body?.querySelector(':scope > .section-caption'),captionOf),
|
||||
grid:when(grid,gridOf),
|
||||
cards:grid?[...grid.querySelectorAll(':scope > .media-card')].slice(0,6).map(cardOf):null,
|
||||
continuation:when(continuation,continuationOf),
|
||||
panel:when(gallery,panelOf),
|
||||
library:libraryOf(),history:historyOf(),noPageScroll:document.documentElement.scrollWidth<=innerWidth+1};
|
||||
}
|
||||
|
||||
/**
|
||||
* The inspector (#249 D4): `panel` is the Images side panel's content (`aside#image-inspector`, app.js imageDetails
|
||||
* a400–414) and `viewerDetails` the viewer's full details (`aside#viewer-details`, imageDetails(i, true)), each null
|
||||
* when absent. Both are measured by one in-page function with the concept's selectors in either document.
|
||||
*/
|
||||
export async function inspectorOf(page) {
|
||||
return {panel:await page.evaluate(measureInspector,'#image-inspector'),
|
||||
viewerDetails:await page.evaluate(measureInspector,'#viewer-details')};
|
||||
}
|
||||
|
||||
/** Runs in the page; self-contained. Every box is relative to the measured root. */
|
||||
export function measureInspector(selector) {
|
||||
const root=document.querySelector(selector);
|
||||
if(!root)return null;
|
||||
const px=value=>Math.round(value);
|
||||
const css=(node,...keys)=>{const style=getComputedStyle(node);return Object.fromEntries(keys.map(key=>[key,style[key]]));};
|
||||
const shown=node=>Boolean(node)&&node.getClientRects().length>0&&getComputedStyle(node).visibility!=='hidden';
|
||||
const rect=node=>node.getBoundingClientRect();
|
||||
const font=node=>css(node,'fontFamily','fontSize','fontWeight','lineHeight','letterSpacing','color','textTransform');
|
||||
const edges=node=>css(node,'paddingTop','paddingRight','paddingBottom','paddingLeft','marginTop','marginRight','marginBottom','marginLeft',
|
||||
'borderTopWidth','borderTopStyle','borderTopColor','borderRightWidth','borderBottomWidth','borderTopLeftRadius','backgroundColor');
|
||||
const layout=node=>css(node,'display','flexDirection','flexWrap','justifyContent','alignItems','columnGap','rowGap');
|
||||
const box=node=>{const a=rect(node),b=rect(root);return {left:px(a.left-b.left),top:px(a.top-b.top),width:px(a.width),height:px(a.height)};};
|
||||
const classes=node=>[...node.classList].sort().join(' ');
|
||||
const when=(node,measure)=>node?measure(node):null;
|
||||
// Visible text: without visually hidden prefixes and aria-hidden glyphs, whitespace collapsed.
|
||||
const textOf=(node,skip='.ca-visually-hidden, [aria-hidden="true"]')=>{const copy=node.cloneNode(true);copy.querySelectorAll(skip).forEach(item=>item.remove());
|
||||
return copy.textContent.replace(/\s+/g,' ').trim();};
|
||||
const hiddenOf=node=>[...node.querySelectorAll('.ca-visually-hidden')].map(item=>item.textContent).join('')||null;
|
||||
const nameOf=node=>node.getAttribute('aria-label')??textOf(node,'[aria-hidden="true"]');
|
||||
function iconOf(node) {
|
||||
const svg=node.querySelector(':scope > svg.icon');
|
||||
if(!svg)return null;
|
||||
return {width:px(rect(svg).width),height:px(rect(svg).height),viewBox:svg.getAttribute('viewBox'),hidden:svg.getAttribute('aria-hidden'),
|
||||
d:svg.querySelector('path')?.getAttribute('d')??null,...css(svg,'strokeWidth','color')};
|
||||
}
|
||||
function controlOf(node) {
|
||||
const style=getComputedStyle(node);
|
||||
return {tag:node.tagName.toLowerCase(),className:classes(node),text:textOf(node),name:nameOf(node),pressed:node.getAttribute('aria-pressed'),
|
||||
primary:node.classList.contains('ca-primary'),...box(node),...font(node),...edges(node),...layout(node),
|
||||
minHeight:style.minHeight,minWidth:style.minWidth,icon:iconOf(node)};
|
||||
}
|
||||
function chipOf(node) {
|
||||
return {tag:node.tagName.toLowerCase(),className:classes(node),text:textOf(node),hidden:hiddenOf(node),...box(node),...font(node),...edges(node),
|
||||
minHeight:getComputedStyle(node).minHeight};
|
||||
}
|
||||
function rowOf(row) {
|
||||
return {...layout(row),...edges(row),...box(row),chips:[...row.children].map(chipOf)};
|
||||
}
|
||||
function sectionOf(section) {
|
||||
const heading=section.querySelector(':scope > .prompt-heading'),label=heading?.querySelector('.role-label');
|
||||
const actions=heading?.querySelector('.prompt-actions'),text=section.querySelector(':scope > .prompt-text');
|
||||
return {className:classes(section),...edges(section),...box(section),
|
||||
heading:when(heading,()=>({...layout(heading),...edges(heading),...box(heading)})),
|
||||
label:when(label,()=>({tag:label.tagName.toLowerCase(),text:label.textContent,...font(label),...box(label)})),
|
||||
actions:when(actions,()=>({...layout(actions),...box(actions),buttons:[...actions.children].map(controlOf)})),
|
||||
text:when(text,()=>({tag:text.tagName.toLowerCase(),className:classes(text),text:text.textContent,tabIndex:text.tabIndex,...font(text),...edges(text),
|
||||
...css(text,'whiteSpace','overflowWrap'),...box(text),lines:px(rect(text).height/parseFloat(getComputedStyle(text).lineHeight))}))};
|
||||
}
|
||||
function factsOf(list) {
|
||||
const style=getComputedStyle(list);
|
||||
return {...css(list,'display','columnGap','rowGap','fontSize'),...edges(list),columns:style.gridTemplateColumns.split(/\s+/).length,...box(list),
|
||||
items:[...list.children].map(item=>{const dt=item.querySelector('dt'),dd=item.querySelector('dd');
|
||||
return {...box(item),dt:when(dt,()=>({text:dt.textContent,...font(dt),...edges(dt)})),dd:when(dd,()=>({text:dd.textContent,...font(dd),...edges(dd)}))};})};
|
||||
}
|
||||
const describe=item=>`${item.localName}${item.classList.length?'.'+[...item.classList].join('.'):''}`;
|
||||
const panel=root.classList.contains('detail-panel');
|
||||
// The panel's `.detail-inner`, the viewer's class-less wrapper (D20). A prompt-block mount point that is
|
||||
// `display: contents` is transparent: its sections count as the wrapper's own children.
|
||||
const inner=root.querySelector(panel?':scope > .detail-inner':':scope > div');
|
||||
const parts=inner?[...inner.children].flatMap(item=>getComputedStyle(item).display==='contents'?[...item.children]:[item]):[];
|
||||
const sections=parts.filter(item=>item.classList.contains('prompt-section'));
|
||||
const firstSection=parts.indexOf(sections[0]);
|
||||
const rows=parts.filter(item=>item.classList.contains('tags'));
|
||||
const heading=parts.find(item=>item.classList.contains('panel-heading')),h2=heading?.querySelector('h2'),format=heading?.querySelector('.chip');
|
||||
const filename=parts.find(item=>item.matches('p.small'));
|
||||
const cover=root.querySelector(':scope > .detail-cover'),footer=root.querySelector(':scope > .detail-footer');
|
||||
// What a closed disclosure holds is not offered (only its summary is), whatever boxes the engine keeps for it.
|
||||
const offered=node=>{const closed=node.closest('details:not([open])');return !closed||node.parentElement===closed&&node.localName==='summary';};
|
||||
const controls=[...root.querySelectorAll('button, a[href], [tabindex]:not([tabindex="-1"]), summary')].filter(node=>shown(node)&&offered(node));
|
||||
return {
|
||||
className:classes(root),label:root.getAttribute('aria-label'),
|
||||
...css(root,'position','top','overflowX','overflowY','maxHeight'),...edges(root),width:px(rect(root).width),height:px(rect(root).height),
|
||||
structure:{outer:[...root.children].map(describe),inner:parts.map(describe)},
|
||||
cover:when(cover,()=>({tag:cover.tagName.toLowerCase(),alt:cover.getAttribute('alt'),...box(cover),...css(cover,'objectFit','display')})),
|
||||
inner:when(inner,()=>({...edges(inner),...box(inner)})),
|
||||
heading:when(heading,()=>({...layout(heading),...edges(heading),...box(heading),
|
||||
h2:when(h2,()=>({text:h2.textContent,...font(h2),...edges(h2),...box(h2)})),format:when(format,chipOf)})),
|
||||
filename:when(filename,()=>({text:filename.textContent,...font(filename),...edges(filename),...box(filename)})),
|
||||
badges:when(rows.find(row=>parts.indexOf(row)<firstSection),rowOf),
|
||||
sections:sections.map(sectionOf),
|
||||
tags:when(rows.find(row=>parts.indexOf(row)>firstSection),rowOf),
|
||||
facts:when(parts.find(item=>item.matches('dl.meta-grid')),factsOf),
|
||||
// Disclosures (viewer only) by their summary and frame; their content is the D20 production facts.
|
||||
details:parts.filter(item=>item.localName==='details').map(item=>{const summary=item.querySelector(':scope > summary');
|
||||
return {open:item.open,...edges(item),...box(item),summary:when(summary,()=>({text:summary.textContent,...font(summary),...box(summary)}))};}),
|
||||
footer:when(footer,()=>({...layout(footer),...edges(footer),...box(footer),buttons:[...footer.children].map(controlOf)})),
|
||||
// The viewer's pair send and history follow the wrapper directly (imageDetails(i, true) has no footer).
|
||||
actions:panel?null:[...root.children].filter(item=>item.localName==='button').map(controlOf),
|
||||
controls:controls.map(node=>({tag:node.tagName.toLowerCase(),text:textOf(node),name:nameOf(node),pressed:node.getAttribute('aria-pressed'),
|
||||
primary:node.classList.contains('ca-primary'),minHeight:getComputedStyle(node).minHeight})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Shared `test` for the page workflows: every Playwright worker owns one
|
||||
// tests/browser/server.py process on a port the OS chose for it, with its own temp root
|
||||
// and database. `baseURL` (and so `page`, `request` and `browser.newContext`)
|
||||
// points at that worker's server. A server that dies fails the running test
|
||||
// with its stderr tail, and the next test in the worker gets a fresh server.
|
||||
// A test that fails gets the stderr its server wrote during it attached as
|
||||
// `server-stderr`.
|
||||
import { test as base, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import { attachServerStderr, createServerSupervisor, startBrowserServer } from '../../tools/vendor/start-browser-server.mjs';
|
||||
|
||||
const SERVER_FIXTURE_TIMEOUT_MS = 90_000;
|
||||
|
||||
export const test = base.extend({
|
||||
testServer: [async ({}, use) => {
|
||||
const supervisor = createServerSupervisor({ start: () => startBrowserServer() });
|
||||
try {
|
||||
await use(supervisor);
|
||||
} finally {
|
||||
await supervisor.stop();
|
||||
}
|
||||
}, { scope: 'worker', timeout: SERVER_FIXTURE_TIMEOUT_MS }],
|
||||
workerServer: [async ({ testServer }, use, testInfo) => {
|
||||
const server = await testServer.ensure();
|
||||
const mark = server.stderrMark();
|
||||
await use(server);
|
||||
await attachServerStderr(testInfo, server, mark);
|
||||
const crash = testServer.crash(server);
|
||||
if (crash) throw crash;
|
||||
}, { auto: true, timeout: SERVER_FIXTURE_TIMEOUT_MS }],
|
||||
baseURL: async ({ workerServer }, use) => use(workerServer.url),
|
||||
});
|
||||
|
||||
export { expect };
|
||||
@@ -1,5 +1,5 @@
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
|
||||
|
||||
for (const variant of ['Focused', 'Split']) {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
import { seedConcept, openConcept, openProduction, SIZES } from './concept-inventory.mjs';
|
||||
// D10/D20: the heading is the linked positive's title (its confirmed first line here), never the pair's
|
||||
// "Inspector recipe", which names the negative variant.
|
||||
const POSITIVE_TITLE='Inspector portrait 猫 😀';
|
||||
|
||||
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));});
|
||||
@@ -71,7 +75,7 @@ test('inspection wire preserves exact rooted envelope', async ({ request }) => {
|
||||
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(result.library).toMatchObject({ status: 'linked', category_scope: 'pair', tags_scope: 'pair', title: POSITIVE_TITLE, 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);
|
||||
@@ -85,8 +89,10 @@ test('inspection wire preserves exact rooted envelope', async ({ request }) => {
|
||||
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();
|
||||
const inspector=page=>page.locator('.detail-panel');
|
||||
/** D19: the selected card's image opens the viewer (the side panel has no full-preview cover). */
|
||||
const openSelected=page=>page.locator('.media-card.selected .image-open').click();
|
||||
const selectFile=async(page,name,root=0)=>page.locator(`article[data-image-key="${root}:inspector/${name}"] .image-card-actions button:first-child`).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');
|
||||
@@ -95,47 +101,257 @@ 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')};
|
||||
}
|
||||
/** The viewer's full details for one file (D19: the card image opens the viewer; D20: only it shows provenance and raw). */
|
||||
async function viewerDetails(page,name,root=0) {
|
||||
await page.locator(`article[data-image-key="${root}:inspector/${name}"] .image-open`).click();
|
||||
const details=page.locator('dialog#viewer-dialog #viewer-details');await expect(details.locator('.panel-heading')).toBeVisible();return details;
|
||||
}
|
||||
/** Rewrites the next inspection reads; `null` passes them through. */
|
||||
async function patchInspection(page) {
|
||||
const state={patch:null};
|
||||
await page.route('**/images/inspect/**',async route=>{
|
||||
if (!state.patch) return route.continue();
|
||||
const response=await route.fetch();const data=await response.json();state.patch(data);await route.fulfill({response,json:data});
|
||||
});
|
||||
return state;
|
||||
}
|
||||
/** The side panel's concept structure (app.js imageDetails a400–414): element, class and visible text, in order. */
|
||||
const panelStructure=panel=>panel.evaluate(node=>{
|
||||
const describe=item=>`${item.localName}${item.className?'.'+item.className.trim().split(/\s+/).join('.'):''}`;
|
||||
const inner=node.querySelector(':scope > .detail-inner');
|
||||
const flatten=item=>item.classList.contains('prompt-inspector')?[...item.children].map(describe):[describe(item)];
|
||||
return {outer:[...node.children].map(describe),inner:inner?[...inner.children].flatMap(flatten):null};
|
||||
});
|
||||
|
||||
test('panel matches the concept order and copy',async({page,request})=>{
|
||||
await page.setViewportSize({width:1600,height:1100});const reads=await patchInspection(page);
|
||||
await fullFixture(page,request);const panel=page.getByRole('complementary',{name:'Selected image details',exact:true});
|
||||
await expect(panel).toHaveAttribute('id','image-inspector');await expect(panel).toHaveClass('detail-panel file-inspector');
|
||||
// Linked, workflow inferred: every concept element.
|
||||
await expect(panel.locator('.panel-heading h2')).toHaveText(POSITIVE_TITLE);
|
||||
expect(await panelStructure(panel)).toEqual({outer:['img.detail-cover','div.detail-inner','div.detail-footer'],
|
||||
inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','div.tags','dl.meta-grid']});
|
||||
await expect(panel.locator('img.detail-cover')).toHaveAttribute('alt','');
|
||||
await expect(panel.locator('.panel-heading .chip')).toHaveText('PNG');
|
||||
await expect(panel.locator('.detail-inner > p.small')).toHaveText('complete.png');
|
||||
const badges=panel.locator('.detail-inner > .tags').first();
|
||||
await expect(badges.locator('span.chip.blue')).toHaveText('Workflow inferred');
|
||||
await expect(badges.locator('span.chip:not(.blue)')).toHaveText('Recipe category: Inspection');
|
||||
await expect(badges.locator('span.chip:not(.blue) .ca-visually-hidden')).toHaveText('Recipe category: ');
|
||||
// Recipe tags are not the positive tags the Images Tags filter matches, so they stay plain chips (D21 ruling).
|
||||
await expect(panel.locator('.detail-inner > .tags').last().locator('span.chip')).toHaveText(['fixture','geometric']);
|
||||
await expect(panel.locator('.detail-inner > .tags button')).toHaveCount(0);
|
||||
await expect(panel.locator('.meta-grid dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);
|
||||
await expect(panel.locator('.meta-grid dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']);
|
||||
const footer=panel.locator('.detail-footer');
|
||||
await expect(footer.locator('button')).toHaveText(['Send to ComfyUI','Prompt history']);
|
||||
await expect(footer.locator('button.send')).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${POSITIVE_TITLE}`);
|
||||
await expect(footer.locator('button.send svg.icon')).toHaveCount(1);await expect(footer.locator('button:not(.send) svg.icon')).toHaveCount(1);
|
||||
await expect(panel).not.toContainText(/prompt evidence|Recipe tags|Positive prompt tags/);
|
||||
// Execution recorded and embedded parameters (D21), with the extension chip naming an unverified format.
|
||||
reads.patch=data=>{for (const role of ['positive','negative']) data.prompts[role].source='execution';};
|
||||
await selectFile(page,'explicit-empty.png');await expect(panel.locator('.panel-heading h2')).toHaveText('explicit-empty.png');
|
||||
await expect(panel.locator('span.chip.blue')).toHaveText('Execution recorded');
|
||||
reads.patch=data=>{data.prompts={positive:{status:'resolved',text:'embedded positive',source:'embedded-parameters'},negative:{status:'resolved',text:'embedded negative',source:'embedded-parameters'}};data.file.format=null;};
|
||||
await selectFile(page,'ambiguous.png');await expect(panel.locator('.prompt-section.positive .prompt-text')).toHaveText('embedded positive');
|
||||
await expect(panel.locator('span.chip.blue')).toHaveText('Embedded parameters');
|
||||
await expect(panel.locator('.panel-heading .chip')).toHaveText('File extension PNG');await expect(panel.locator('.panel-heading .chip .ca-visually-hidden')).toHaveText('File extension ');
|
||||
await expect(panel.locator('.detail-footer button')).toHaveText(['Send to ComfyUI']);
|
||||
// Unlinked: no badge, category, tags, pair or history.
|
||||
reads.patch=null;await selectFile(page,'empty.png');await expect(panel.locator('.panel-heading h2')).toHaveText('empty.png');
|
||||
expect(await panelStructure(panel)).toEqual({outer:['img.detail-cover','div.detail-inner'],
|
||||
inner:['div.panel-heading','p.small','section.prompt-section.positive','section.prompt-section.negative','dl.meta-grid']});
|
||||
await expect(panel.locator('.meta-grid dd')).toHaveText(Array(6).fill('Not recorded'));
|
||||
// Ambiguous saved association: the same honest absence.
|
||||
reads.patch=data=>{data.library={...data.library,status:'ambiguous',positive_id:null,pair_id:null,title:null,category:null,tags:[],category_scope:null,tags_scope:null};};
|
||||
await selectFile(page,'workflow-only.png');await expect(panel.locator('.panel-heading h2')).toHaveText('workflow-only.png');
|
||||
await expect(panel.locator('.chip:not(.blue)')).toHaveCount(1);await expect(panel.locator('.detail-inner > .tags button')).toHaveCount(0);
|
||||
await expect(panel.getByRole('button',{name:'Prompt history',exact:true})).toHaveCount(0);
|
||||
// D39 mixed scopes: each field follows its own source, so the prefix and the tag chips can disagree.
|
||||
const tagRow=panel.locator('.detail-inner > .tags').last();
|
||||
reads.patch=data=>{data.library={...data.library,status:'linked',category:'Subject',category_scope:'pair',tags_scope:'positive',tags:['alpha','beta']};};
|
||||
await selectFile(page,'complete.png');
|
||||
await expect(panel.locator('span.chip:not(.blue) .ca-visually-hidden')).toHaveText('Recipe category: ');
|
||||
await expect(tagRow.getByRole('button')).toHaveText(['alpha','beta']);
|
||||
reads.patch=data=>{data.library={...data.library,status:'linked',category:'Subject',category_scope:'positive',tags_scope:'pair',tags:['alpha','beta']};};
|
||||
await selectFile(page,'workflow-only.png');
|
||||
await expect(panel.locator('span.chip:not(.blue) .ca-visually-hidden')).toHaveText('Positive prompt category: ');
|
||||
await expect(tagRow.locator('span.chip')).toHaveText(['alpha','beta']);await expect(tagRow.getByRole('button')).toHaveCount(0);
|
||||
// Loading and error states render in `.detail-inner` as `.small` text with the existing buttons.
|
||||
reads.patch=null;await page.unroute('**/images/inspect/**');
|
||||
await page.route('**/images/inspect/**',route=>route.fulfill({status:500,json:{success:false,error:'broken'}}));
|
||||
await selectFile(page,'malformed.png');
|
||||
await expect(panel.locator('.detail-inner > p.small[role="alert"]')).toHaveText('Could not read prompts from this file.');
|
||||
await expect(panel.locator('.detail-inner > button')).toHaveText(['Retry prompt details']);
|
||||
});
|
||||
|
||||
test('tag chip filters Images by that tag',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 expect(await request.post('/prompt_manager/prompts/tags',{data:{prompt_id:id,tags:['geometric']}})).toBeOK();
|
||||
// workflow-only.png is linked to the positive alone, so its chips are the positive's tags, which the filter matches.
|
||||
await page.goto('/prompt_manager/workspace#/images');await selectFile(page,'workflow-only.png');
|
||||
const panel=page.getByRole('complementary',{name:'Selected image details',exact:true});
|
||||
const listing=page.waitForRequest(item=>item.url().includes('/images/output?')&&new URL(item.url()).searchParams.has('tags'));
|
||||
await panel.locator('.detail-inner > .tags').getByRole('button',{name:'geometric',exact:true}).click();
|
||||
expect(new URL((await listing).url()).searchParams.get('tags')).toBe('geometric');
|
||||
await expect(page.locator('article[data-image-key="0:inspector/workflow-only.png"]')).toBeVisible();
|
||||
await expect(page.locator('article[data-image-key="0:inspector/complete.png"]')).toBeVisible();
|
||||
await expect(page.locator('article[data-image-key="0:inspector/empty.png"]')).toHaveCount(0);
|
||||
// #249 D44: the chip's Tags filter shows on the toolbar's Filters button.
|
||||
await page.getByRole('button',{name:'Filters · 1',exact:true}).click();
|
||||
await expect(page.getByRole('dialog').getByRole('textbox',{name:'Tags',exact:true})).toHaveValue('geometric');
|
||||
});
|
||||
|
||||
test('pair send and history appear only with real roles and link',async({page,request})=>{
|
||||
await fullFixture(page,request);const panel=page.getByRole('complementary',{name:'Selected image details',exact:true});
|
||||
await panel.getByRole('button',{name:'Prompt history',exact:true}).click();await expect(page).toHaveURL(/#\/library\/\d+$/);
|
||||
await page.goto('/prompt_manager/workspace#/images');await selectFile(page,'explicit-empty.png');
|
||||
await expect(panel.getByRole('button',{name:'Send prompt pair to ComfyUI: explicit-empty.png',exact:true})).toBeVisible();
|
||||
await expect(panel.getByRole('button',{name:'Prompt history',exact:true})).toHaveCount(0);
|
||||
await selectFile(page,'empty.png');await expect(panel.locator('.panel-heading h2')).toHaveText('empty.png');
|
||||
await expect(panel.locator('.detail-footer')).toHaveCount(0);await expect(panel.getByRole('button',{name:/^Send prompt pair/})).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('no provenance, raw or download controls in the side panel',async({page,request})=>{
|
||||
await fullFixture(page,request);const panel=page.getByRole('complementary',{name:'Selected image details',exact:true});
|
||||
await expect(panel.locator('.panel-heading h2')).toHaveText(POSITIVE_TITLE);
|
||||
await expect(panel.locator('details,summary,pre,a')).toHaveCount(0);
|
||||
await expect(panel.getByRole('button',{name:/Download|Open full preview/})).toHaveCount(0);
|
||||
await expect(panel).not.toContainText(/File & provenance|Workflow & raw metadata|Download/);
|
||||
// The viewer keeps them until its own full details (D3).
|
||||
const details=await viewerDetails(page,'complete.png');
|
||||
await expect(details.locator('summary')).toHaveText(['File & provenance','Workflow & raw metadata']);
|
||||
await details.getByText('Workflow & raw metadata',{exact:true}).click();
|
||||
await expect(details.getByRole('button',{name:'Download metadata JSON',exact:true})).toBeVisible();
|
||||
});
|
||||
|
||||
/** The viewer's full details (app.js imageDetails(i, true)): its children, and the class-less wrapper's in order. */
|
||||
const viewerStructure=details=>details.evaluate(node=>{
|
||||
const describe=item=>`${item.localName}${item.className?'.'+item.className.trim().split(/\s+/).join('.'):''}`;
|
||||
const inner=node.querySelector(':scope > div');
|
||||
const flatten=item=>item.classList.contains('prompt-inspector')?[...item.children].map(describe):[describe(item)];
|
||||
return {outer:[...node.children].map(describe),inner:inner?[...inner.children].flatMap(flatten):null};
|
||||
});
|
||||
|
||||
test('viewer details hold provenance, raw records and downloads',async({page,request})=>{
|
||||
await page.setViewportSize({width:1600,height:1100});const reads=await patchInspection(page);
|
||||
await fullFixture(page,request);let details=await viewerDetails(page,'complete.png');
|
||||
// The panel content without the cover, then the two disclosures, then the pair send and history buttons (D20).
|
||||
expect(await viewerStructure(details)).toEqual({outer:['div','button.send','button'],
|
||||
inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','div.tags','dl.meta-grid','details','details']});
|
||||
await expect(details.locator('.detail-cover,.detail-inner,.detail-footer')).toHaveCount(0);
|
||||
await expect(details.locator('.panel-heading h2')).toHaveText(POSITIVE_TITLE);await expect(details.locator('.panel-heading .chip')).toHaveText('PNG');
|
||||
await expect(details.locator(':scope > div > p.small')).toHaveText('complete.png');
|
||||
await expect(details.locator('span.chip.blue')).toHaveText('Workflow inferred');
|
||||
await expect(details.locator(':scope > div > .tags').last().locator('span.chip')).toHaveText(['fixture','geometric']);
|
||||
await expect(details.locator('.meta-grid').first().locator('dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']);
|
||||
await expect(details.locator('summary')).toHaveText(['File & provenance','Workflow & raw metadata']);
|
||||
await expect(details.locator(':scope > button')).toHaveText(['Send to ComfyUI','Prompt history']);
|
||||
await expect(details.locator(':scope > button.send')).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${POSITIVE_TITLE}`);
|
||||
// File & provenance: the honest facts and the concept's "Prompt evidence" line; no per-fact source lines.
|
||||
const provenance=details.locator('details').first();await provenance.locator('summary').click();
|
||||
await expect(provenance.locator('p.small').first()).toHaveText('Current file at this source; historical bytes are not verified.');
|
||||
await expect(provenance.locator('dl.meta-grid dt')).toHaveText(['Source','Path','Size','Dimensions','Format','Media type','Modified']);
|
||||
// D38: the Path value stays text, with "Show in folder" right after it.
|
||||
await expect(provenance.locator('dl.meta-grid dd').nth(1)).toHaveText('inspector/complete.png Show in folder');
|
||||
await expect(provenance.getByText('Prompt evidence: workflow inferred',{exact:true})).toBeVisible();
|
||||
await expect(provenance.getByText('Saved association; not verified against current file',{exact:true})).toBeVisible();
|
||||
await expect(provenance.getByRole('link',{name:'Download original',exact:true})).toBeVisible();
|
||||
await expect(details).not.toContainText(/Configuration evidence|Embedded parameter evidence|prompt evidence|extension|Recipe tags/);
|
||||
// Workflow & raw metadata: the concept's focusable `pre`, then the downloads with their unavailable reasons.
|
||||
const raw=details.locator('details').last();await raw.locator('summary').click();
|
||||
await expect(raw.locator('pre[tabindex="0"] > code').first()).toBeVisible();
|
||||
for (const name of ['Download metadata JSON','Download workflow JSON']) await expect(raw.getByRole('button',{name,exact:true})).toBeVisible();
|
||||
// D21 wording for execution and embedded parameters; an unverified format names its extension without a dot.
|
||||
reads.patch=data=>{for (const role of ['positive','negative']) data.prompts[role].source='execution';};
|
||||
await page.keyboard.press('Escape');details=await viewerDetails(page,'explicit-empty.png');await details.getByText('File & provenance',{exact:true}).click();
|
||||
await expect(details.getByText('Prompt evidence: execution recorded',{exact:true})).toBeVisible();
|
||||
reads.patch=data=>{data.prompts={positive:{status:'resolved',text:'embedded positive',source:'embedded-parameters'},negative:{status:'resolved',text:'embedded negative',source:'embedded-parameters'}};data.file.format=null;};
|
||||
await page.keyboard.press('Escape');details=await viewerDetails(page,'ambiguous.png');await details.getByText('File & provenance',{exact:true}).click();
|
||||
await expect(details.getByText('Prompt evidence: embedded parameters',{exact:true})).toBeVisible();
|
||||
await expect(details.locator('.panel-heading .chip')).toHaveText('File extension PNG');
|
||||
await expect(details.locator('details dl.meta-grid dd').nth(4)).toHaveText('PNG extension');await expect(details).not.toContainText('.PNG');
|
||||
// No badge, no line; no resolved pair or link, no buttons after the details.
|
||||
reads.patch=null;await page.keyboard.press('Escape');details=await viewerDetails(page,'empty.png');
|
||||
expect((await viewerStructure(details)).outer).toEqual(['div']);
|
||||
await details.getByText('File & provenance',{exact:true}).click();await expect(details).not.toContainText('Prompt evidence');
|
||||
// The history button leaves the viewer for the prompt's history.
|
||||
await page.keyboard.press('Escape');details=await viewerDetails(page,'complete.png');
|
||||
await details.getByRole('button',{name:'Prompt history',exact:true}).click();await expect(page).toHaveURL(/#\/library\/\d+$/);
|
||||
await expect(page.locator('#viewer-dialog')).toHaveCount(0);
|
||||
});
|
||||
|
||||
// D38 Show in folder: a listed file's Path offers it; the stubbed opener records what the route resolved.
|
||||
test('Show in folder asks the server to open the listed file and announces each outcome',async({page,request})=>{
|
||||
await fullFixture(page,request);
|
||||
await expect(inspector(page).getByRole('button',{name:/in folder$/})).toHaveCount(0);
|
||||
const details=await viewerDetails(page,'complete.png');
|
||||
const provenance=details.locator('details').first();await provenance.locator('summary').click();
|
||||
const path=provenance.locator('dl.meta-grid > div').nth(1);await expect(path.locator('dt')).toHaveText('Path');
|
||||
const show=path.locator('dd > button');
|
||||
await expect(show).toHaveText('Show in folder');await expect(show).toHaveAccessibleName('Show complete.png in folder');
|
||||
const toast=page.locator('#workspace-toast');
|
||||
const [sent]=await Promise.all([page.waitForRequest(entry=>entry.url().endsWith('/prompt_manager/images/reveal')),show.click()]);
|
||||
expect(sent.method()).toBe('POST');
|
||||
// The route takes only a same-origin JSON request, as a real browser sends it.
|
||||
const sentHeaders=await sent.allHeaders();
|
||||
expect(sentHeaders.origin).toBe(new URL(page.url()).origin);expect(sentHeaders['content-type']).toBe('application/json');
|
||||
const body=sent.postDataJSON();
|
||||
expect(Object.keys(body).sort()).toEqual(['path','root_index','root_scope']);
|
||||
expect(body).toMatchObject({root_index:0,path:'inspector/complete.png'});expect(body.root_scope).toMatch(/^[0-9a-f]{64}$/);
|
||||
await expect(toast).toHaveText('Opened the folder on this computer.');
|
||||
const opened=async()=>(await (await request.get('/__test__/reveal')).json()).opened;
|
||||
expect(await opened()).toEqual(['output/inspector/complete.png']);
|
||||
for (const [outcome,message] of [['unavailable','No file manager is available on this computer.'],['fail','The folder could not be opened. Try again.']]) {
|
||||
await expect(await request.post('/__test__/reveal',{data:{outcome}})).toBeOK();
|
||||
await show.click();await expect(toast).toHaveText(message);
|
||||
}
|
||||
// A reveal already in progress answers 429 busy.
|
||||
await page.route('**/prompt_manager/images/reveal',route=>route.fulfill({status:429,contentType:'application/json',body:JSON.stringify({success:false,code:'busy',error:'The folder is already opening.'})}));
|
||||
await show.click();await expect(toast).toHaveText('The folder is already opening.');
|
||||
await page.unroute('**/prompt_manager/images/reveal');
|
||||
// A proxied request counts as remote, so the real route answers 403 not-local.
|
||||
await expect(await request.post('/__test__/reveal',{data:{outcome:'opened'}})).toBeOK();
|
||||
await page.route('**/prompt_manager/images/reveal',route=>route.continue({headers:{...route.request().headers(),'x-forwarded-for':'203.0.113.7'}}));
|
||||
await show.click();await expect(toast).toHaveText('Show in folder works only in a browser on the computer running ComfyUI.');
|
||||
expect(await opened()).toEqual(['output/inspector/complete.png']);
|
||||
});
|
||||
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');
|
||||
await expect(panel.getByRole('heading',{name:POSITIVE_TITLE,exact:true})).toBeVisible();await expect(panel.locator('.panel-heading .chip')).toHaveText('PNG');await expect(panel.locator('.detail-inner > p.small')).toHaveText('complete.png');
|
||||
expect(await panel.evaluate(node=>node.getBoundingClientRect().width)).toBe(310);
|
||||
expect(await panel.locator('.file-inspector-inner').evaluate(node=>getComputedStyle(node).padding)).toBe('17px');
|
||||
expect(await panel.locator('.detail-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);
|
||||
// The concept's prompt actions (`.prompt-actions button.icon-btn`) measure 31×31 in the mock.
|
||||
const copy=await panel.getByRole('button',{name:/^Copy positive prompt: /}).boundingBox();expect(copy.width).toBe(31);expect(copy.height).toBe(31);
|
||||
}
|
||||
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();
|
||||
await expect(panel.locator('.meta-grid dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);
|
||||
await expect(panel.locator('.meta-grid dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']);
|
||||
await selectFile(page,'empty.png');await expect(panel.locator('.panel-heading .chip')).toHaveText('PNG');await expect(panel.getByRole('heading',{name:'empty.png',exact:true})).toBeVisible();
|
||||
await expect(panel.getByRole('button',{name:/^Send negative prompt to ComfyUI: /})).toHaveCount(0);await expect(panel).toContainText('Not recorded in this file.');await expect(panel.getByRole('button',{name:'Prompt history',exact:true})).toHaveCount(0);
|
||||
await selectFile(page,'explicit-empty.png');await expect(panel.locator('.prompt-section.negative p.prompt-text')).toHaveText('No negative prompt (explicitly empty)');await expect(panel.getByRole('button',{name:/^Send prompt pair/})).toHaveCount(1);
|
||||
// Provenance lives in the viewer's full details (D20): one "Prompt evidence" line replaces the per-fact source lines.
|
||||
const details=await viewerDetails(page,'complete.png');
|
||||
await expect(details.locator('span.chip.blue')).toHaveText('Workflow inferred');await expect(details).toContainText('Recipe category: Inspection');
|
||||
await details.getByText('File & provenance',{exact:true}).click();
|
||||
await expect(details).toContainText('Current file at this source; historical bytes are not verified.');await expect(details).toContainText('Saved association; not verified against current file');
|
||||
await expect(details.getByText('Prompt evidence: workflow inferred',{exact:true})).toBeVisible();
|
||||
await expect(details).not.toContainText(/Configuration evidence|Embedded API configuration|Saved workflow configuration/);
|
||||
await expect(details.getByRole('button',{name:'Prompt history',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'});
|
||||
await details.getByText('Prompt evidence: workflow inferred',{exact:true}).scrollIntoViewIfNeeded();
|
||||
const path=testInfo.outputPath('prompt-evidence.png');await page.screenshot({path});await testInfo.attach('prompt-evidence',{path,contentType:'image/png'});
|
||||
}
|
||||
await expect(details).not.toContainText(/Run:|Original file unchanged|Execution recorded/);
|
||||
});
|
||||
|
||||
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 fullFixture(page,request);const panel=inspector(page);const positive=panel.locator('.prompt-section.positive .prompt-text');await expect(positive).toContainText('猫 😀');const text=await positive.textContent();expect(text.length).toBeGreaterThan(1000);
|
||||
await panel.locator('.detail-footer button.send').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.');
|
||||
@@ -144,14 +360,14 @@ test('full inspector preserves long role text and exact sends',async({page,reque
|
||||
});
|
||||
|
||||
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();
|
||||
await fullFixture(page,request);let panel=await viewerDetails(page,'complete.png');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.');
|
||||
await page.keyboard.press('Escape');panel=await viewerDetails(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 page.keyboard.press('Escape');panel=await viewerDetails(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}/);
|
||||
});
|
||||
@@ -159,9 +375,9 @@ test('full inspector downloads actual bounded evidence',async({page,request})=>{
|
||||
test('full inspector labels a limited workflow record without hiding prompt facts',async({page,request})=>{
|
||||
// The server marks a workflow over its 4 MiB read budget `limited`: no raw text, a limited graph.
|
||||
await page.route('**/images/inspect/inspector/complete.png?*',async route=>{const response=await route.fetch();const data=await response.json();data.metadata.record_formats.workflow='limited';delete data.metadata.raw.workflow;data.metadata.workflow_graph={status:'limited',value:null};await route.fulfill({response,json:data});});
|
||||
await fullFixture(page,request);const panel=inspector(page);
|
||||
await expect(panel.locator('.generation-facts dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']);
|
||||
await expect(panel.getByRole('button',{name:'Send prompt pair',exact:true})).toBeVisible();
|
||||
await fullFixture(page,request);const panel=await viewerDetails(page,'complete.png');
|
||||
await expect(panel.locator('.meta-grid').first().locator('dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']);
|
||||
await expect(panel.getByRole('button',{name:/^Send prompt pair to ComfyUI: /})).toBeVisible();
|
||||
await panel.getByText('Workflow & raw metadata',{exact:true}).click();
|
||||
await expect(panel.getByText('Record exceeds inspection limits; text is unavailable',{exact:true})).toBeVisible();await expect(panel.locator('pre code')).toHaveCount(1);
|
||||
const workflow=panel.getByRole('button',{name:'Download workflow JSON',exact:true});await expect(workflow).toHaveAttribute('aria-disabled','true');
|
||||
@@ -173,8 +389,8 @@ test('full inspector labels a limited workflow record without hiding prompt fact
|
||||
|
||||
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();
|
||||
await fullFixture(page,request);const panel=inspector(page);await expect(panel.locator('.detail-footer button.send')).toBeVisible();const before=reads;
|
||||
await openSelected(page);const preview=page.locator('dialog#viewer-dialog');await expect(preview.locator('.meta-grid dd').first()).toHaveText('9007199254740993');expect(reads).toBe(before);await preview.getByRole('button',{name:'Close image viewer',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');
|
||||
@@ -186,15 +402,32 @@ test('full inspector keeps long literal content reachable on mobile',async({page
|
||||
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');}
|
||||
await page.setViewportSize({width:390,height:844});await fullFixture(page,request);const panel=inspector(page);await expect(panel.locator('.prompt-section.positive p.prompt-text')).toContainText('fine geometry and light.');await panel.getByRole('button',{name:/^Copy positive prompt: /}).click();const fallback=page.getByRole('dialog',{name:'Copy prompt',exact:true});await expect(fallback.getByRole('textbox')).toHaveValue(/^Inspector portrait 猫 😀\nFull prompt context, fine geometry and light\./);await page.keyboard.press('Escape');await expect(fallback).toBeHidden();
|
||||
await expect(panel.locator('.meta-grid 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.locator('.detail-footer button.send');await footer.focus();await expect(footer).toBeFocused();}
|
||||
await page.evaluate(()=>{document.documentElement.style.zoom='1';});
|
||||
const details=await viewerDetails(page,'complete.png');await details.getByText('Workflow & raw metadata',{exact:true}).click();await expect(details.locator('pre code').filter({hasText:literal}).last()).toHaveText(literal);
|
||||
const summary=details.getByText('File & provenance',{exact:true});await summary.focus();await page.keyboard.press('Enter');await expect(details).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);
|
||||
await page.goto(`/prompt_manager/workspace#/library/${id}`);await selectFile(page,'complete.png');const panel=inspector(page);await expect(panel.getByRole('heading',{name:POSITIVE_TITLE,exact:true})).toBeVisible();await expect(panel.locator('.meta-grid dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);await expect(panel.locator('.detail-inner > .tags').last().locator('span.chip')).toHaveText(['fixture','geometric']);await expect(panel).not.toHaveAttribute('id','image-inspector');await openSelected(page);await expect(page.locator('#viewer-dialog .file-inspector')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('Prompt history to the shown route closes the viewer and re-renders it, as the concept go() does',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');await expect(inspector(page).getByRole('heading',{name:POSITIVE_TITLE,exact:true})).toBeVisible();
|
||||
await openSelected(page);const viewer=page.locator('dialog#viewer-dialog');await expect(viewer).toBeVisible();
|
||||
const heading=page.locator('#workspace-main h1');await heading.evaluate(node=>{node.dataset.beforeHistory='true';});
|
||||
await viewer.locator('#viewer-details').getByRole('button',{name:'Prompt history',exact:true}).click();
|
||||
await expect(page.locator('dialog#viewer-dialog[open]')).toHaveCount(0);await expect(page).toHaveURL(new RegExp(`#/library/${id}$`));
|
||||
await expect(page.locator('#workspace-main h1[data-before-history]')).toHaveCount(0);await expect(heading).toBeFocused();
|
||||
// The side panel's Prompt history does the same.
|
||||
await selectFile(page,'complete.png');await heading.evaluate(node=>{node.dataset.beforeHistory='true';});
|
||||
await inspector(page).getByRole('button',{name:'Prompt history',exact:true}).click();
|
||||
await expect(page.locator('#workspace-main h1[data-before-history]')).toHaveCount(0);await expect(heading).toBeFocused();
|
||||
});
|
||||
|
||||
test('full inspector labels container representations and releases action URLs',async({page,request})=>{
|
||||
@@ -226,12 +459,12 @@ test('full inspector labels container representations and releases action URLs',
|
||||
|
||||
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,'empty.png');await expect(panel.locator('.meta-grid dd')).toHaveText(Array(6).fill('Not recorded'));
|
||||
await page.locator('article[data-image-key="0:architecture/study-01.png"] .image-card-actions button:first-child').click();await expect(panel.locator('.meta-grid 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);
|
||||
for(const value of await panel.locator('.meta-grid 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');
|
||||
@@ -240,7 +473,7 @@ test('full inspector distinguishes missing, limited and legacy unknown generatio
|
||||
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);
|
||||
const legacy=page.locator('#legacy-facts');await expect(legacy.locator('.meta-grid').first().locator('dd')).toHaveText(Array(6).fill('Unresolved'));await expect(legacy).not.toContainText(explanation);await expect(legacy.locator('.meta-grid dd[aria-describedby]')).toHaveCount(0);
|
||||
await page.evaluate(()=>{window.legacyFactsView.dispose();window.legacyFactsScope.dispose();});
|
||||
});
|
||||
|
||||
@@ -248,49 +481,126 @@ 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();
|
||||
await expect(panel.locator('.prompt-section.positive p.prompt-text')).toContainText('Full prompt context, fine geometry and light.');
|
||||
const fullText=await panel.locator('.prompt-section.positive p.prompt-text').textContent();expect(fullText.length).toBeGreaterThan(1000);
|
||||
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})]) {
|
||||
for(const target of [panel.locator('.detail-footer button.send'),panel.getByRole('button',{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 expect(panel.locator('.prompt-section.positive p.prompt-text')).toHaveText(fullText);
|
||||
}
|
||||
await panel.getByRole('button',{name:'Open full preview',exact:true}).click();const viewer=page.getByRole('dialog',{name:'Image preview',exact:true});
|
||||
await openSelected(page);const viewer=page.locator('dialog#viewer-dialog');
|
||||
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()) {
|
||||
for(const target of await viewer.locator('#viewer-toolbar > :is(button,a),.dialog-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();
|
||||
// #249 D34: the viewer details' disclosure summaries and "Download original" keep 40px coarse targets as well.
|
||||
const rail=viewer.locator('#viewer-details');await rail.getByText('File & provenance',{exact:true}).click();await expect(rail.getByRole('link',{name:'Download original',exact:true})).toBeVisible();
|
||||
const details=await rail.locator(':is(summary,a)').evaluateAll(nodes=>nodes.map(node=>{const {width,height}=node.getBoundingClientRect();return {name:node.textContent,width,height};}));
|
||||
expect(details.map(target=>target.name)).toEqual(['File & provenance','Download original','Workflow & raw metadata']);
|
||||
for(const target of details){expect(target.width,target.name).toBeGreaterThanOrEqual(40);expect(target.height,target.name).toBeGreaterThanOrEqual(40);}
|
||||
});
|
||||
});
|
||||
|
||||
const mediaDialog=page=>page.getByRole('dialog',{name:'Image preview',exact:true});
|
||||
const mediaDialog=page=>page.locator('dialog#viewer-dialog');
|
||||
const mediaStage=page=>mediaDialog(page).getByRole('region',{name:'Media preview',exact:true});
|
||||
const position=page=>mediaDialog(page).locator('.image-preview-position');
|
||||
const position=page=>mediaDialog(page).locator('.media-position');
|
||||
async function openCollection(page) {
|
||||
await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();
|
||||
await openSelected(page);
|
||||
await expect(mediaDialog(page).locator('.viewer-canvas img')).toBeVisible();
|
||||
await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
|
||||
}
|
||||
|
||||
// #249 C6: the concept viewer shell (h84–103, a889–926, s1008–1058, ≤700 s1456–1474), measured by one function in the
|
||||
// concept and in production. #viewer-stage and #viewer-details content is not part of the shell (C8 owns the details).
|
||||
const VIEWER_SHELL=()=>{
|
||||
const dialog=document.querySelector('dialog#viewer-dialog');
|
||||
const describe=node=>`${node.tagName.toLowerCase()}${node.id?`#${node.id}`:''}${[...node.classList].sort().map(value=>`.${value}`).join('')}`;
|
||||
const paths=[];
|
||||
const walk=(node,prefix)=>{for(const child of node.children){const path=`${prefix} > ${describe(child)}`;paths.push(path);if(!child.matches('#viewer-stage,#viewer-toolbar,#viewer-details'))walk(child,path);}};
|
||||
walk(dialog,describe(dialog));
|
||||
const box=node=>{const bounds=node.getBoundingClientRect();return [bounds.x,bounds.y,bounds.width,bounds.height].map(Math.round);};
|
||||
const toolbar=dialog.querySelector('#viewer-toolbar');
|
||||
const part=selector=>dialog.querySelector(selector);
|
||||
return {open:dialog.open,labelledBy:dialog.getAttribute('aria-labelledby'),paths,
|
||||
title:part('#viewer-title').textContent.trim(),subtitle:part('.dialog-header > div > p').textContent.trim(),
|
||||
close:{text:part('#viewer-close').textContent.trim(),label:part('#viewer-close').getAttribute('aria-label')},
|
||||
details:part('#viewer-details').getAttribute('aria-label'),
|
||||
toolbar:[...toolbar.children].map(node=>({tag:node.tagName.toLowerCase(),text:node.textContent.trim(),size:box(node).slice(2)})),
|
||||
geometry:Object.fromEntries(['dialog','.dialog-header','#viewer-title','#viewer-close','.viewer-layout','.viewer-left','#viewer-details']
|
||||
.map(selector=>[selector,box(selector==='dialog'?dialog:part(selector))])),
|
||||
toolbarBox:box(toolbar),stageBox:box(part('#viewer-stage'))};
|
||||
};
|
||||
|
||||
test('viewer matches the concept shell and toolbar order',async({page,request})=>{
|
||||
await seedConcept(request);
|
||||
for(const [width,height] of SIZES) {
|
||||
await page.setViewportSize({width,height});
|
||||
await openConcept(page,'images','dark');
|
||||
const card=page.locator('#image-grid > article.media-card').nth(1).locator('.image-open');
|
||||
const filename=(await card.getAttribute('aria-label')).replace(/^Open /,'');
|
||||
await card.click();await expect(page.locator('#viewer-dialog .viewer-canvas img')).toBeVisible();
|
||||
const concept=await page.evaluate(VIEWER_SHELL);
|
||||
expect(concept.toolbar.map(control=>control.text)).toEqual(['← Previous','−','+','Fit','Rotate','Flip','Play','Download','Next →']);
|
||||
await openProduction(page,'images','dark');
|
||||
const opener=page.locator(`article[data-image-key] .image-open[aria-label="Open ${filename}"]`);await opener.click();
|
||||
const viewer=page.locator('dialog#viewer-dialog');
|
||||
await expect(viewer.locator('.viewer-canvas img')).toBeVisible();await expect(viewer.locator('#viewer-title')).toHaveText(concept.title);
|
||||
await expect(viewer.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
|
||||
// The dialog is named by its title, as the concept's aria-labelledby does.
|
||||
await expect(page.getByRole('dialog',{name:concept.title,exact:true})).toBeVisible();
|
||||
const production=await page.evaluate(VIEWER_SHELL);
|
||||
// C8 rebuilds the details content; until then the shared file inspector marks the aside it renders into.
|
||||
production.paths=production.paths.map(path=>path.replace('aside#viewer-details.file-inspector','aside#viewer-details'));
|
||||
// #249 D34: after Next → production appends its own status lines (position, then the transform and slideshow
|
||||
// reasons), and Download stays the original-file link; every other control keeps the concept's text and order.
|
||||
const appended=production.toolbar.slice(9);
|
||||
expect(appended.map(control=>control.tag)).toEqual(['p','p','p']);
|
||||
await expect(viewer.locator('#viewer-toolbar > p[role="status"]')).toHaveText(new RegExp(`^\\d+ of \\d+ loaded: ${filename.replace(/[\\^$.*+?()[\]{}|]/g,'\\$&')}$`));
|
||||
expect(production.toolbar[7].tag).toBe('a');
|
||||
production.toolbar=production.toolbar.slice(0,9).map((control,index)=>index===7?{...control,tag:'button'}:control);
|
||||
// #249 D34: Viewer.js's own `.viewer-toolbar` rule makes the concept's toolbar absolute across the dialog's
|
||||
// bottom edge, over the details; production keeps it in `.viewer-left` under the stage.
|
||||
const [leftX,leftY,leftWidth,leftHeight]=production.geometry['.viewer-left'],[barX,barY,barWidth,barHeight]=production.toolbarBox;
|
||||
// Inside the left box's 1px border (boxes are rounded to whole pixels).
|
||||
for(const [actual,expected] of [[barX,leftX+1],[barWidth,leftWidth-2],[barY+barHeight,leftY+leftHeight-1],[production.stageBox[1]+production.stageBox[3],barY]])
|
||||
expect(Math.abs(actual-expected),`${width}x${height} toolbar inside .viewer-left`).toBeLessThanOrEqual(1);
|
||||
expect(concept.toolbarBox[2]).toBeGreaterThan(concept.geometry['.viewer-left'][2]);
|
||||
for(const shell of [concept,production]) {
|
||||
delete shell.toolbarBox;delete shell.stageBox;
|
||||
// At 700px and below the details and the stacked layout grow with the details content, which C8 rebuilds.
|
||||
if(width<=700) for(const selector of ['#viewer-details','.viewer-layout']) shell.geometry[selector]=shell.geometry[selector].slice(0,3);
|
||||
}
|
||||
if(await page.evaluate(()=>matchMedia('(pointer: coarse)').matches)) {
|
||||
// #249 D34: coarse pointers keep production's 40px square targets, wider than the concept's ×, "−" and "+" (the title beside × narrows by the difference).
|
||||
for(const shell of [concept,production]) {shell.toolbar=shell.toolbar.map(({tag,text})=>({tag,text}));delete shell.geometry['#viewer-close'];shell.geometry['#viewer-title'].splice(2,1);}
|
||||
expect(production.close).toEqual(concept.close);
|
||||
}
|
||||
expect(production,`${width}x${height}`).toEqual(concept);
|
||||
await page.keyboard.press('Escape');await expect(viewer).toHaveCount(0);await expect(opener).toBeFocused();
|
||||
}
|
||||
});
|
||||
|
||||
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');
|
||||
// #249 C2: Source and Folder live in the concept's "Refine this view"; sort is the toolbar's.
|
||||
await page.getByRole('button',{name:'Filters',exact:true}).click();
|
||||
const refine=page.getByRole('dialog',{name:'Refine this view',exact:true});
|
||||
await refine.getByLabel('Source',{exact:true}).selectOption('0');await refine.getByLabel('Folder',{exact:true}).selectOption('inspector');
|
||||
await refine.getByRole('button',{name:'Apply filters',exact:true}).click();
|
||||
await page.getByRole('combobox',{name:'Sort results',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`);}
|
||||
await expect(page.locator('.section-caption')).toContainText('40 of');
|
||||
for(const n of [80,120,160]) {await more.click();await expect(page.locator('.section-caption')).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);
|
||||
@@ -298,32 +608,38 @@ test('collection viewer follows filtered loaded order beyond mounted cards',asyn
|
||||
// A grid page, not the sidebar's `limit=1` Images count.
|
||||
await page.route(url=>url.pathname.endsWith('/images/output') && url.searchParams.get('limit')!=='1',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 opener=page.locator(`article[data-image-key="0:${loaded[0].path}"] .image-open`);
|
||||
await opener.click();release();await expect(page.locator('.section-caption')).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(modal.locator('#viewer-details > div > p.small')).toHaveText(loaded[159].filename);
|
||||
await expect(modal.locator('#viewer-toolbar').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();
|
||||
expect(pages).toBe(0);await modal.getByRole('button',{name:'Close image viewer',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');
|
||||
// #249 D44: Source and Folder are active.
|
||||
await page.getByRole('button',{name:'Filters · 2',exact:true}).click();
|
||||
await refine.getByLabel('Source',{exact:true}).selectOption('1');await refine.getByLabel('Folder',{exact:true}).selectOption('inspector');
|
||||
await refine.getByRole('button',{name:'Apply filters',exact:true}).click();
|
||||
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();
|
||||
await mediaDialog(page).getByRole('button',{name:'Close image viewer',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 page.goto(`/prompt_manager/workspace#/library/${id}`);await page.getByRole('button',{name:'Select negative Inspector recipe',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 mediaDialog(page).getByRole('button',{name:'Close image viewer',exact:true}).click();
|
||||
// #249 D26: the whole positive's images are in the All images dialog's own embedded gallery.
|
||||
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:');
|
||||
const listing=page.getByRole('dialog',{name:'All images for this positive',exact:true});
|
||||
await listing.locator('article[data-image-key="0:inspector/complete.png"] .image-card-actions button:first-child').click();
|
||||
await listing.locator('.media-card.selected .image-open').click();await expect(mediaDialog(page).locator('.viewer-canvas img')).toBeVisible();
|
||||
await expect(position(page)).toContainText('of 2 loaded:');
|
||||
});
|
||||
|
||||
test('collection viewer uses real public transforms and resets on navigation',async({page,request})=>{
|
||||
@@ -334,8 +650,8 @@ test('collection viewer uses real public transforms and resets on navigation',as
|
||||
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);
|
||||
await openCollection(page);const modal=mediaDialog(page),controls=modal.locator('#viewer-toolbar');
|
||||
await expect(modal.locator('.viewer-container')).toHaveCount(1);await expect(modal.locator('.viewer-container :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();
|
||||
@@ -375,10 +691,10 @@ async function slideshowFixture(page,request,preferences={videoAutoplay:false,vi
|
||||
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 expect(mediaDialog(page).locator('#viewer-details > div > p.small')).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 slideshowButton=page=>mediaDialog(page).locator('#viewer-toolbar').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) {
|
||||
@@ -407,7 +723,7 @@ test('slideshow exposes exact state labels and respects video playback',async({p
|
||||
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 modal.locator('#viewer-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);
|
||||
});
|
||||
|
||||
@@ -438,30 +754,32 @@ test('slideshow production query and route disposal cancel the active deadline',
|
||||
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;});
|
||||
const pendingAtReset=await page.evaluate(()=>{const sort=document.getElementById('sort');sort.value='name';sort.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 selectFile(page,'complete.png');await openSelected(page);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})=>{
|
||||
// A working clipboard keeps the copy action on the toast, so no "Copy prompt" fallback covers the viewer.
|
||||
await page.addInitScript(()=>Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async()=>{}}}));
|
||||
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(),
|
||||
()=>modal.locator('.prompt-section.positive p.prompt-text').click(),
|
||||
()=>modal.locator('#viewer-details').dispatchEvent('wheel',{deltaY:100}),
|
||||
()=>modal.getByRole('button',{name:/^Copy positive prompt: /}).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(),
|
||||
async()=>{const trigger=modal.getByRole('button',{name:/^Send prompt pair to ComfyUI: /});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.locator('#viewer-toolbar').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 button.click();await modal.getByRole('button',{name:'Close image viewer',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);
|
||||
}
|
||||
@@ -486,19 +804,20 @@ test('slideshow preference failures leave native playback usable',async({page,re
|
||||
});
|
||||
|
||||
test('full inspector attaches matched theme and mobile evidence with reachable content',async({page,request},testInfo)=>{
|
||||
test.setTimeout(120_000); // 32 evidence captures across four viewports, like the concept comparisons
|
||||
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');
|
||||
const modal=mediaDialog(page),rail=modal.locator('#viewer-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};
|
||||
return{frame:box(node),header:box(node.querySelector('.dialog-header')),stage:box(node.querySelector('#viewer-stage')),
|
||||
toolbar:box(node.querySelector('#viewer-toolbar')),rail:box(node.querySelector('#viewer-details')),
|
||||
pair:node.querySelector('#viewer-details > button.send')?box(node.querySelector('#viewer-details > button.send')):null};
|
||||
})});
|
||||
if(page.viewportSize().width===1600)expect(await rail.evaluate(node=>node.getBoundingClientRect().width)).toBe(340);
|
||||
else await expect(rail).toHaveCSS('overflow-y','visible');
|
||||
@@ -508,22 +827,22 @@ test('full inspector attaches matched theme and mobile evidence with reachable c
|
||||
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:'Theme',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 selectFile(page,'complete.png');await expect(inspector(page).locator('.meta-grid dd').first()).toHaveText('9007199254740993');
|
||||
if(width===1600)await page.evaluate(()=>scrollTo(0,0));else await inspector(page).locator('.detail-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 openCollection(page);const modal=mediaDialog(page),rail=modal.locator('#viewer-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 expect(rail.locator('.prompt-section.negative')).toContainText('blurred edges, watermark');await rail.locator('.meta-grid').first().scrollIntoViewIfNeeded();await capture(`facts-${label}`,true);
|
||||
await rail.getByText('File & provenance',{exact:true}).click();await rail.locator('details .meta-grid').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();
|
||||
const send=rail.getByRole('button',{name:/^Send prompt pair to ComfyUI: /});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 image viewer',exact:true}).click();await selectFile(page,'clip.webm');await openSelected(page);
|
||||
await expect.poll(()=>modal.locator('video').evaluate(node=>node.readyState)).toBeGreaterThanOrEqual(1);await capture(`video-${label}`,true);await modal.getByRole('button',{name:'Close image viewer',exact:true}).click();
|
||||
await selectFile(page,'control-heavy.png');await openSelected(page);
|
||||
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();
|
||||
await modal.getByRole('button',{name:'Close image viewer',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'});
|
||||
@@ -532,14 +851,14 @@ test('full inspector attaches matched theme and mobile evidence with reachable c
|
||||
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 openSelected(page);const modal=mediaDialog(page);
|
||||
await expect(modal).toContainText('Media could not be loaded. Retry this file.');await expect(modal.locator('.meta-grid dd').first()).toHaveText('9007199254740993');
|
||||
await expect(modal.locator('.media-position')).toContainText('Media could not be loaded.');
|
||||
const download=modal.locator('#viewer-toolbar').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.poll(()=>mediaStage(page).locator(':scope > 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);
|
||||
@@ -547,38 +866,39 @@ test('collection viewer preserves original download and rooted retries',async({p
|
||||
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 modal.getByRole('button',{name:'Close image viewer',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 page.locator('article[data-image-key="0:inspector/explicit-empty.png"] .image-open').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');
|
||||
await expect(mediaDialog(page).locator('#viewer-details > div > p.small')).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});
|
||||
const send=modal.getByRole('button',{name:/^Send prompt pair to ComfyUI: /});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);
|
||||
const text=modal.locator('.prompt-section.positive p.prompt-text');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);
|
||||
// D19: focus returns to the opening image; the panel follows the file the viewer moved to.
|
||||
await page.keyboard.press('Escape');await expect(modal).toHaveCount(0);await expect(page.locator('article[data-image-key="0:inspector/complete.png"] .image-open')).toBeFocused();await expect(inspector(page).locator('.detail-inner > p.small')).toHaveText(key);
|
||||
await openSelected(page);await page.evaluate(()=>{location.hash='/overview';});
|
||||
await expect(page.locator('h1')).toHaveText('Overview');await expect(page.locator('.viewer-container,#viewer-dialog')).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');
|
||||
const modal=mediaDialog(page),body=modal.locator('.viewer-layout'),rail=modal.locator('#viewer-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('theme');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};});
|
||||
const geometry=await modal.evaluate(node=>{const style=getComputedStyle(node),box=node.getBoundingClientRect(),rail=node.querySelector('#viewer-details').getBoundingClientRect();return{width:box.width,height:box.height,padding:style.padding,radius:style.borderRadius,rail:rail.width,gap:getComputedStyle(node.querySelector('.viewer-layout')).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`)});
|
||||
}
|
||||
@@ -594,30 +914,30 @@ test('collection viewer matches approved responsive frame',async({page,request},
|
||||
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 expect(rail).toHaveCSS('overflow-y','visible');expect(await body.evaluate(node=>{const style=getComputedStyle(node);return [style.display,style.flexDirection];})).toEqual(['flex','column']);
|
||||
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 rail.getByRole('button',{name:/^Send prompt pair to ComfyUI: /}).focus();await expect(rail.getByRole('button',{name:/^Send prompt pair to ComfyUI: /})).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();
|
||||
const sendPair=rail.getByRole('button',{name:/^Send prompt pair to ComfyUI: /});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};});
|
||||
const footer=await sendPair.evaluate(node=>{const box=node.getBoundingClientRect(),parent=node.closest('#viewer-details').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);
|
||||
await modal.getByRole('button',{name:'Close image viewer',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.locator(`article[data-image-key="${long.root_index}:${long.path}"] .image-card-actions button:first-child`).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();
|
||||
await expect(modal.getByRole('button',{name:'Close image viewer',exact:true})).toBeInViewport();
|
||||
});
|
||||
|
||||
async function currentStageFit(page) {
|
||||
return page.evaluate(()=>{
|
||||
const instance=window.fitInstances.at(-1),stage=document.querySelector('.image-preview-stage');
|
||||
const instance=window.fitInstances.at(-1),stage=document.querySelector('#viewer-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},
|
||||
@@ -655,7 +975,7 @@ async function openStageFitFixture(page,request) {
|
||||
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();
|
||||
await expect(mediaDialog(page).locator('.media-reason')).toBeHidden();
|
||||
return release;
|
||||
}
|
||||
async function attachStageFit(page,testInfo,name) {
|
||||
@@ -679,7 +999,7 @@ test('collection viewer refits a late wrapped title without replacing current me
|
||||
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(modal.locator('#viewer-title')).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);
|
||||
@@ -687,12 +1007,12 @@ test('collection viewer refits a late wrapped title without replacing current me
|
||||
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))));
|
||||
await expect(modal.locator('#viewer-details > div > p.small')).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 modal.getByRole('button',{name:'Close image viewer',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([]);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import { expect } from './fixtures.mjs';
|
||||
|
||||
// Content, row counts, and image bytes come from independent synthetic datasets.
|
||||
// Saved choices and actual executions are separate production sections. The concept's
|
||||
@@ -10,14 +10,13 @@ import { expect } from '../../tools/vendor/node_modules/@playwright/test/index.m
|
||||
export const canonicalDifferences = {
|
||||
data: ['Independent synthetic text, image bytes, IDs, counts and text wrapping'],
|
||||
semantics: [
|
||||
'Saved combinations are distinct from the additional chronological executions',
|
||||
'Negative variants are saved combinations; Recorded runs list the chronological executions',
|
||||
'Recorded run exposes exact frozen evidence; Repeat run is illustrative',
|
||||
'Current-file candidates disclose unknown identity and context membership',
|
||||
],
|
||||
// Closed by #249: the gallery's checkbox is Images-only (#259) and the role blocks are the concept's (PR D, F); the
|
||||
// exact history comparison is tests/browser/history-concept.spec.mjs.
|
||||
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',
|
||||
],
|
||||
};
|
||||
@@ -34,29 +33,27 @@ export async function captureCanonicalState(page, testInfo, name, selector) {
|
||||
await testInfo.attach(name, { path, contentType: 'image/png' });
|
||||
}
|
||||
|
||||
const CONCEPT_HISTORY_SELECTORS = Object.freeze({
|
||||
surface: 'article.surface', timeline: '.timeline', positive: '.positive-lane', branch: '.variant-lane',
|
||||
label: '.variant-lane > span', dot: '.variant-dot[aria-pressed="true"]', role: 'article.surface .prompt-section',
|
||||
positiveLabel: '.positive-lane span',
|
||||
prompt: 'article.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:not([hidden])',
|
||||
});
|
||||
const RECORDED_HISTORY_SELECTORS = Object.freeze({
|
||||
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',
|
||||
});
|
||||
|
||||
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',
|
||||
};
|
||||
// Production renders the concept's history classes (#249 PR F), so both documents use the concept's selectors;
|
||||
// production adds the recorded executions (#209), which the concept does not have.
|
||||
const selectors = production ? { ...CONCEPT_HISTORY_SELECTORS, ...RECORDED_HISTORY_SELECTORS } : CONCEPT_HISTORY_SELECTORS;
|
||||
return page.evaluate(selectors => ({ pointerCoarse: matchMedia('(pointer: coarse)').matches,
|
||||
...Object.fromEntries(Object.entries(selectors).map(([key, selector]) => {
|
||||
const nodes = [...document.querySelectorAll(selector)];
|
||||
@@ -106,8 +103,8 @@ export async function exerciseCurrentPairSend(page, 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 viewer = page.locator('dialog#viewer-dialog');
|
||||
const send = viewer.getByRole('button', { name: /^Send prompt pair to ComfyUI: / });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' });
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact} from './concept-inventory.mjs';
|
||||
|
||||
// Task F5 of #249: the production Prompt history (#/library/<id>) against the immutable concept's (#prompt/alpine,
|
||||
// app.js renderHistory a587–622, the dialogs a1282–1304, style.css 1059–1179 and 1546–1567), on the concept-shaped real
|
||||
// fixture: Alpine observatory with its three saved negatives, four images linked to their pairs, four recorded runs and
|
||||
// real pair run counts (2, 1, 1). Heading, summary, the negative variants panel, the comparison and the shared gallery's
|
||||
// heading are compared at 1600x1100 and 390x844 in both themes and motion settings, with the page's behaviors in both
|
||||
// documents. Both documents are measured with "Clean architecture" selected: it is the middle lane in either order
|
||||
// (D46). The topbar and sidebar are the shell spec's, the gallery's cards the Images spec's (D47). Paired captures are
|
||||
// artifacts only; there are no PNG baselines.
|
||||
test.use({locale:'en-US',timezoneId:'UTC'});
|
||||
|
||||
const TITLE='Alpine observatory';
|
||||
const SELECTED='Clean architecture';
|
||||
const LANES=3;
|
||||
const SHOW_ARCHIVED='Show archived (0)';
|
||||
const SECTIONS=['heading','history','noPageScroll'];
|
||||
|
||||
/**
|
||||
* Recorded history deviations only (spec §8, TESTING.md D24–D26, D35, D45–D47); everything else must equal production.
|
||||
* `reference.oracle` is the concept page itself, measured once its lanes list newest first with the singular for one
|
||||
* (D46, D35) and once the approved Show archived button (#235) follows + Add negative: the browser lays out what moves.
|
||||
*/
|
||||
export function mapHistoryConcept(reference) {
|
||||
const {history}=reference.oracle;
|
||||
return {...reference,history:{...history,
|
||||
// D24: "N tracked runs" is the recorded window's `recorded_run_count`; the fixture records one run per image, so
|
||||
// the concept's "4 tracked runs" holds by construction (asserted on both documents below).
|
||||
summary:history.summary,
|
||||
variants:{...history.variants,
|
||||
// D45: the bounded "N of M saved negative variants" count stays a visually hidden status.
|
||||
structure:{...history.variants.structure,spoken:[{role:'status',text:`${LANES} of ${LANES} saved negative variants`}]}},
|
||||
compare:history.compare&&{...history.compare,grid:{...history.compare.grid,items:history.compare.grid.items.map((item,index)=>({...item,
|
||||
// D46: the two compared blocks are named Baseline and Selected, so no action name repeats on the page.
|
||||
sections:item.sections.map(section=>({...section,actions:{...section.actions,buttons:section.actions.buttons.map(button=>({...button,
|
||||
label:button.label.replace(/^(Copy|Send) negative/,`$1 ${COMPARED[index]} negative`)}))}}))}))}},
|
||||
// D47: the gallery's body is the Images collection (layout row, cards, side panel), so only its place, frame and
|
||||
// heading are compared here; the cards are the Images comparison's.
|
||||
gallery:history.gallery&&withoutHeight(history.gallery),
|
||||
}};
|
||||
}
|
||||
const COMPARED=['baseline','selected'];
|
||||
const withoutHeight=({height,...gallery})=>gallery;
|
||||
|
||||
function compare(reference,actual) {
|
||||
const only=inventory=>Object.fromEntries(SECTIONS.map(key=>[key,key==='history'&&inventory.history?.gallery
|
||||
?{...inventory.history,gallery:withoutHeight(inventory.history.gallery)}:inventory[key]]));
|
||||
return diffInventory(stripExact(only(mapHistoryConcept(reference))),stripExact(only(actual)));
|
||||
}
|
||||
|
||||
/** The comparison as a gate: throws, naming every differing path, unless production equals the mapped concept. */
|
||||
function assertHistoryMatch(reference,actual) {
|
||||
const diff=compare(reference,actual);
|
||||
if(diff.length)throw new Error(`the Prompt history differs from the concept at ${diff.map(item=>item.path).join(', ')}`);
|
||||
}
|
||||
|
||||
// Two frames, then every running transition finished.
|
||||
const settle=page=>page.evaluate(async()=>{
|
||||
await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));
|
||||
await Promise.race([Promise.all(document.getAnimations().map(animation=>animation.finished.catch(()=>{}))),
|
||||
new Promise(resolve=>setTimeout(resolve,2000))]);
|
||||
});
|
||||
const decoded=page=>page.evaluate(async()=>{
|
||||
await document.fonts.ready;
|
||||
await Promise.all([...document.querySelectorAll('.history-gallery img')].map(img=>{img.loading='eager';return img.decode().catch(()=>{});}));
|
||||
});
|
||||
|
||||
const dot=(page,name)=>page.getByRole('button',{name:`Select negative ${name}`,exact:true});
|
||||
const compareButton=page=>page.getByRole('button',{name:'Compare negatives',exact:true});
|
||||
const galleryCards=page=>page.locator('.history-gallery .media-card');
|
||||
|
||||
/** Press a variant dot and wait until the summary and the gallery follow it. */
|
||||
async function selectVariant(page,name) {
|
||||
await dot(page,name).click();
|
||||
await expect(dot(page,name)).toHaveAttribute('aria-pressed','true');
|
||||
await expect(page.locator('.history-gallery .panel-heading .small')).toHaveText(`${name} · same positive prompt`);
|
||||
await expect(galleryCards(page).first()).toBeVisible();
|
||||
await page.mouse.move(0,0);await decoded(page);await settle(page);
|
||||
}
|
||||
async function setCompare(page,pressed) {
|
||||
const button=compareButton(page);
|
||||
if(await button.getAttribute('aria-pressed')!==String(pressed))await button.click();
|
||||
await expect(compareButton(page)).toHaveAttribute('aria-pressed',String(pressed));
|
||||
await page.mouse.move(0,0);await settle(page);
|
||||
}
|
||||
|
||||
async function openConceptHistory(page,theme,motion) {
|
||||
await openConcept(page,'prompt/alpine',theme,motion);
|
||||
await expect(page.locator('.timeline .variant-lane')).toHaveCount(LANES);
|
||||
await selectVariant(page,SELECTED);
|
||||
}
|
||||
async function openProductionHistory(page,theme,motion,positiveId) {
|
||||
await openProduction(page,`library/${positiveId}`,theme,motion);
|
||||
await expect(page.locator('.timeline .variant-lane')).toHaveCount(LANES);
|
||||
await expect(page.locator('article.surface .card-meta > span').first()).toBeVisible();
|
||||
await selectVariant(page,SELECTED);
|
||||
}
|
||||
|
||||
// D45: the recorded production rules that lay the panel out (workspace.css): the panel heading and the legend row wrap,
|
||||
// All images wraps its label, and a lane's text wraps before a shown repeat-run dot. Applied to the concept so the browser
|
||||
// lays out what follows; at 1600px nothing wraps.
|
||||
const D45_LAYOUT=`.timeline-panel > .panel-heading, .timeline-lower { flex-wrap: wrap; }
|
||||
.variant-lane:has(> .run-dot:not([hidden])) > span { max-inline-size: 139px; overflow-wrap: anywhere; }
|
||||
.ca-tools .timeline-lower > button { max-inline-size: 100%; white-space: normal; }`;
|
||||
|
||||
/**
|
||||
* The concept laid out as production lists it: lanes newest first with their offsets (D46), "1 run" in the singular
|
||||
* (D35), the approved Show archived button after + Add negative (#235) and D45's layout rules. Returns the undo.
|
||||
*/
|
||||
async function conceptAsRecorded(page) {
|
||||
const restoreLayout=await mutate(page,{css:D45_LAYOUT});
|
||||
await page.evaluate(label=>{
|
||||
const timeline=document.querySelector('.timeline'),lanes=[...timeline.querySelectorAll(':scope > .variant-lane')];
|
||||
const texts=lanes.map(lane=>[lane.querySelector(':scope > span'),lane.querySelector(':scope > span').textContent]);
|
||||
lanes.reverse().forEach((lane,index)=>{lane.style.setProperty('--lane-offset',`${38+index*47}px`);timeline.append(lane);});
|
||||
for(const [span,text] of texts)span.textContent=text.replace(/(^|\D)1 runs$/,(match,before)=>`${before}1 run`);
|
||||
const add=[...document.querySelectorAll('.timeline-panel .row-actions > button')].find(node=>node.textContent==='+ Add negative');
|
||||
const archived=add.cloneNode(false);archived.removeAttribute('data-action');archived.removeAttribute('data-prompt');
|
||||
archived.setAttribute('aria-pressed','false');archived.textContent=label;add.after(archived);
|
||||
window.__historyOracle={timeline,order:[...lanes].reverse(),texts,archived};
|
||||
},SHOW_ARCHIVED);
|
||||
await settle(page);
|
||||
return ()=>page.evaluate(()=>{
|
||||
const {timeline,order,texts,archived}=window.__historyOracle;
|
||||
order.forEach((lane,index)=>{lane.style.setProperty('--lane-offset',`${38+index*47}px`);timeline.append(lane);});
|
||||
for(const [span,text] of texts)span.textContent=text;
|
||||
archived.remove();delete window.__historyOracle;
|
||||
}).then(restoreLayout);
|
||||
}
|
||||
|
||||
/** The concept inventory, with `oracle`: the same page laid out as recorded (`conceptAsRecorded`). */
|
||||
async function conceptInventory(page) {
|
||||
const reference=await inventoryOf(page,'#main');
|
||||
const restore=await conceptAsRecorded(page);
|
||||
const oracle=await inventoryOf(page,'#main');
|
||||
await restore();await settle(page);
|
||||
return {...reference,oracle};
|
||||
}
|
||||
|
||||
const negativeText=page=>page.locator('article.surface .prompt-section.negative .prompt-text').textContent();
|
||||
const galleryOf=async page=>({context:await page.locator('.history-gallery .panel-heading .small').textContent(),
|
||||
cards:await galleryCards(page).locator('.image-open').evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')))});
|
||||
|
||||
/** A dialog the page opens: its accessible name, how many media cards it shows once loaded and its captions; then closed. */
|
||||
async function dialogOf(page,trigger,{name,cards}) {
|
||||
await trigger.click();
|
||||
const dialog=page.getByRole('dialog',{name,exact:true});
|
||||
await expect(dialog).toBeVisible();
|
||||
if(cards)await expect(dialog.locator('.media-card')).toHaveCount(cards);
|
||||
// D47: neither document's media dialog carries a collection caption ("Linked images", "N loaded · …").
|
||||
const captions=await dialog.locator('.section-caption').count();
|
||||
const result=cards?{name,cards:await dialog.locator('.media-card').count(),captions}:{name,captions};
|
||||
await page.keyboard.press('Escape');await expect(dialog).toHaveCount(0);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** The behaviors Task F5 lists, with identical selectors in both documents. */
|
||||
async function behaviorsOf(page,{sent,runDialog}) {
|
||||
await selectVariant(page,'Quality baseline');
|
||||
const before={negative:await negativeText(page),gallery:await galleryOf(page)};
|
||||
await selectVariant(page,SELECTED);
|
||||
const after={negative:await negativeText(page),gallery:await galleryOf(page)};
|
||||
const pressed=[await compareButton(page).getAttribute('aria-pressed')];
|
||||
await setCompare(page,true);pressed.push(await compareButton(page).getAttribute('aria-pressed'));
|
||||
const compared=await page.locator('section.surface:not(.timeline-panel) > h2').textContent();
|
||||
await setCompare(page,false);pressed.push(await compareButton(page).getAttribute('aria-pressed'));
|
||||
const all=await dialogOf(page,page.getByRole('button',{name:'All images for this positive',exact:true}),{name:'All images for this positive',cards:4});
|
||||
const run=await dialogOf(page,page.getByRole('button',{name:'Show repeat run for Quality baseline',exact:true}),{name:runDialog});
|
||||
await page.locator('.heading-actions').getByRole('button',{name:`Send prompt pair to ComfyUI: ${TITLE}`,exact:true}).click();
|
||||
const send=await sent(page);
|
||||
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
|
||||
await expect(page.locator('h1#page-title')).toHaveText('Prompt library');
|
||||
return {variant:{before,after},pressed,compared,all,run,send,back:await page.locator('h1#page-title').textContent()};
|
||||
}
|
||||
|
||||
/** The concept's demo sender: the roles its preview lists, with their text. */
|
||||
async function conceptSent(page) {
|
||||
const modal=page.locator('dialog#modal[open]');
|
||||
await expect(modal.locator('#modal-title')).toHaveText('Send to ComfyUI');
|
||||
const roles=await modal.locator('.send-preview').evaluateAll(nodes=>Object.fromEntries(nodes.map(node=>
|
||||
[node.querySelector('h3').textContent.startsWith('+')?'positive':'negative',node.querySelector('p').textContent])));
|
||||
await page.keyboard.press('Escape');await expect(modal).toHaveCount(0);
|
||||
return roles;
|
||||
}
|
||||
/** Production's acknowledged sender against a bridge host page: the roles its preview asked the host to change. */
|
||||
function productionSent(host) {
|
||||
return async page=>{
|
||||
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
|
||||
await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'Concept ComfyUI'});
|
||||
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'Concept destination'});
|
||||
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
|
||||
await expect.poll(()=>host.evaluate(()=>window.previewed.length)).toBe(1);
|
||||
const roles=await host.evaluate(()=>window.previewed.pop());
|
||||
await page.keyboard.press('Escape');await expect(sender).toHaveCount(0);
|
||||
return roles;
|
||||
};
|
||||
}
|
||||
async function openHost(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-1',label:'Concept destination',type:'PromptManagerPair',roles:['positive','negative']};
|
||||
window.previewed=[];
|
||||
window.conceptHost=createBridgeHost({origin:location.origin,namespace:'/',label:'Concept ComfyUI',adapter:{
|
||||
listTargets:()=>[target],
|
||||
preview:(_id,roles)=>{window.previewed.push(roles);return {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=>({targetId:target.id,values:roles}),
|
||||
}});
|
||||
});
|
||||
return host;
|
||||
}
|
||||
|
||||
test.beforeEach(async({page,request},testInfo)=>{
|
||||
testInfo.manifest=await seedConcept(request);
|
||||
await page.addInitScript(()=>{window.copied=[];Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>{window.copied.push(text);}}});});
|
||||
});
|
||||
|
||||
for(const [width,height] of SIZES)for(const theme of THEMES)test(`history geometry, tokens, controls and behaviors match the concept ${width}x${height} ${theme}`,async({page,context},testInfo)=>{
|
||||
test.setTimeout(240_000);
|
||||
await page.setViewportSize({width,height});
|
||||
const positiveId=testInfo.manifest.prompts[0].positive_id;
|
||||
const host=await openHost(context);
|
||||
try {
|
||||
const result={};
|
||||
for(const motion of MOTIONS) {
|
||||
const capture=motion==='no-preference';
|
||||
await openConceptHistory(page,theme,motion);
|
||||
const reference=await conceptInventory(page);
|
||||
await setCompare(page,true);
|
||||
const referenceCompared=await conceptInventory(page);
|
||||
if(capture)await captureCanonicalState(page,testInfo,`concept-history-${width}-${theme}`);
|
||||
const referenceBehaviors=capture?await behaviorsOf(page,{sent:conceptSent,runDialog:'Repeat run'}):null;
|
||||
|
||||
await openProductionHistory(page,theme,motion,positiveId);
|
||||
const actual=await inventoryOf(page,'#workspace-main');
|
||||
await setCompare(page,true);
|
||||
const actualCompared=await inventoryOf(page,'#workspace-main');
|
||||
if(capture)await captureCanonicalState(page,testInfo,`production-history-${width}-${theme}`);
|
||||
const actualBehaviors=capture?await behaviorsOf(page,{sent:productionSent(host),runDialog:'Recorded run'}):null;
|
||||
result[motion]={reference,actual,diff:compare(reference,actual),referenceCompared,actualCompared,
|
||||
compareDiff:compare(referenceCompared,actualCompared),referenceBehaviors,actualBehaviors};
|
||||
}
|
||||
const path=testInfo.outputPath('history-geometry.json');await writeFile(path,JSON.stringify(result,null,2));
|
||||
await testInfo.attach('history-geometry',{path,contentType:'application/json'});
|
||||
for(const [motion,{diff,compareDiff,actual,referenceCompared}] of Object.entries(result)) {
|
||||
// The concept itself is what this comparison assumes (style.css 709–722, 1059–1179, 1426, 1546).
|
||||
const {summary,variants,compare:compared,gallery}=referenceCompared.history;
|
||||
expect.soft(summary,`${motion} concept surface`).toMatchObject({paddingTop:'22px',paddingLeft:'22px',borderTopLeftRadius:'7px',borderTopWidth:'1px'});
|
||||
// D24: the fixture's four recorded runs give the concept's own "4 tracked runs"; production reads them.
|
||||
expect.soft(summary.meta.items.map(item=>item.text),`${motion} concept counts`).toEqual(['4 tracked runs','4 images','3 negative variants']);
|
||||
expect.soft(actual.history.summary.meta.items.map(item=>item.text),`${motion} tracked runs`).toEqual(['4 tracked runs','4 images','3 negative variants']);
|
||||
expect.soft(variants.timeline,`${motion} concept timeline`).toMatchObject({paddingTop:'18px',paddingLeft:'20px',borderTopLeftRadius:'6px'});
|
||||
variants.timeline.lanes.forEach((lane,index)=>{
|
||||
expect.soft(lane,`${motion} concept lane ${index}`).toMatchObject({offset:`${38+index*47}px`,marginLeft:`${38+index*47}px`,height:57});
|
||||
expect.soft(lane.dot,`${motion} concept dot ${index}`).toMatchObject({width:'29px',height:'29px',text:'−'});
|
||||
});
|
||||
expect.soft(variants.timeline.lanes.map(lane=>lane.run&&{width:lane.run.width,left:lane.run.left}),`${motion} concept run dots`).toEqual([{width:'12px',left:'190px'},null,null]);
|
||||
expect.soft(compared.grid,`${motion} concept compare grid`).toMatchObject({columns:width>700?2:1,columnGap:'15px'});
|
||||
expect.soft(compared.added.map(chip=>chip.text),`${motion} concept added terms`).toEqual(['+ distorted architecture','+ watermark','+ text']);
|
||||
expect.soft(compared.added[0],`${motion} concept diff chip`).toMatchObject({fontSize:'11px',paddingTop:'3px',paddingLeft:'7px',borderTopLeftRadius:'4px',display:'inline-block'});
|
||||
expect.soft(gallery,`${motion} concept gallery`).toMatchObject({marginTop:'24px',heading:{h2:{text:'Shared gallery',fontSize:'14px'},note:{text:`${SELECTED} · same positive prompt`}}});
|
||||
expect.soft(diff,`${motion} differences after the recorded deviations`).toEqual([]);
|
||||
expect.soft(compareDiff,`${motion} differences with Compare negatives pressed`).toEqual([]);
|
||||
expect.soft(actual.noPageScroll,`${motion} no page-level horizontal scroll`).toBe(true);
|
||||
}
|
||||
const {referenceBehaviors:concept,actualBehaviors:production}=result['no-preference'];
|
||||
expect.soft(production,'behaviors').toEqual({...concept,run:{...concept.run,name:'Recorded run'}});
|
||||
} finally {await host.evaluate(()=>window.conceptHost.dispose());await host.close();}
|
||||
});
|
||||
|
||||
// The comparison is only worth something if it notices a change (spec §7 mutation check): one CSS mutation (the variant
|
||||
// dot's width) and one DOM mutation (Compare negatives removed), each applied after an exact match.
|
||||
test('history comparison detects mutations',async({page},testInfo)=>{
|
||||
await page.setViewportSize({width:1600,height:1100});
|
||||
await openConceptHistory(page,'dark','no-preference');
|
||||
const reference=await conceptInventory(page);
|
||||
await openProductionHistory(page,'dark','no-preference',testInfo.manifest.prompts[0].positive_id);
|
||||
const measure=()=>inventoryOf(page,'#workspace-main');
|
||||
assertHistoryMatch(reference,await measure());
|
||||
|
||||
const restoreCss=await mutate(page,{css:'.ca-tools .variant-dot { width: 20px }'});
|
||||
const cssMutated=await measure();
|
||||
expect(()=>assertHistoryMatch(reference,cssMutated)).toThrow(/history\.variants\.timeline\.lanes\.0\.dot\.width/);
|
||||
await restoreCss();await settle(page);
|
||||
assertHistoryMatch(reference,await measure());
|
||||
|
||||
await compareButton(page).evaluate(node=>node.remove());
|
||||
const domMutated=await measure();
|
||||
expect(()=>assertHistoryMatch(reference,domMutated)).toThrow(/history\.variants\.heading\.actions\.buttons\.0\.text/);
|
||||
});
|
||||
@@ -0,0 +1,562 @@
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact,countCopy,reflow,withoutItems} from './concept-inventory.mjs';
|
||||
|
||||
// Task C7 of #249: the production Images page against the immutable concept, on the concept-shaped real fixture (24
|
||||
// images of the concept's six prompts, its seeds, sizes and favorites). Heading, toolbar, filter row, caption, grid,
|
||||
// the first six cards, the continuation and the panel box are compared at 1600x1100 and 390x844 in both themes, and the
|
||||
// page's behaviors under normal and reduced motion; grid, cards and panel also at 1200x900 and 1800x1100 (2 and 4
|
||||
// columns, the 275px and 310px panel). The topbar and sidebar (D28, D29, D52, D53) are the shell spec's.
|
||||
// Paired captures are artifacts only; there are no PNG baselines.
|
||||
test.use({locale:'en-US',timezoneId:'UTC'});
|
||||
|
||||
// D37: the concept loads 12 images per batch (app.js imageBatchSize) and production one listing page (40), so both are
|
||||
// compared once the 24 fixture images are all loaded: the concept after its own Load more press.
|
||||
const FIXTURE_IMAGES=24;
|
||||
const CONCEPT_BATCH=12;
|
||||
const PRODUCTION_BATCH=40;
|
||||
// Load more in production needs more than one listing page: the fixture continues the concept's rounds (two pages).
|
||||
const LOAD_MORE_EXTRA=2*PRODUCTION_BATCH-FIXTURE_IMAGES;
|
||||
const CARDS='.gallery-body > .image-grid > .media-card';
|
||||
const WIDE_SIZES=Object.freeze([[1200,900],[1800,1100]]);
|
||||
|
||||
/** D17: "<Today|Yesterday|weekday> · <Month D>" of the newest file (the fixture's own mtime), in the test's UTC zone. */
|
||||
function captionDay(seconds,now=new Date()) {
|
||||
const day=value=>Date.UTC(value.getUTCFullYear(),value.getUTCMonth(),value.getUTCDate());
|
||||
const date=new Date(seconds*1000),days=Math.round((day(now)-day(date))/86400000);
|
||||
const name=days===0?'Today':days===1?'Yesterday':date.toLocaleDateString('en-US',{weekday:'long',timeZone:'UTC'});
|
||||
const options={month:'long',day:'numeric',timeZone:'UTC',...(date.getUTCFullYear()===now.getUTCFullYear()?{}:{year:'numeric'})};
|
||||
return `${name} · ${date.toLocaleDateString('en-US',options)}`;
|
||||
}
|
||||
|
||||
/** D8: the selection bar's real actions only; Add tags, Add to collection and Remove links… are left out. */
|
||||
const OMITTED_BULK=Object.freeze(['Add tags','Add to collection','Remove links…']);
|
||||
const OMITTED_BULK_SELECTORS=Object.freeze(['bulk-tags','bulk-collection','bulk-remove'].map(action=>`#bulkbar [data-action="${action}"]`));
|
||||
|
||||
/**
|
||||
* `withoutItems` for a row with padding and a border (the `.bulkbar`): its items flow again inside the content box,
|
||||
* measured from the border box as the inventory records them. The concept's border is one width on every side
|
||||
* (`border: 1px solid`), so the right border stands for the left one.
|
||||
*/
|
||||
function withoutPaddedItems(row,isRemoved) {
|
||||
const [left,right,top,bottom]=[['paddingLeft','borderRightWidth'],['paddingRight','borderRightWidth'],
|
||||
['paddingTop','borderTopWidth'],['paddingBottom','borderBottomWidth']].map(keys=>keys.reduce((sum,key)=>sum+parseFloat(row[key]),0));
|
||||
const shift=(item,x,y)=>item.left===undefined?item:{...item,left:item.left+x,top:item.top+y,
|
||||
exact:item.exact&&{...item.exact,left:item.exact.left+x}};
|
||||
const inner={...row,width:row.width-left-right,exact:{width:row.exact.width-left-right},items:row.items.map(item=>shift(item,-left,-top))};
|
||||
const flowed=withoutItems(inner,isRemoved);
|
||||
return {...flowed,width:row.width,exact:row.exact,height:flowed.height+top+bottom,items:flowed.items.map(item=>shift(item,left,top))};
|
||||
}
|
||||
|
||||
/** D8: the bar without the omitted actions, laid out again from the concept's boxes; what follows moves up by as much. */
|
||||
function mapBulkbar(bulkbar,rise) {
|
||||
if(!bulkbar)return {bulkbar,shrink:0};
|
||||
if(!bulkbar.bar)return {bulkbar:{...bulkbar,top:bulkbar.top-rise},shrink:0};
|
||||
const bar=withoutPaddedItems(bulkbar.bar,item=>OMITTED_BULK.includes(item.text));
|
||||
const shrink=bulkbar.bar.height-bar.height;
|
||||
return {bulkbar:{...bulkbar,top:bulkbar.top-rise,height:bulkbar.height-shrink,bar:{...bar,top:bar.top-rise}},shrink};
|
||||
}
|
||||
|
||||
/**
|
||||
* A list row's info with its actions `actionsWidth` wide, from the concept's boxes:
|
||||
* - D3 (the measurable list row, `.list-grid .media-info` in the overlay): the info fills the row beside the art and may
|
||||
* shrink below a long nowrap title (`min-width: 0`), so it is the card's inner width less the art, and the actions
|
||||
* end at its right padding; it keeps its own height (text or actions plus padding), centred in the row the concept
|
||||
* stretches it across, so the text, centred in either, moves only by half the lines it lost;
|
||||
* - the text column fits a subtitle the concept wrapped when its one-line width (`maxContent`) now fits.
|
||||
*/
|
||||
function listInfo(card,actionsWidth) {
|
||||
const {info}=card,{copy,p}=info;
|
||||
const width=card.width-2*parseFloat(card.borderTopWidth)-card.art.width;
|
||||
const column=width-parseFloat(info.paddingLeft)-parseFloat(info.paddingRight)-parseFloat(info.columnGap)-actionsWidth;
|
||||
const line=parseFloat(p.lineHeight);
|
||||
const pHeight=p.height>line&&p.maxContent<=column?line:p.height;
|
||||
const copyHeight=copy.height-(p.height-pHeight);
|
||||
const natural=Math.max(copyHeight,info.actions.height)+parseFloat(info.paddingTop)+parseFloat(info.paddingBottom);
|
||||
// The row's inner box is the stretched concept info: its top is the card border, its height the art's.
|
||||
return {info:{width,top:Math.round(info.top+(info.height-natural)/2),height:Math.round(natural)},
|
||||
copy:{height:copyHeight,top:Math.round(info.top+(info.height-copyHeight)/2)},p:{height:pHeight},
|
||||
actionsLeft:info.left+width-parseFloat(info.paddingRight)-actionsWidth};
|
||||
}
|
||||
|
||||
/** Recorded Images deviations only (spec §8); everything else in the concept inventory must equal production. */
|
||||
const nocase=(a,b)=>{const x=a.toLowerCase(),y=b.toLowerCase();return x<y?-1:x>y?1:a<b?-1:a>b?1:0;};
|
||||
|
||||
export function mapImagesConcept(reference,{manifest,now=new Date()}) {
|
||||
const paths=new Map(manifest.images.map(image=>[image.filename,image.path]));
|
||||
// D14: the sort appends production's two sorts. Save view is the concept's own (saved views, #249 D16 retired).
|
||||
const toolbar={...reference.toolbar};
|
||||
toolbar.items=toolbar.items.map(item=>item.id==='sort'?{...item,options:[...item.options,'Oldest first','Name Z–A']}:item);
|
||||
// D36: the category list is the Library's real list, sorted alphabetically (database/library.py `ORDER BY category
|
||||
// COLLATE NOCASE, category`); the concept lists its samples in prompt order (app.js 348). Only the order is mapped.
|
||||
toolbar.items=toolbar.items.map(item=>item.id==='category-filter'
|
||||
?{...item,options:[item.options[0],...item.options.slice(1).sort(nocase)]}:item);
|
||||
// D29: no "Illustrative previews" sample tag.
|
||||
const filterRow=withoutItems(reference.filterRow,item=>String(item.className).includes('sample-tag'));
|
||||
filterRow.items=filterRow.items.map(item=>{
|
||||
// D32: the layout buttons may wrap at extreme zoom (they never do while they fit).
|
||||
if(item.className==='segmented')return {...item,flexWrap:'wrap'};
|
||||
// D14: the scope chip may shrink and wrap a long source or folder name inside itself.
|
||||
if(item.tag==='span'&&item.className==='chip')return {...item,minWidth:'0px'};
|
||||
return item;
|
||||
});
|
||||
// A shorter toolbar or filter row moves what follows it up by as much.
|
||||
const toolbarRise=reference.toolbar.height-toolbar.height;
|
||||
filterRow.top-=toolbarRise;
|
||||
const rise=toolbarRise+reference.filterRow.height-filterRow.height;
|
||||
const list=reference.grid.className.split(' ').includes('list-grid');
|
||||
const cards=reference.cards.map(card=>{
|
||||
const filename=card.open.label.replace(/^Open /,'');
|
||||
const actions=card.info.actions;
|
||||
// D3: a list row's info keeps its own height (text or actions plus its padding) so bounded rendering can measure
|
||||
// it, centred in the row the concept stretches it across; the text and the actions keep their place.
|
||||
const listRow=list?listInfo(card,actions.width):{};
|
||||
return {...card,
|
||||
// D29: the concept's alt adds its demo-only " · sample illustration N" to the card title.
|
||||
img:{...card.img,alt:card.img.alt.replace(/ · sample illustration \d+$/,'')},
|
||||
info:{...card.info,...listRow.info,copy:{...card.info.copy,...listRow.copy},p:{...card.info.p,...listRow.p},
|
||||
// D10: the title keeps the file's relative path (the fixture manifest's) in `title`.
|
||||
h3:{...card.info.h3,title:paths.get(filename)??null},
|
||||
actions:{...actions,left:listRow.actionsLeft??actions.left}}};
|
||||
});
|
||||
// D17: the right-hand date is the newest result's day under Recently added, here the fixture's newest mtime.
|
||||
// D35: the count line uses the singular for one.
|
||||
const caption={...reference.caption,spans:reference.caption.spans.map((span,index)=>index===1
|
||||
?{...span,text:captionDay(manifest.newest_modified,now)}:{...span,text:countCopy(span.text)})};
|
||||
const continuation=mapContinuation(reference.continuation);
|
||||
const {bulkbar,shrink}=mapBulkbar(reference.bulkbar,rise);
|
||||
return {...reference,toolbar,filterRow,bulkbar,cards,caption,continuation,panel:{...reference.panel,top:reference.panel.top-rise-shrink}};
|
||||
}
|
||||
|
||||
/** D32: the continuation's text and Load more may wrap at extreme zoom; D35: its count uses the singular for one. */
|
||||
function mapContinuation(continuation,numbers=null) {
|
||||
return {...continuation,flexWrap:'wrap',items:continuation.items.map(item=>item.tag!=='p'?item:{...item,
|
||||
// D37: a row with more to load counts production's own page ("N of M"), not the concept's 12-image batch.
|
||||
text:countCopy(numbers?item.text.replace(/^\d+ of \d+/,`${numbers.loaded} of ${numbers.total}`):item.text)})};
|
||||
}
|
||||
|
||||
/** D37: the Load more row before its press, the concept's at 12 of 24 against production's at its own page. */
|
||||
function compareLoadingRow(reference,actual,count) {
|
||||
const [,loaded,total]=count.match(/^(\d+) of (\d+)/);
|
||||
// The row's place follows the grid above it, whose height is the batch's (D37).
|
||||
const unplaced=({top,...row})=>stripExact(row);
|
||||
return diffInventory(unplaced(mapContinuation(reference,{loaded,total})),unplaced(actual));
|
||||
}
|
||||
|
||||
/**
|
||||
* D15: the Import dialog's real operations, with the ruled intro and the Rescan wording. D55: the concept's own "Scan an
|
||||
* output folder → Review scan" row follows Rescan as "Scan for metadata", opening the concept's "Review metadata scan".
|
||||
*/
|
||||
function mapImportConcept(reference) {
|
||||
return {...reference,
|
||||
intro:{...reference.intro,text:'Image inspection and output scanning have different effects. Choose the workflow you want.'},
|
||||
rows:reference.rows.filter(row=>row.title!=='Import portable recipes').flatMap(row=>row.title==='Scan an output folder'
|
||||
?[{...row,description:'Find files added or removed in the output folders.',buttons:['Rescan']},{...row,title:'Scan for metadata'}]:[row])};
|
||||
}
|
||||
|
||||
const SECTIONS=['heading','toolbar','filterRow','bulkbar','caption','grid','cards','continuation','panel','noPageScroll'];
|
||||
const pick=inventory=>stripExact(inventory);
|
||||
|
||||
// The concept's own sample categories, in its first-appearance order (app.js 348): what D36 reorders.
|
||||
const CONCEPT_CATEGORIES=['All categories','Landscapes','Architecture','Environments','Illustration'];
|
||||
|
||||
function compare(reference,actual,manifest,sections=SECTIONS) {
|
||||
const only=inventory=>Object.fromEntries(sections.map(key=>[key,inventory[key]]));
|
||||
return diffInventory(pick(only(mapImagesConcept(reference,{manifest}))),pick(only(actual)));
|
||||
}
|
||||
|
||||
/** The comparison as a gate: throws, naming every differing path, unless production equals the mapped concept. */
|
||||
function assertImagesMatch(reference,actual,manifest) {
|
||||
const diff=compare(reference,actual,manifest);
|
||||
if(diff.length)throw new Error(`the Images page differs from the concept at ${diff.map(item=>item.path).join(', ')}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* D3 (masonry sub-pixel snapping): bounded rendering places masonry cards from the layout arithmetic (media width over
|
||||
* the file's ratio), while the concept's flow snaps each box to the browser's layout unit (1/64px in Chromium and
|
||||
* WebKit, 1/60px in Firefox) before measuring the next. So masonry tops and heights are compared unrounded instead,
|
||||
* within one layout unit per card above in the column (the card's own included); the rounded fields are skipped there.
|
||||
*/
|
||||
const MASONRY_ROUNDED=/^cards\.\d+\.(top|height)$/;
|
||||
function masonryDrift(reference,actual) {
|
||||
return reference.cards.flatMap((card,index)=>{
|
||||
const above=reference.cards.slice(0,index).filter(other=>Math.abs(other.exact.left-card.exact.left)<1).length;
|
||||
const bound=(above+1)/60+1e-6,other=actual.cards[index];
|
||||
return ['top','height'].filter(key=>!(Math.abs(card.exact[key]-other.exact[key])<=bound))
|
||||
.map(key=>({path:`cards.${index}.exact.${key}`,reference:card.exact[key],actual:other.exact[key],bound}));
|
||||
});
|
||||
}
|
||||
function compareLayout(layout,reference,mapped,actual) {
|
||||
const diff=diffInventory(stripExact({grid:mapped.grid,cards:mapped.cards}),stripExact(actual));
|
||||
return layout==='Masonry'?[...diff.filter(item=>!MASONRY_ROUNDED.test(item.path)),...masonryDrift(reference,actual)]:diff;
|
||||
}
|
||||
|
||||
// Two frames, then every running transition (a card image zooming back after the pointer left it) finished.
|
||||
const settle=page=>page.evaluate(async()=>{
|
||||
await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));
|
||||
await Promise.race([Promise.all(document.getAnimations().map(animation=>animation.finished.catch(()=>{}))),
|
||||
new Promise(resolve=>setTimeout(resolve,2000))]);
|
||||
});
|
||||
const countText=page=>page.locator('#result-count').textContent();
|
||||
const toastText=page=>page.locator('.toast').textContent();
|
||||
|
||||
/** The concept with every fixture image loaded through its own Load more button; returns what the press did. */
|
||||
async function openConceptLoaded(page,theme,motion) {
|
||||
await openConcept(page,'images',theme,motion);await page.mouse.move(0,0);await settle(page);
|
||||
const before=await page.locator(CARDS).count();
|
||||
const loadingRow=(await inventoryOf(page,'#main')).continuation;
|
||||
// A dispatched press: scrolling the button into view would let the concept's own observer load the batch first.
|
||||
// Where the first batch leaves the row in view (1800px, four columns), that observer has already loaded it.
|
||||
if(before<FIXTURE_IMAGES)await page.getByRole('button',{name:'Load more images',exact:true}).dispatchEvent('click');
|
||||
await expect(page.locator(CARDS)).toHaveCount(FIXTURE_IMAGES);
|
||||
const load={before,after:await page.locator(CARDS).count(),toast:await toastText(page),count:await countText(page),loadingRow};
|
||||
await page.evaluate(()=>{document.activeElement?.blur();window.scrollTo(0,0);});await settle(page);
|
||||
return load;
|
||||
}
|
||||
|
||||
async function openProductionLoaded(page,theme,motion,total=FIXTURE_IMAGES) {
|
||||
await openProduction(page,'images',theme,motion);await page.mouse.move(0,0);
|
||||
await expect(page.locator('#result-count')).toHaveText(new RegExp(`^${Math.min(total,PRODUCTION_BATCH)} of ${total} images`));
|
||||
await expect(page.locator(CARDS)).toHaveCount(Math.min(total,PRODUCTION_BATCH));
|
||||
await settle(page);
|
||||
}
|
||||
|
||||
/** The Import dialog's intro and operation rows (D15). */
|
||||
async function importDialogOf(page) {
|
||||
await page.locator('.heading-actions').getByRole('button',{name:'Import',exact:true}).click();
|
||||
const dialog=page.locator('dialog#modal[open]');
|
||||
await expect(dialog).toBeVisible();
|
||||
const inventory=await dialog.evaluate(node=>{
|
||||
const css=(element,...keys)=>{const style=getComputedStyle(element);return Object.fromEntries(keys.map(key=>[key,style[key]]));};
|
||||
const font=element=>css(element,'fontSize','fontWeight','lineHeight','color');
|
||||
const intro=node.querySelector('#modal-body > p.small');
|
||||
return {title:node.querySelector('#modal-title').textContent,
|
||||
intro:intro&&{text:intro.textContent,...font(intro)},
|
||||
...css(node.querySelector('.operation-list'),'display','rowGap'),
|
||||
rows:[...node.querySelectorAll('.operation-list > .operation')].map(row=>({title:row.querySelector('h3').textContent,
|
||||
description:row.querySelector('p').textContent,buttons:[...row.querySelectorAll('button')].map(button=>button.textContent.trim()),
|
||||
titleFont:font(row.querySelector('h3')),descriptionFont:font(row.querySelector('p')),...css(row,'display','alignItems')}))};
|
||||
});
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(dialog).toHaveCount(0);
|
||||
return inventory;
|
||||
}
|
||||
|
||||
/** The hovered first card image after its transition, then the pointer parked again. */
|
||||
async function hoverOf(page) {
|
||||
const open=page.locator(CARDS).first().locator('.image-open');
|
||||
await open.hover();
|
||||
// The concept's 0.2s transition (instant under reduced motion) has started and finished.
|
||||
await settle(page);
|
||||
const state=await open.locator('img').evaluate(img=>({hovered:img.closest('.image-open').matches(':hover'),
|
||||
transform:getComputedStyle(img).transform,transitionDuration:getComputedStyle(img).transitionDuration,
|
||||
transitionProperty:getComputedStyle(img).transitionProperty,hoverNone:matchMedia('(hover: none)').matches}));
|
||||
await page.mouse.move(0,0);
|
||||
return state;
|
||||
}
|
||||
|
||||
const activeOf=page=>page.evaluate(()=>{const node=document.activeElement;
|
||||
return {tag:node?.tagName.toLowerCase()??null,label:node?.getAttribute('aria-label')??null,
|
||||
card:node?.closest('.media-card')?[...node.closest('.image-grid').children].indexOf(node.closest('.media-card')):null};});
|
||||
const selectedOf=page=>page.locator(CARDS).evaluateAll(nodes=>nodes.flatMap((node,index)=>node.classList.contains('selected')?[index]:[]));
|
||||
|
||||
const LAYOUTS=['List','Masonry','Grid','Large','Compact'];
|
||||
/** The card boxes in each layout: Grid → List → Masonry → Grid, then the Large and Compact thumbnail sizes. */
|
||||
async function layoutsOf(page,scope) {
|
||||
const result={};
|
||||
for(const layout of LAYOUTS) {
|
||||
if(['Large','Compact'].includes(layout))await page.locator('#image-size').selectOption(layout.toLowerCase());
|
||||
else {
|
||||
await page.locator('.filter-row .segmented').getByRole('button',{name:layout,exact:true}).click();
|
||||
await expect(page.locator('.filter-row .segmented').getByRole('button',{name:layout,exact:true})).toHaveAttribute('aria-pressed','true');
|
||||
}
|
||||
await page.mouse.move(0,0);await settle(page);await settle(page);
|
||||
const inventory=await inventoryOf(page,scope);
|
||||
result[layout]={grid:inventory.grid,cards:inventory.cards};
|
||||
}
|
||||
await page.locator('#image-size').selectOption('comfortable');await settle(page);
|
||||
return result;
|
||||
}
|
||||
|
||||
const continuationText=page=>page.locator('.infinite-continuation > p').first().textContent();
|
||||
/** A search: the count line, the continuation and the cards it leaves, then the search cleared again. */
|
||||
async function searchOf(page,term) {
|
||||
const before=await countText(page);
|
||||
await page.locator('#search').fill(term);
|
||||
await expect.poll(()=>countText(page)).not.toBe(before);
|
||||
await expect.poll(()=>page.locator(CARDS).count()).toBeLessThan(FIXTURE_IMAGES);
|
||||
await settle(page);
|
||||
const result={count:await countText(page),continuation:await continuationText(page),cards:await page.locator(`${CARDS} .image-open`).evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')))};
|
||||
await page.locator('#search').fill('');
|
||||
await expect(page.locator('#result-count')).toHaveText(before);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** The behaviors §7 lists, run with identical selectors in both documents. */
|
||||
async function behaviorsOf(page,scope) {
|
||||
const hover=await hoverOf(page);
|
||||
const second=page.locator(CARDS).nth(1).locator('.image-open');
|
||||
const opened=await second.getAttribute('aria-label');
|
||||
await second.click();
|
||||
const viewer=page.locator('#viewer-dialog[open]');
|
||||
await expect(viewer).toBeVisible();
|
||||
const viewerTitle=await page.locator('#viewer-title').textContent();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(viewer).toHaveCount(0);
|
||||
await settle(page);
|
||||
const focusAfterViewer=await activeOf(page);
|
||||
await page.locator(CARDS).nth(2).locator('[aria-label^="Inspect"]').click();
|
||||
await expect.poll(()=>selectedOf(page)).toEqual([2]);
|
||||
const selected=await selectedOf(page);
|
||||
const layouts=await layoutsOf(page,scope);
|
||||
const search=await searchOf(page,'alpine');
|
||||
// Exactly one image (a unique filename stem): both counted nouns in the singular (D35).
|
||||
const single=await searchOf(page,'alpine_00003');
|
||||
return {hover,opened,viewerTitle,focusAfterViewer,selected,layouts,search,single};
|
||||
}
|
||||
|
||||
test.beforeEach(async({request},testInfo)=>{testInfo.manifest=await seedConcept(request);});
|
||||
|
||||
for(const [width,height] of SIZES)for(const theme of THEMES)test(`images page geometry, tokens, controls and behaviors match the concept ${width}x${height} ${theme}`,async({page,request},testInfo)=>{
|
||||
test.setTimeout(240_000);
|
||||
await page.setViewportSize({width,height});
|
||||
const manifest=testInfo.manifest;const result={};
|
||||
for(const motion of MOTIONS) {
|
||||
const capture=motion==='no-preference';
|
||||
const conceptLoad=await openConceptLoaded(page,theme,motion);
|
||||
const reference=await inventoryOf(page,'#main');
|
||||
if(capture)await captureCanonicalState(page,testInfo,`concept-images-${width}-${theme}`);
|
||||
// The browser as the re-layout's oracle: the concept page itself without the sample tag.
|
||||
const removed={};
|
||||
for(const [key,selector] of [['filterRow','.filter-row > .sample-tag']]) {
|
||||
const restore=await mutate(page,{remove:[selector]});
|
||||
removed[key]=(await inventoryOf(page,'#main'))[key];
|
||||
await restore();
|
||||
}
|
||||
const referenceImport=await importDialogOf(page);
|
||||
const referenceBehaviors=await behaviorsOf(page,'#main');
|
||||
|
||||
await openProductionLoaded(page,theme,motion);
|
||||
const actual=await inventoryOf(page,'#workspace-main');
|
||||
if(capture)await captureCanonicalState(page,testInfo,`production-images-${width}-${theme}`);
|
||||
const actualImport=await importDialogOf(page);
|
||||
const actualBehaviors=await behaviorsOf(page,'#workspace-main');
|
||||
result[motion]={conceptLoad,reference,removed,actual,diff:compare(reference,actual,manifest),referenceImport,actualImport,
|
||||
importDiff:diffInventory(mapImportConcept(referenceImport),actualImport),referenceBehaviors,actualBehaviors};
|
||||
}
|
||||
// Load more in production: a fixture past one listing page appends one batch and shows the concept's toast.
|
||||
testInfo.manifest=await seedConcept(request,{extra_images:LOAD_MORE_EXTRA});
|
||||
for(const motion of MOTIONS) {
|
||||
const total=FIXTURE_IMAGES+LOAD_MORE_EXTRA;
|
||||
await openProductionLoaded(page,theme,motion,total);
|
||||
const before=await page.locator(CARDS).count();
|
||||
const loadingRow=(await inventoryOf(page,'#workspace-main')).continuation,loadingCount=await countText(page);
|
||||
// A dispatched press, as on the concept side: a pointer click scrolls the row into view first, and the page's
|
||||
// own observer (rootMargin 250px) can append the final batch and hide the button before the click lands
|
||||
// (WebKit, run 39512528039). The button must still be the enabled one the user would press.
|
||||
const more=page.getByRole('button',{name:'Load more images',exact:true});
|
||||
await expect(more).toBeVisible();await expect(more).toHaveAttribute('aria-disabled','false');
|
||||
await more.dispatchEvent('click');
|
||||
await expect(page.locator('#result-count')).toHaveText(new RegExp(`^${total} of ${total} images`));
|
||||
result[motion].productionLoad={before,after:await page.locator(CARDS).count(),toast:await toastText(page),count:await countText(page)};
|
||||
result[motion].loadingDiff=compareLoadingRow(result[motion].conceptLoad.loadingRow,loadingRow,loadingCount);
|
||||
}
|
||||
const path=testInfo.outputPath('images-geometry.json');await writeFile(path,JSON.stringify(result,null,2));
|
||||
await testInfo.attach('images-geometry',{path,contentType:'application/json'});
|
||||
|
||||
for(const [motion,entry] of Object.entries(result)) {
|
||||
const {conceptLoad:{loadingRow,...conceptLoad},productionLoad,loadingDiff,reference,removed,actual,diff,referenceImport,importDiff,
|
||||
referenceBehaviors:concept,actualBehaviors:production}=entry;
|
||||
// The concept itself is what this comparison assumes: its first batch, its sample card and its copy.
|
||||
expect.soft(conceptLoad,`${motion} concept Load more`).toEqual({before:CONCEPT_BATCH,after:FIXTURE_IMAGES,
|
||||
toast:`${CONCEPT_BATCH} more images loaded. ${FIXTURE_IMAGES} of ${FIXTURE_IMAGES}.`,count:`${FIXTURE_IMAGES} of ${FIXTURE_IMAGES} images · 6 positive prompts`});
|
||||
// D37: production appends one listing page, with the concept's toast and count copy (D35 for one).
|
||||
expect.soft(productionLoad,`${motion} production Load more`).toEqual({before:PRODUCTION_BATCH,after:2*PRODUCTION_BATCH,
|
||||
toast:countCopy(`${PRODUCTION_BATCH} more images loaded. ${2*PRODUCTION_BATCH} of ${2*PRODUCTION_BATCH}.`),
|
||||
count:countCopy(`${2*PRODUCTION_BATCH} of ${2*PRODUCTION_BATCH} images · 6 positive prompts`)});
|
||||
// D37: the row with more to load, before its press, in both documents.
|
||||
expect.soft(loadingRow.items.map(item=>item.text),`${motion} concept loading row`).toEqual([`${CONCEPT_BATCH} of ${FIXTURE_IMAGES} images · more load as you scroll`,'Load more images']);
|
||||
expect.soft(loadingDiff,`${motion} loading row differences after D32, D35, D37`).toEqual([]);
|
||||
// D11: the concept's subtitle is the fixture's own "W × H · PNG · Seed N".
|
||||
const first=manifest.images[0];
|
||||
expect.soft(reference.cards[0].info.p.text,`${motion} concept subtitle`).toBe(`${first.width} × ${first.height} · PNG · Seed ${first.seed}`);
|
||||
expect.soft(reference.cards[0].info.h3.fontSize,`${motion} concept h3 size`).toBe('11px');
|
||||
expect.soft(referenceImport.rows.map(row=>row.title),`${motion} concept Import rows`).toEqual(['Inspect a local image','Scan an output folder','Import portable recipes']);
|
||||
// The row model reproduces the concept's own rows, and the concept page itself once the sample tag (D29) is
|
||||
// removed from it at runtime: the browser is the oracle for the remainders the mapping uses.
|
||||
expect.soft(diffInventory(stripExact(reflow(reference.toolbar,reference.toolbar.items)),stripExact(reference.toolbar)),`${motion} toolbar model`).toEqual([]);
|
||||
expect.soft(diffInventory(stripExact(reflow(reference.filterRow,reference.filterRow.items)),stripExact(reference.filterRow)),`${motion} filter row model`).toEqual([]);
|
||||
expect.soft(diffInventory(stripExact(withoutItems(reference.filterRow,item=>String(item.className).includes('sample-tag'))),stripExact(removed.filterRow)),
|
||||
`${motion} filter row without the sample tag`).toEqual([]);
|
||||
expect.soft(reference.toolbar.items.find(item=>item.id==='category-filter').options,`${motion} concept categories`).toEqual(CONCEPT_CATEGORIES);
|
||||
expect.soft(diff,`${motion} differences after the recorded deviations`).toEqual([]);
|
||||
expect.soft(importDiff,`${motion} Import dialog differences after D15`).toEqual([]);
|
||||
expect.soft(actual.noPageScroll,`${motion} no page-level horizontal scroll`).toBe(true);
|
||||
// Hover (C4): the concept's 1.03 zoom over 0.2s, instant under reduced motion; D18: none where there is no hover.
|
||||
expect.soft(concept.hover,`${motion} concept hover`).toMatchObject({hovered:true,transform:'matrix(1.03, 0, 0, 1.03, 0, 0)',
|
||||
transitionDuration:motion==='reduce'?'0s':'0.2s'});
|
||||
expect.soft(production.hover,`${motion} hover`).toEqual(production.hover.hoverNone?{...concept.hover,transform:'none'}:concept.hover);
|
||||
// The 2nd card's image opens the viewer titled "<title> · <filename>".
|
||||
expect.soft(concept.viewerTitle,`${motion} concept viewer title`).toBe(`${reference.cards[1].info.h3.text} · ${concept.opened.replace(/^Open /,'')}`);
|
||||
expect.soft(production.viewerTitle,`${motion} viewer title`).toBe(concept.viewerTitle);
|
||||
// Escape returns focus to the image that opened the viewer.
|
||||
expect.soft(production.focusAfterViewer,`${motion} focus after the viewer`).toEqual({tag:'button',label:concept.opened,card:1});
|
||||
// The concept re-renders its grid when the viewer closes (app.js 1737-1743), so its focus falls to the body;
|
||||
// §7 and D19 keep production's restore, which is what is asserted above.
|
||||
// Info on the 3rd card moves `.media-card.selected` there.
|
||||
expect.soft(production.selected,`${motion} selection after Info`).toEqual(concept.selected);
|
||||
// List (160×95 art) and Masonry (--media-ratio media) as the concept lays them out.
|
||||
expect.soft(concept.layouts.List.cards[0].img,`${motion} concept list art`).toMatchObject({width:160,height:95});
|
||||
for(const layout of LAYOUTS) {
|
||||
const mapped=mapImagesConcept({...reference,...concept.layouts[layout]},{manifest});
|
||||
expect.soft(compareLayout(layout,concept.layouts[layout],mapped,production.layouts[layout]),`${motion} ${layout} layout`).toEqual([]);
|
||||
}
|
||||
for(const card of concept.layouts.Masonry.cards) {
|
||||
const [w,h]=card.ratio.split('/').map(Number);
|
||||
expect.soft(Math.abs(card.img.width/card.img.height-w/h),`${motion} concept masonry ratio ${card.open.label}`).toBeLessThan(0.02);
|
||||
}
|
||||
// Searches leave the same images, count line and continuation in both documents (D35 for one).
|
||||
const mappedSearch=search=>({...search,count:countCopy(search.count),continuation:countCopy(search.continuation)});
|
||||
expect.soft(production.search,`${motion} search alpine`).toEqual(mappedSearch(concept.search));
|
||||
expect.soft(concept.single,`${motion} concept single result`).toMatchObject({count:'1 of 1 images · 1 positive prompts',cards:['Open alpine_00003.png']});
|
||||
expect.soft(production.single,`${motion} search for one image`).toEqual(mappedSearch(concept.single));
|
||||
expect.soft(production.single.count,`${motion} singular count line`).toBe('1 of 1 image · 1 positive prompt');
|
||||
}
|
||||
});
|
||||
|
||||
// D8 follow-up (image selection, Task 1): with the first card checked, the concept's bar (app.js 430-434) and the
|
||||
// checked card, against production's bar of real actions. The concept page itself, without the omitted actions, is the
|
||||
// oracle for the re-laid-out bar.
|
||||
for(const [width,height] of SIZES)for(const theme of THEMES)test(`images selection bar matches the concept ${width}x${height} ${theme}`,async({page},testInfo)=>{
|
||||
await page.setViewportSize({width,height});
|
||||
const manifest=testInfo.manifest;
|
||||
const check=async()=>{
|
||||
await page.locator(CARDS).first().locator('.image-overlays > label > input[type="checkbox"]').check();
|
||||
await expect(page.locator('#bulkbar > .bulkbar > strong')).toHaveText('1 selected');
|
||||
await page.evaluate(()=>document.activeElement?.blur());await page.mouse.move(0,0);await settle(page);
|
||||
};
|
||||
await openConceptLoaded(page,theme,'no-preference');
|
||||
const empty=await inventoryOf(page,'#main');
|
||||
await check();
|
||||
const reference=await inventoryOf(page,'#main');
|
||||
const restore=await mutate(page,{remove:[...OMITTED_BULK_SELECTORS]});
|
||||
const removed=(await inventoryOf(page,'#main')).bulkbar;
|
||||
await restore();
|
||||
await captureCanonicalState(page,testInfo,`concept-images-selected-${width}-${theme}`);
|
||||
await openProductionLoaded(page,theme,'no-preference');
|
||||
const emptyActual=await inventoryOf(page,'#workspace-main');
|
||||
await check();
|
||||
const actual=await inventoryOf(page,'#workspace-main');
|
||||
await captureCanonicalState(page,testInfo,`production-images-selected-${width}-${theme}`);
|
||||
// The concept itself: an empty bar, then "1 selected" and its six actions.
|
||||
expect.soft(empty.bulkbar,'concept empty bar').toMatchObject({id:'bulkbar',height:0,children:0,bar:null});
|
||||
expect.soft(reference.bulkbar.bar.items.map(item=>item.text),'concept bar').toEqual(['1 selected','Add tags','Set category',
|
||||
'Add to collection','Export metadata','Remove links…','Clear']);
|
||||
expect.soft(diffInventory(stripExact(mapBulkbar(reference.bulkbar,0).bulkbar.bar),stripExact(removed.bar)),'bar without the omitted actions').toEqual([]);
|
||||
expect.soft(actual.bulkbar.bar.items.map(item=>item.text),'production bar').toEqual(['1 selected','Set category','Export metadata','Clear']);
|
||||
expect.soft(compare(empty,emptyActual,manifest,['bulkbar','cards','panel']),'nothing checked').toEqual([]);
|
||||
expect.soft(compare(reference,actual,manifest,['bulkbar','cards','panel']),'one card checked').toEqual([]);
|
||||
// The bar comparison notices a change to the production bar.
|
||||
await mutate(page,{css:'.bulkbar { padding: 4px }'});
|
||||
expect(compare(reference,await inventoryOf(page,'#workspace-main'),manifest,['bulkbar']).map(item=>item.path)).toContain('bulkbar.bar.paddingTop');
|
||||
});
|
||||
|
||||
// The comparison is only worth something if it notices a change (spec §7 mutation check): one CSS mutation (the
|
||||
// `.media-info` padding) and one DOM mutation (the Inspect buttons removed), each applied after an exact match.
|
||||
test('images comparison detects mutations',async({page},testInfo)=>{
|
||||
await page.setViewportSize({width:1600,height:1100});
|
||||
const manifest=testInfo.manifest;
|
||||
await openConceptLoaded(page,'dark','no-preference');
|
||||
const reference=await inventoryOf(page,'#main');
|
||||
await openProductionLoaded(page,'dark','no-preference');
|
||||
const measure=()=>inventoryOf(page,'#workspace-main');
|
||||
assertImagesMatch(reference,await measure(),manifest);
|
||||
|
||||
const restoreCss=await mutate(page,{css:'.media-info { padding: 4px }'});
|
||||
const cssMutated=await measure();
|
||||
expect(()=>assertImagesMatch(reference,cssMutated,manifest)).toThrow(/cards\.0\.info\.paddingTop/);
|
||||
await restoreCss();await settle(page);
|
||||
assertImagesMatch(reference,await measure(),manifest);
|
||||
|
||||
await mutate(page,{remove:['[aria-label^="Inspect"]']});
|
||||
const domMutated=await measure();
|
||||
expect(()=>assertImagesMatch(reference,domMutated,manifest)).toThrow(/cards\.0\.info\.actions\.buttons\.length/);
|
||||
});
|
||||
|
||||
// Grid, cards and panel at the other concept breakpoints: 2 columns and the 275px panel at 1200, 4 columns and the
|
||||
// 310px panel at 1800.
|
||||
for(const [width,height] of WIDE_SIZES)for(const theme of THEMES)test(`images grid, cards and panel match the concept ${width}x${height} ${theme}`,async({page},testInfo)=>{
|
||||
await page.setViewportSize({width,height});
|
||||
const manifest=testInfo.manifest;
|
||||
await openConceptLoaded(page,theme,'no-preference');
|
||||
const reference=await inventoryOf(page,'#main');
|
||||
await captureCanonicalState(page,testInfo,`concept-images-${width}-${theme}`);
|
||||
await openProductionLoaded(page,theme,'no-preference');
|
||||
const actual=await inventoryOf(page,'#workspace-main');
|
||||
await captureCanonicalState(page,testInfo,`production-images-${width}-${theme}`);
|
||||
expect(reference.grid.columns,'concept columns').toBe(width<=1250?2:4);
|
||||
expect(reference.panel.box.width,'concept panel').toBe(width<=1250?275:310);
|
||||
expect(compare(reference,actual,manifest,['grid','cards','panel'])).toEqual([]);
|
||||
});
|
||||
|
||||
// D35 for the toast and the continuation: production's Load more appending exactly one image, against the concept's
|
||||
// own toast and row copy (app.js 489, 517-521) with production's numbers (D37).
|
||||
test('one appended image reads in the singular',async({page,request})=>{
|
||||
// A second fixture seed (dozens of PNGs and their thumbnails) runs inside the test.
|
||||
test.setTimeout(120_000);
|
||||
await page.setViewportSize({width:1600,height:1100});
|
||||
const total=PRODUCTION_BATCH+1;
|
||||
await seedConcept(request,{extra_images:total-FIXTURE_IMAGES});
|
||||
await openProductionLoaded(page,'dark','no-preference',total);
|
||||
await expect(page.locator('.infinite-continuation > p').first()).toHaveText(countCopy(`${PRODUCTION_BATCH} of ${total} images · more load as you scroll`));
|
||||
// A dispatched press: scrolling the button into view lets the page's own observer load the one image first.
|
||||
await page.getByRole('button',{name:'Load more images',exact:true}).dispatchEvent('click');
|
||||
await expect(page.locator('#result-count')).toHaveText(new RegExp(`^${total} of ${total} images`));
|
||||
await expect(page.locator('.toast')).toHaveText(countCopy(`1 more images loaded. ${total} of ${total}.`));
|
||||
expect(countCopy('1 more images loaded. 41 of 41.')).toBe('1 more image loaded. 41 of 41.');
|
||||
await expect(page.locator('.infinite-continuation > p').first()).toHaveText(`You’re all caught up · ${total} images`);
|
||||
});
|
||||
|
||||
// The same singular copy when the page's own observer loads the one image: the user only scrolls (no press).
|
||||
test('one image appended by scrolling reads in the singular',async({page,request})=>{
|
||||
test.setTimeout(120_000);
|
||||
await page.setViewportSize({width:1600,height:1100});
|
||||
const total=PRODUCTION_BATCH+1;
|
||||
await seedConcept(request,{extra_images:total-FIXTURE_IMAGES});
|
||||
await openProductionLoaded(page,'dark','no-preference',total);
|
||||
await expect(page.locator('#result-count')).toHaveText(new RegExp(`^${PRODUCTION_BATCH} of ${total} images`));
|
||||
await page.locator('.infinite-continuation').scrollIntoViewIfNeeded();
|
||||
await expect(page.locator('#result-count')).toHaveText(new RegExp(`^${total} of ${total} images`));
|
||||
await expect(page.locator('.toast')).toHaveText(countCopy(`1 more images loaded. ${total} of ${total}.`));
|
||||
await expect(page.locator('.infinite-continuation > p').first()).toHaveText(`You’re all caught up · ${total} images`);
|
||||
});
|
||||
|
||||
// D3 (one footer height for the collection): bounded rendering cannot measure unrendered cards, so every row reserves
|
||||
// the tallest measured footer. Two seedless files (D11 "W × H · PNG", one line: a short "96 × 72" beside Info and Tag)
|
||||
// form the first row at 390px, where every concept subtitle wraps: their row is as tall as the wrapped rows, and every
|
||||
// concept row moves down by one.
|
||||
test('one footer height for the collection (D3)',async({page,request})=>{
|
||||
// A second fixture seed (dozens of PNGs and their thumbnails) runs inside the test.
|
||||
test.setTimeout(120_000);
|
||||
await page.setViewportSize({width:390,height:844});
|
||||
await openConceptLoaded(page,'dark','no-preference');
|
||||
const reference=await inventoryOf(page,'#main');
|
||||
expect(reference.grid.columns).toBe(2);
|
||||
expect(reference.cards.every(card=>card.info.p.height>parseFloat(card.info.p.lineHeight))).toBe(true);
|
||||
const manifest=await seedConcept(request,{seedless_images:2,short_seedless:true});
|
||||
await openProductionLoaded(page,'dark','no-preference',FIXTURE_IMAGES+2);
|
||||
const actual=await inventoryOf(page,'#workspace-main');
|
||||
const box=card=>({left:card.left,top:card.top,width:card.width,height:card.height});
|
||||
const pitch=reference.cards[2].top-reference.cards[0].top;
|
||||
expect(actual.cards.map(box)).toEqual([box(reference.cards[0]),box(reference.cards[1]),
|
||||
...reference.cards.slice(0,4).map(card=>({...box(card),top:card.top+pitch}))]);
|
||||
const seedless=actual.cards.slice(0,2);
|
||||
expect(seedless.map(card=>card.open.label)).toEqual(manifest.seedless.map(image=>`Open ${image.filename}`));
|
||||
expect(seedless.map(card=>card.info.p.text)).toEqual(['96 × 72 · PNG','96 × 72 · PNG']);
|
||||
// Their own footer is one line shorter than the row it reserves.
|
||||
for(const card of seedless) {
|
||||
expect(card.info.p.height).toBeLessThanOrEqual(parseFloat(card.info.p.lineHeight)+0.5);
|
||||
expect(card.info.height).toBeLessThan(reference.cards[0].info.height);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,307 @@
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
import { captureCanonicalState } from './history-canonical-comparison.mjs';
|
||||
import { SIZES, THEMES, seedConcept, openConcept, openProduction, diffInventory, mutate } from './concept-inventory.mjs';
|
||||
import { parityPng } from './local-inspect-files.mjs';
|
||||
|
||||
// PR L2 Task 7 of the local-Inspect plan (#249): the native "Inspect a file" page against the immutable concept's
|
||||
// `#metadata` (docs/concepts/workspace/app.js renderMetadata 740–748, selection 1715–1729, style.css 709–722,
|
||||
// 1180–1194, 1543–1545, 1559–1564), at 1600x1100 and 390x844 in both themes, in three states: initial, selected (the
|
||||
// same synthetic PNG set into both `#local-file` inputs) and viewer-open. The concept is never edited: the recorded
|
||||
// differences D60–D66 (TESTING.md) are applied to its measurement, and, where they change copy, to its page before it
|
||||
// is measured, so the browser lays the concept out with production's words. After that the diff must be empty.
|
||||
// Paired captures are artifacts only; there are no PNG baselines.
|
||||
test.use({ locale: 'en-US', timezoneId: 'UTC', reducedMotion: 'reduce' });
|
||||
|
||||
const FILE = 'f02.png';
|
||||
/** D60 and D61, the maintainer's LI-3 copy, keyed by the concept's own copy. */
|
||||
const COPY = {
|
||||
action: ['Open sample metadata', 'Open file metadata'],
|
||||
intro: ['Keep the existing local-file inspection workflow. Only add the file to your library when you choose to import it.',
|
||||
'Read embedded metadata in this browser. The file is not uploaded or added to your library.'],
|
||||
chooser: ['Choose an image for a local preview', 'Choose an image or video for a local preview'],
|
||||
note: ['This mock previews your file locally. Sample metadata is available with “Open sample metadata”.',
|
||||
'Your file stays in this browser. Its metadata opens with “Open file metadata”.'],
|
||||
toast: ['Local preview only. No file uploaded; sample metadata is available above.',
|
||||
'Local preview only. No file uploaded; metadata is available above.'],
|
||||
};
|
||||
/** D62: the concept's image list plus the videos the local readers support. */
|
||||
const ACCEPT = ['image/png,image/jpeg,image/webp', 'image/png,image/jpeg,image/webp,video/*,.mp4,.mov,.m4v,.webm,.mkv,.avi,.wmv'];
|
||||
|
||||
test.beforeEach(async ({ page, browserName }) => {
|
||||
await startCoverage(page, browserName);
|
||||
page.conceptErrors = [];
|
||||
page.on('pageerror', error => page.conceptErrors.push(error.message));
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page, browserName }) => {
|
||||
await saveCoverage(page, browserName);
|
||||
expect(page.conceptErrors).toEqual([]);
|
||||
});
|
||||
|
||||
/** Runs in the page: the Inspect surface below the heading, and the heading's actions. Self-contained. */
|
||||
function measureInspect(scope) {
|
||||
const main = document.querySelector(scope);
|
||||
const css = (node, ...keys) => node ? Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]])) : null;
|
||||
const name = node => [node.localName, ...[...node.classList].sort()].join('.');
|
||||
const font = node => css(node, 'fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'color');
|
||||
const origin = main.querySelector(':scope > section.surface');
|
||||
const box = node => {
|
||||
if (!node || !origin) return null;
|
||||
const a = node.getBoundingClientRect(), b = origin.getBoundingClientRect();
|
||||
return { left: Math.round(a.left - b.left), top: Math.round(a.top - b.top), width: Math.round(a.width), height: Math.round(a.height) };
|
||||
};
|
||||
const text = node => node ? node.textContent.replace(/\s+/g, ' ').trim() : null;
|
||||
const heading = main.querySelector(':scope > .heading');
|
||||
const surface = origin;
|
||||
const drop = surface?.querySelector(':scope > .file-drop');
|
||||
const input = drop?.querySelector('input#local-file');
|
||||
const preview = surface?.querySelector(':scope > #local-preview');
|
||||
const media = preview?.querySelector('.local-file-preview');
|
||||
return {
|
||||
heading: {
|
||||
title: text(heading?.querySelector('h1')),
|
||||
description: text(heading?.querySelector('p')),
|
||||
actions: [...(heading?.querySelectorAll('.heading-actions > *') ?? [])].map(node => ({ name: name(node), text: text(node),
|
||||
...css(node, 'backgroundColor', 'color', 'fontSize', 'paddingTop', 'paddingLeft', 'borderTopLeftRadius'), box: box(node) })),
|
||||
},
|
||||
surface: surface && {
|
||||
children: [...surface.children].map(name), box: box(surface),
|
||||
...css(surface, 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'borderTopWidth', 'borderTopStyle',
|
||||
'borderTopColor', 'borderTopLeftRadius', 'backgroundColor'),
|
||||
h2: { text: text(surface.querySelector(':scope > h2')), ...font(surface.querySelector(':scope > h2')), box: box(surface.querySelector(':scope > h2')) },
|
||||
p: { text: text(surface.querySelector(':scope > p')), ...font(surface.querySelector(':scope > p')),
|
||||
...css(surface.querySelector(':scope > p'), 'marginTop'), box: box(surface.querySelector(':scope > p')) },
|
||||
},
|
||||
drop: drop && {
|
||||
children: [...drop.children].map(name), box: box(drop),
|
||||
...css(drop, 'display', 'placeItems', 'textAlign', 'minHeight', 'rowGap', 'columnGap', 'marginTop', 'paddingTop',
|
||||
'paddingRight', 'paddingBottom', 'paddingLeft', 'borderTopWidth', 'borderTopStyle', 'borderTopColor',
|
||||
'borderBottomStyle', 'borderLeftStyle', 'borderRightStyle', 'borderTopLeftRadius', 'backgroundColor'),
|
||||
icon: { box: box(drop.querySelector(':scope > svg.icon')), viewBox: drop.querySelector(':scope > svg.icon')?.getAttribute('viewBox'),
|
||||
d: drop.querySelector(':scope > svg.icon path')?.getAttribute('d') ?? null,
|
||||
...css(drop.querySelector(':scope > svg.icon'), 'color', 'strokeWidth') },
|
||||
label: { text: text(drop.querySelector(':scope > label')), for: drop.querySelector(':scope > label')?.htmlFor ?? null,
|
||||
...font(drop.querySelector(':scope > label')), box: box(drop.querySelector(':scope > label')) },
|
||||
input: input && { type: input.type, accept: input.accept, multiple: input.multiple, ...css(input, 'maxWidth', 'fontSize', 'color'),
|
||||
box: box(input) },
|
||||
note: { text: text(drop.querySelector(':scope > span.small')), ...font(drop.querySelector(':scope > span.small')),
|
||||
box: box(drop.querySelector(':scope > span.small')) },
|
||||
},
|
||||
preview: preview && {
|
||||
children: [...preview.children].map(name), box: box(preview),
|
||||
media: media && { tag: media.localName, alt: media.getAttribute('alt'), blob: media.src.startsWith('blob:'),
|
||||
...css(media, 'width', 'maxHeight', 'objectFit', 'marginTop', 'display'), box: box(media) },
|
||||
},
|
||||
// The chooser's displayed name: a file input shows the last path segment of its value.
|
||||
chosen: input ? { name: input.value.split(/[\\/]/).pop(), files: [...input.files].map(file => file.name) } : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Runs in the page: the open viewer's dialog box, its toolbar controls in order and its rail's sections in order. */
|
||||
function measureViewer() {
|
||||
const dialog = document.querySelector('dialog#viewer-dialog[open]');
|
||||
if (!dialog) return null;
|
||||
const css = (node, ...keys) => node ? Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]])) : null;
|
||||
const rect = node => { const b = node.getBoundingClientRect(); return { left: Math.round(b.left), top: Math.round(b.top), width: Math.round(b.width), height: Math.round(b.height) }; };
|
||||
const text = node => node.textContent.replace(/\s+/g, ' ').trim();
|
||||
const rail = dialog.querySelector('#viewer-details');
|
||||
// The rail's sections in document order, whatever wraps them.
|
||||
const SECTIONS = [
|
||||
['.panel-heading', () => 'heading'],
|
||||
['.panel-heading + p.small', () => 'filename'],
|
||||
['.tags', node => node.querySelector('button.chip') ? 'tag chips' : 'badges'],
|
||||
['section.prompt-section.positive', () => 'positive'],
|
||||
['section.prompt-section.negative', () => 'negative'],
|
||||
['dl.meta-grid', node => node.closest('details') ? null : 'facts'],
|
||||
['details > summary', node => text(node)],
|
||||
['button.send', node => node.closest('.prompt-section') ? `send ${node.closest('.prompt-section').matches('.positive') ? 'positive' : 'negative'}` : 'send pair'],
|
||||
['button', node => /Prompt history/.test(node.textContent) ? 'Prompt history' : null],
|
||||
];
|
||||
const sections = [...rail.querySelectorAll('*')].map(node => {
|
||||
for (const [selector, label] of SECTIONS) if (node.matches(selector)) return label(node);
|
||||
return null;
|
||||
}).filter(Boolean);
|
||||
return {
|
||||
dialog: rect(dialog),
|
||||
// The layout below the header; the header's height follows the title (D66) and the rail's height its content.
|
||||
layout: (() => {
|
||||
const header = dialog.querySelector('.dialog-header').getBoundingClientRect().bottom;
|
||||
const left = rect(dialog.querySelector('.viewer-left')), side = rect(rail);
|
||||
return { left: { ...left, top: Math.round(left.top - header) }, rail: { left: side.left, width: side.width },
|
||||
...css(dialog.querySelector('.viewer-layout'), 'display', 'gridTemplateColumns') };
|
||||
})(),
|
||||
title: text(dialog.querySelector('#viewer-title')),
|
||||
subtitle: text(dialog.querySelector('.dialog-header p')),
|
||||
toolbar: [...dialog.querySelectorAll('#viewer-toolbar > button, #viewer-toolbar > a')].map(text),
|
||||
railTitle: text(rail.querySelector('.panel-heading h2')),
|
||||
sections,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* D60–D62 applied to the open concept page before it is measured: production's action label, copy and accept list,
|
||||
* so the browser lays out what follows them. The concept file itself is never touched.
|
||||
*/
|
||||
async function withProductionCopy(page) {
|
||||
await page.evaluate(({ COPY, ACCEPT }) => {
|
||||
const main = document.querySelector('#main');
|
||||
const swap = (node, [from, to]) => { if (node.textContent !== from) throw new Error(`concept copy changed: ${node.textContent}`); node.textContent = to; };
|
||||
swap(main.querySelector('.heading-actions > button'), COPY.action);
|
||||
swap(main.querySelector('section.surface > p'), COPY.intro);
|
||||
swap(main.querySelector('.file-drop > label'), COPY.chooser);
|
||||
swap(main.querySelector('.file-drop > span.small'), COPY.note);
|
||||
const input = main.querySelector('#local-file');
|
||||
if (input.accept !== ACCEPT[0]) throw new Error(`concept accept changed: ${input.accept}`);
|
||||
input.accept = ACCEPT[1];
|
||||
}, { COPY, ACCEPT });
|
||||
}
|
||||
|
||||
/** D66: the viewer of a local file is titled by its filename and has no tag chips and no Prompt history. */
|
||||
function mapViewerConcept(reference) {
|
||||
return {
|
||||
...reference,
|
||||
title: FILE,
|
||||
railTitle: FILE,
|
||||
sections: reference.sections.filter(section => section !== 'tag chips' && section !== 'Prompt history'),
|
||||
};
|
||||
}
|
||||
|
||||
// Two frames, then every running transition finished.
|
||||
const settle = page => page.evaluate(async () => {
|
||||
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
await Promise.all(document.getAnimations().map(animation => animation.finished.catch(() => {})));
|
||||
});
|
||||
|
||||
async function conceptStates(page, theme, testInfo, tag) {
|
||||
await openConcept(page, 'metadata', theme);
|
||||
await page.mouse.move(0, 0);
|
||||
await withProductionCopy(page);
|
||||
await settle(page);
|
||||
const initial = await page.evaluate(measureInspect, '#main');
|
||||
await captureCanonicalState(page, testInfo, `concept-initial-${tag}`);
|
||||
await page.locator('#main #local-file').setInputFiles(parityPng(FILE));
|
||||
await expect(page.locator('#main #local-preview img.local-file-preview')).toBeVisible();
|
||||
await page.locator('#main #local-preview img').evaluate(image => image.decode().catch(() => {}));
|
||||
await expect(page.locator('#toast')).toHaveText(COPY.toast[0]);
|
||||
await settle(page);
|
||||
const selected = await page.evaluate(measureInspect, '#main');
|
||||
await captureCanonicalState(page, testInfo, `concept-selected-${tag}`);
|
||||
await page.locator('#main .heading-actions > button').click();
|
||||
await expect(page.locator('dialog#viewer-dialog[open] #viewer-details .panel-heading')).toBeVisible();
|
||||
await page.mouse.move(0, 0);
|
||||
await settle(page);
|
||||
const viewer = await page.evaluate(measureViewer);
|
||||
await captureCanonicalState(page, testInfo, `concept-viewer-${tag}`);
|
||||
return { initial, selected, viewer };
|
||||
}
|
||||
|
||||
async function productionStates(page, theme, testInfo, tag) {
|
||||
await openProduction(page, 'inspect', theme);
|
||||
await page.mouse.move(0, 0);
|
||||
const action = page.locator('#workspace-main .heading-actions').getByRole('button', { name: COPY.action[1], exact: true });
|
||||
// D60: unavailable, and described by the drop note, until a classified file is chosen.
|
||||
await expect(action).toHaveAttribute('aria-disabled', 'true');
|
||||
await expect(action).toHaveAttribute('aria-describedby', 'local-file-note');
|
||||
await settle(page);
|
||||
const initial = await page.evaluate(measureInspect, '#workspace-main');
|
||||
await captureCanonicalState(page, testInfo, `production-initial-${tag}`);
|
||||
await page.locator('#workspace-main #local-file').setInputFiles(parityPng(FILE));
|
||||
await expect(page.locator('#workspace-main #local-preview img.local-file-preview')).toBeVisible();
|
||||
await page.locator('#workspace-main #local-preview img').evaluate(image => image.decode().catch(() => {}));
|
||||
await expect(page.locator('#workspace-toast')).toHaveText(COPY.toast[1]);
|
||||
await expect(action).toHaveAttribute('aria-disabled', 'false');
|
||||
// D63: a readable PNG shows no status line.
|
||||
await expect(page.locator('#workspace-main #local-preview p.small')).toHaveCount(0);
|
||||
await settle(page);
|
||||
const selected = await page.evaluate(measureInspect, '#workspace-main');
|
||||
await captureCanonicalState(page, testInfo, `production-selected-${tag}`);
|
||||
await action.click();
|
||||
await expect(page.locator('dialog#viewer-dialog[open] #viewer-details .panel-heading')).toBeVisible();
|
||||
await expect(page.locator('dialog#viewer-dialog[open] #viewer-details .meta-grid').first()).toBeVisible();
|
||||
await page.mouse.move(0, 0);
|
||||
await settle(page);
|
||||
const viewer = await page.evaluate(measureViewer);
|
||||
await captureCanonicalState(page, testInfo, `production-viewer-${tag}`);
|
||||
return { initial, selected, viewer };
|
||||
}
|
||||
|
||||
/** The concept's fixed CSS values (style.css 709–722, 1180–1194, 1543–1545, 1559–1564), restated as the plan pins them. */
|
||||
function expectPinnedValues(state) {
|
||||
expect(state.surface).toMatchObject({ paddingTop: '22px', paddingLeft: '22px', borderTopLeftRadius: '7px', borderTopStyle: 'solid' });
|
||||
expect(state.surface.h2).toMatchObject({ fontSize: '17px', fontWeight: '600' });
|
||||
expect(state.surface.p).toMatchObject({ fontSize: '12px' });
|
||||
expect(state.drop).toMatchObject({ display: 'grid', textAlign: 'center', minHeight: '170px', rowGap: '12px', columnGap: '12px',
|
||||
marginTop: '20px', paddingTop: '22px', paddingLeft: '22px', borderTopStyle: 'dashed', borderBottomStyle: 'dashed',
|
||||
borderLeftStyle: 'dashed', borderRightStyle: 'dashed', borderTopWidth: '1px', borderTopLeftRadius: '7px' });
|
||||
expect(state.drop.input).toMatchObject({ maxWidth: '100%', fontSize: '12px' });
|
||||
}
|
||||
|
||||
for (const [width, height] of SIZES) for (const theme of THEMES) {
|
||||
test(`inspect surface matches the concept ${width}x${height} ${theme}`, async ({ page, request }, testInfo) => {
|
||||
await page.setViewportSize({ width, height });
|
||||
await seedConcept(request);
|
||||
const tag = `${theme}-${width}`;
|
||||
const concept = await conceptStates(page, theme, testInfo, tag);
|
||||
const production = await productionStates(page, theme, testInfo, tag);
|
||||
const mappedViewer = mapViewerConcept(concept.viewer);
|
||||
const diff = {
|
||||
initial: diffInventory(concept.initial, production.initial),
|
||||
selected: diffInventory(concept.selected, production.selected),
|
||||
viewer: diffInventory(mappedViewer, production.viewer),
|
||||
};
|
||||
const path = testInfo.outputPath('inspect-geometry.json');
|
||||
await writeFile(path, JSON.stringify({ concept, production, diff }, null, 2));
|
||||
await testInfo.attach('inspect-geometry', { path, contentType: 'application/json' });
|
||||
|
||||
// The concept is what this comparison assumes: its structure, its own copy (swapped above) and its chooser.
|
||||
expect(concept.initial.surface.children).toEqual(['h2', 'p', 'div.file-drop.spaced-20', 'div']);
|
||||
expect(concept.initial.drop.children).toEqual(['svg.icon', 'label', 'input', 'span.small']);
|
||||
expect(concept.selected.preview.children).toEqual(['img.local-file-preview']);
|
||||
expect(concept.selected.chosen).toEqual({ name: FILE, files: [FILE] });
|
||||
expect(concept.viewer.toolbar).toEqual(['← Previous', '−', '+', 'Fit', 'Rotate', 'Flip', 'Play', 'Download', 'Next →']);
|
||||
|
||||
expect(diff).toEqual({ initial: [], selected: [], viewer: [] });
|
||||
for (const state of [production.initial, production.selected]) expectPinnedValues(state);
|
||||
expect(production.selected.preview.media).toMatchObject({ tag: 'img', alt: 'Local file preview', blob: true,
|
||||
maxHeight: '400px', objectFit: 'contain', marginTop: '20px' });
|
||||
// The chooser names the chosen file after a real setInputFiles, as the concept's does.
|
||||
expect(production.initial.chosen).toEqual({ name: '', files: [] });
|
||||
expect(production.selected.chosen).toEqual({ name: FILE, files: [FILE] });
|
||||
// D66 in the rail: no tag chips and no Prompt history; the title is the filename.
|
||||
expect(production.viewer.sections).toEqual(['heading', 'filename', 'badges', 'positive', 'send positive', 'negative',
|
||||
'send negative', 'facts', 'File & provenance', 'Workflow & raw metadata', 'send pair']);
|
||||
});
|
||||
}
|
||||
|
||||
test('mutation is noticed', async ({ page, request }, testInfo) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
await seedConcept(request);
|
||||
await openConcept(page, 'metadata', 'dark');
|
||||
await withProductionCopy(page);
|
||||
const concept = await page.evaluate(measureInspect, '#main');
|
||||
await openProduction(page, 'inspect', 'dark');
|
||||
await page.mouse.move(0, 0);
|
||||
expect(diffInventory(concept, await page.evaluate(measureInspect, '#workspace-main'))).toEqual([]);
|
||||
|
||||
// A solid border on the drop area is noticed on every side and nowhere else.
|
||||
const undoCss = await mutate(page, { css: '#workspace-main .file-drop { border-style: solid; }' });
|
||||
expect(diffInventory(concept, await page.evaluate(measureInspect, '#workspace-main')).map(item => item.path))
|
||||
.toEqual(['drop.borderTopStyle', 'drop.borderBottomStyle', 'drop.borderLeftStyle', 'drop.borderRightStyle']);
|
||||
await undoCss();
|
||||
// A renamed label is noticed (its text; its box only if the new words lay out differently).
|
||||
const label = page.locator('#workspace-main .file-drop > label');
|
||||
await label.evaluate(node => { node.dataset.original = node.textContent; node.textContent = 'Choose a file'; });
|
||||
const renamed = diffInventory(concept, await page.evaluate(measureInspect, '#workspace-main')).map(item => item.path);
|
||||
expect(renamed).toContain('drop.label.text');
|
||||
expect(renamed.every(path => path.startsWith('drop.label.'))).toBe(true);
|
||||
await label.evaluate(node => { node.textContent = node.dataset.original; delete node.dataset.original; });
|
||||
// A removed note is noticed by its path.
|
||||
const undoDom = await mutate(page, { remove: ['#workspace-main .file-drop > span.small'] });
|
||||
expect(diffInventory(concept, await page.evaluate(measureInspect, '#workspace-main')).map(item => item.path))
|
||||
.toEqual(expect.arrayContaining(['drop.children.3', 'drop.note.text']));
|
||||
await undoDom();
|
||||
expect(diffInventory(concept, await page.evaluate(measureInspect, '#workspace-main'))).toEqual([]);
|
||||
await captureCanonicalState(page, testInfo, 'production-after-mutations');
|
||||
});
|
||||
@@ -0,0 +1,374 @@
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
import {SIZES,THEMES,seedConcept,openConcept,openProduction,inspectorOf,diffInventory,mutate,countCopy,saveImageTags} from './concept-inventory.mjs';
|
||||
|
||||
// Task D4 of #249: the Images side panel (app.js imageDetails a400–414) and the viewer's full details (imageDetails(i,
|
||||
// true)) against the immutable concept, on the concept-shaped real fixture, at 1600x1100 and 390x844 in both themes.
|
||||
// Both documents select the first listed image, alpine_00001.png (D19: the first load selects it). The fixture links it
|
||||
// to a recipe without a category or tags, so (D39, ruled 2026-10-21) its panel falls back to the positive's own category
|
||||
// and tags, as the concept's does. The panel's box in the gallery layout is the Images spec's. Paired captures are
|
||||
// artifacts only; there are no PNG baselines.
|
||||
test.use({locale:'en-US',timezoneId:'UTC'});
|
||||
|
||||
const CARDS='.gallery-body > .image-grid > .media-card';
|
||||
const TITLE='Alpine observatory';
|
||||
const FIXTURE_IMAGES=24;
|
||||
// The concept's sticky panel never scrolls; production's scrolls inside the window above 700px (D20/D21 side panel).
|
||||
const PANEL_INSET=40;
|
||||
// D34: coarse pointers keep 40px targets for the viewer details' disclosure summaries and "Download original" (the
|
||||
// concept's coarse rule covers buttons only). The recorded rule, applied to the concept under the same emulation so the
|
||||
// browser lays out what follows the taller summaries; a fine pointer never matches it.
|
||||
const D34_COARSE_TARGETS=`@media (pointer: coarse) {
|
||||
#viewer-details summary { box-sizing: border-box; min-block-size: 40px; padding-block: 10px; }
|
||||
#viewer-details a { display: inline-flex; align-items: center; min-block-size: 40px; }
|
||||
}`;
|
||||
// What an open "File & provenance" offers beyond the concept's text-only disclosure: D38's Show in folder right after
|
||||
// the Path value, then D20's Download original.
|
||||
const FILE_CONTROLS=[{tag:'button',text:'Show in folder',name:'Show alpine_00001.png in folder'},{tag:'a',text:'Download original',name:'Download original'}];
|
||||
|
||||
/** D22: production's confirmed prompt text is focusable, so it follows its block's actions among the controls. */
|
||||
function withFocusablePrompts(inventory) {
|
||||
const controls=[...inventory.controls];
|
||||
for(const section of inventory.sections) {
|
||||
const send=section.actions.buttons.at(-1);
|
||||
const index=controls.findIndex(control=>control.name===send.name);
|
||||
controls.splice(index+1,0,{tag:'p',text:section.text.text,name:section.text.text,pressed:null,primary:false,minHeight:'0px'});
|
||||
}
|
||||
return {...inventory,controls,sections:inventory.sections.map(section=>({...section,text:{...section.text,tabIndex:0}}))};
|
||||
}
|
||||
|
||||
/** D21: the category chip names its scope in a visually hidden prefix; here the linked positive's (D39 fallback). */
|
||||
function withCategoryScope(inventory) {
|
||||
const [badge,category,...rest]=inventory.badges.chips;
|
||||
return {...inventory,badges:{...inventory.badges,chips:[badge,{...category,hidden:'Positive prompt category: '},...rest]}};
|
||||
}
|
||||
|
||||
/**
|
||||
* Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34 and D38); everything else must equal
|
||||
* production. `reference.viewerPlain` is the concept's own full details
|
||||
* with its tag chips made plain (D20) and, under a coarse pointer, D34's targets, both at runtime: the browser lays out
|
||||
* what follows them, as the Images spec's removed toolbar items do.
|
||||
*/
|
||||
export function mapInspectorConcept(reference,{viewport}) {
|
||||
const {panel,viewerPlain,fileControls}=reference;
|
||||
return {
|
||||
panel:withCategoryScope(withFocusablePrompts({...panel,
|
||||
// D3: the production-only inspector rules (`.file-inspector`, workspace.css) hook on this class.
|
||||
className:`${panel.className} file-inspector`,
|
||||
// D20/D21 side panel: the sticky panel scrolls inside the window, so long real prompts keep the footer
|
||||
// reachable; at 700px and below, where the concept unsticks it, it grows instead.
|
||||
overflowX:'auto',overflowY:'auto',maxHeight:viewport.width>700?`${viewport.height-PANEL_INSET}px`:'none'})),
|
||||
// D3 as above; D20: the viewer's tag chips are plain (the concept's go to the Library).
|
||||
viewerDetails:withCategoryScope(withFocusablePrompts({...viewerPlain,className:'file-inspector'})),
|
||||
// D38 and D20: the open "File & provenance" offers Show in folder and Download original.
|
||||
fileControls:[...fileControls,...FILE_CONTROLS],
|
||||
};
|
||||
}
|
||||
|
||||
function compare(reference,actual,viewport) {
|
||||
return diffInventory(mapInspectorConcept(reference,{viewport}),actual);
|
||||
}
|
||||
|
||||
/** The comparison as a gate: throws, naming every differing path, unless production equals the mapped concept. */
|
||||
function assertInspectorMatch(reference,actual,viewport) {
|
||||
const diff=compare(reference,actual,viewport);
|
||||
if(diff.length)throw new Error(`the inspector differs from the concept at ${diff.map(item=>item.path).join(', ')}`);
|
||||
}
|
||||
|
||||
// Two frames, then every running transition finished.
|
||||
const settle=page=>page.evaluate(async()=>{
|
||||
await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));
|
||||
await Promise.race([Promise.all(document.getAnimations().map(animation=>animation.finished.catch(()=>{}))),
|
||||
new Promise(resolve=>setTimeout(resolve,2000))]);
|
||||
});
|
||||
const panelOf=page=>page.locator('#image-inspector');
|
||||
|
||||
/**
|
||||
* The concept with every fixture image loaded through its own Load more (it loads 12 per batch, D37), as production
|
||||
* has them: where the panel follows the grid (390px), reaching it would otherwise load the batch mid-comparison.
|
||||
*/
|
||||
async function openConceptPanel(page,theme) {
|
||||
await openConcept(page,'images',theme);await page.mouse.move(0,0);
|
||||
if(await page.locator(CARDS).count()<FIXTURE_IMAGES)await page.getByRole('button',{name:'Load more images',exact:true}).dispatchEvent('click');
|
||||
await expect(page.locator(CARDS)).toHaveCount(FIXTURE_IMAGES);
|
||||
await page.evaluate(()=>{document.activeElement?.blur();window.scrollTo(0,0);});
|
||||
await expect(panelOf(page).locator('.panel-heading h2')).toHaveText(TITLE);
|
||||
await settle(page);
|
||||
}
|
||||
async function openProductionPanel(page,theme) {
|
||||
await openProduction(page,'images',theme);await page.mouse.move(0,0);
|
||||
await expect(page.locator(CARDS).first()).toBeVisible();
|
||||
await expect(panelOf(page).locator('.panel-heading h2')).toHaveText(TITLE);
|
||||
await expect(panelOf(page).locator('.detail-footer')).toBeVisible();
|
||||
await expect(panelOf(page).locator('.detail-inner > .tags')).toHaveCount(2);
|
||||
await settle(page);
|
||||
}
|
||||
|
||||
/** The concept's tag chips in the open viewer as plain `span.chip`s (D20) and D34's coarse targets; returns an undo. */
|
||||
async function plainViewerChips(page) {
|
||||
const restoreTargets=await mutate(page,{css:D34_COARSE_TARGETS});
|
||||
await page.evaluate(()=>{
|
||||
window.__plainChips=[...document.querySelectorAll('#viewer-details .tags > button.chip')].map(button=>{
|
||||
const span=document.createElement('span');span.className='chip';span.textContent=button.textContent;
|
||||
button.replaceWith(span);return [span,button];
|
||||
});
|
||||
});
|
||||
await settle(page);
|
||||
return async()=>{
|
||||
await page.evaluate(()=>{for(const [span,button] of window.__plainChips)span.replaceWith(button);delete window.__plainChips;});
|
||||
await restoreTargets();
|
||||
};
|
||||
}
|
||||
|
||||
/** The controls an open "File & provenance" offers between its summary and the next disclosure's (m5). */
|
||||
async function fileControlsOf(page) {
|
||||
const disclosure=page.locator('#viewer-details details').first();
|
||||
await disclosure.evaluate(node=>{node.open=true;});await settle(page);
|
||||
const {controls}=(await inspectorOf(page)).viewerDetails;
|
||||
await disclosure.evaluate(node=>{node.open=false;});await settle(page);
|
||||
const names=controls.map(control=>control.name);
|
||||
return controls.slice(names.indexOf('File & provenance')+1,names.indexOf('Workflow & raw metadata')).map(({tag,text,name})=>({tag,text,name}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The first card's viewer: its full details and open "File & provenance" measured (in the concept also with plain
|
||||
* chips), then closed again. `mutation` (production) is applied once the viewer is open and undone before it closes.
|
||||
*/
|
||||
async function viewerOf(page,concept,mutation=null) {
|
||||
await page.locator(CARDS).first().locator('.image-open').click();
|
||||
await expect(page.locator('#viewer-dialog[open] #viewer-details .panel-heading h2')).toHaveText(TITLE);
|
||||
await settle(page);
|
||||
const restoreMutation=mutation?await mutate(page,mutation):null;
|
||||
if(mutation)await settle(page);
|
||||
const {viewerDetails}=await inspectorOf(page);
|
||||
const fileControls=await fileControlsOf(page);
|
||||
let viewerPlain=null;
|
||||
if(concept) {const restore=await plainViewerChips(page);viewerPlain=(await inspectorOf(page)).viewerDetails;await restore();}
|
||||
await restoreMutation?.();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('#viewer-dialog[open]')).toHaveCount(0);
|
||||
// The concept re-renders the Images page on the viewer's `close` event (app.js updateImages): let it land first.
|
||||
await page.evaluate(()=>new Promise(resolve=>setTimeout(resolve,0)));await settle(page);
|
||||
return {viewerDetails,viewerPlain,fileControls};
|
||||
}
|
||||
async function inventory(page,concept=false) {
|
||||
const {panel}=await inspectorOf(page);
|
||||
const {viewerDetails,viewerPlain,fileControls}=await viewerOf(page,concept);
|
||||
return concept?{panel,viewerDetails,viewerPlain,fileControls}:{panel,viewerDetails,fileControls};
|
||||
}
|
||||
|
||||
/** A ComfyUI host in a second tab that acknowledges what it applies (the shipped prompt bridge). */
|
||||
async function fakeHost(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');
|
||||
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}))},snapshot:roles}),
|
||||
apply:roles=>{Object.assign(window.hostValues,roles);window.applied.push(roles);return {targetId:target.id,values:roles};},
|
||||
}});
|
||||
});
|
||||
return host;
|
||||
}
|
||||
|
||||
/** Copy the positive: the concept's outcome is the toast, or its "Copy prompt" dialog where the clipboard is refused. */
|
||||
async function copyOf(page) {
|
||||
// An earlier toast (the concept's Load more) is not the copy's outcome.
|
||||
await panelOf(page).locator('.prompt-actions').first().scrollIntoViewIfNeeded();await settle(page);
|
||||
await page.evaluate(()=>document.querySelectorAll('.toast').forEach(toast=>{toast.textContent='';}));
|
||||
await panelOf(page).getByRole('button',{name:`Copy positive prompt: ${TITLE}`,exact:true}).click();
|
||||
const dialog=page.getByRole('dialog',{name:'Copy prompt',exact:true});
|
||||
await expect.poll(async()=>(await page.locator('.toast').textContent())||(await dialog.count()?'dialog':'')).not.toBe('');
|
||||
const result={toast:await page.locator('.toast').textContent(),dialog:await dialog.count()>0};
|
||||
if(result.dialog){await page.keyboard.press('Escape');await expect(dialog).toHaveCount(0);}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** The concept's send: its dialog previews both roles (app.js openSend). */
|
||||
async function conceptSendOf(page) {
|
||||
await panelOf(page).getByRole('button',{name:`Send prompt pair to ComfyUI: ${TITLE}`,exact:true}).click();
|
||||
const modal=page.locator('dialog#modal[open]');
|
||||
await expect(modal.locator('#modal-title')).toHaveText('Send to ComfyUI');
|
||||
const previews=await modal.locator('.send-preview').evaluateAll(nodes=>nodes.map(node=>[node.querySelector('h3').textContent,node.querySelector('p').textContent]));
|
||||
await page.keyboard.press('Escape');await expect(modal).toHaveCount(0);
|
||||
return previews;
|
||||
}
|
||||
|
||||
/** Production's send: the acknowledged sender, previewing and applying both roles to a chosen node. */
|
||||
async function productionSendOf(page,context) {
|
||||
const host=await fakeHost(context);
|
||||
try {
|
||||
await panelOf(page).getByRole('button',{name:`Send prompt pair to ComfyUI: ${TITLE}`,exact:true}).click();
|
||||
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});await expect(sender).toBeVisible();
|
||||
const windows=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});
|
||||
await expect(windows.locator('option')).toContainText(['Test ComfyUI']);await windows.selectOption({label:'Test ComfyUI'});
|
||||
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'My prompt 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.');
|
||||
const applied=await host.evaluate(()=>window.applied);
|
||||
await sender.getByRole('button',{name:'Close',exact:true}).click();await expect(sender).toHaveCount(0);
|
||||
return applied;
|
||||
} finally {await host.evaluate(()=>window.fixtureHost.dispose());await host.close();}
|
||||
}
|
||||
|
||||
/** A tag chip: the collection it leaves (the count line), then the chip pressed again for the same result. */
|
||||
async function tagOf(page) {
|
||||
const before=await page.locator('#result-count').textContent();
|
||||
await panelOf(page).locator('.detail-inner > .tags').last().getByRole('button',{name:'cinematic',exact:true}).click();
|
||||
await expect.poll(()=>page.locator('#result-count').textContent()).not.toBe(before);
|
||||
await settle(page);
|
||||
return {count:await page.locator('#result-count').textContent(),
|
||||
cards:await page.locator(`${CARDS} .image-open`).evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')))};
|
||||
}
|
||||
|
||||
const historyOf=async page=>{
|
||||
await panelOf(page).getByRole('button',{name:'Prompt history',exact:true}).click();
|
||||
await expect.poll(()=>page.evaluate(()=>location.hash)).not.toMatch(/images$/);
|
||||
return page.evaluate(()=>location.hash);
|
||||
};
|
||||
|
||||
test.beforeEach(async({request},testInfo)=>{testInfo.manifest=await seedConcept(request);});
|
||||
|
||||
for(const [width,height] of SIZES)for(const theme of THEMES)test(`inspector panel geometry, tokens and controls match the concept ${width}x${height} ${theme}`,async({page,context,browserName},testInfo)=>{
|
||||
test.setTimeout(180_000);
|
||||
// The concept's copy and production's both use the async clipboard; Chromium asks for the permission.
|
||||
if(browserName==='chromium')await context.grantPermissions(['clipboard-read','clipboard-write']);
|
||||
await page.setViewportSize({width,height});
|
||||
const manifest=testInfo.manifest;
|
||||
const viewport={width,height};
|
||||
await openConceptPanel(page,theme);
|
||||
await captureCanonicalState(page,testInfo,`concept-inspector-${width}-${theme}`);
|
||||
const reference=await inventory(page,true);
|
||||
const concept={copy:await copyOf(page),send:await conceptSendOf(page),tag:await tagOf(page)};
|
||||
await openConceptPanel(page,theme);concept.history=await historyOf(page);
|
||||
|
||||
await openProductionPanel(page,theme);
|
||||
await captureCanonicalState(page,testInfo,`production-inspector-${width}-${theme}`);
|
||||
const actual=await inventory(page);
|
||||
const production={copy:await copyOf(page),send:await productionSendOf(page,context)};
|
||||
const listing=page.waitForRequest(item=>item.url().includes('/images/output?')&&new URL(item.url()).searchParams.get('tags')==='cinematic');
|
||||
production.tag=await tagOf(page);await listing;
|
||||
// D44: the applied tag is one active refinement, so the toolbar's Filters button counts it.
|
||||
await page.getByRole('button',{name:'Filters · 1',exact:true}).click();
|
||||
production.tagFilter=await page.getByRole('dialog').getByRole('textbox',{name:'Tags',exact:true}).inputValue();
|
||||
await page.keyboard.press('Escape');
|
||||
await openProductionPanel(page,theme);production.history=await historyOf(page);
|
||||
|
||||
const diff=compare(reference,actual,viewport);
|
||||
const path=testInfo.outputPath('inspector-geometry.json');
|
||||
await writeFile(path,JSON.stringify({reference,actual,diff,concept,production},null,2));
|
||||
await testInfo.attach('inspector-geometry',{path,contentType:'application/json'});
|
||||
|
||||
// The concept itself is what this comparison assumes (spec §3.3, s523–615).
|
||||
const {panel}=reference,coarse=await page.evaluate(()=>matchMedia('(pointer: coarse)').matches);
|
||||
expect.soft(panel.width,'concept panel width').toBe(width>700?310:width-28);
|
||||
expect.soft(panel.position,'concept panel position').toBe(width>700?'sticky':'static');
|
||||
expect.soft(panel.cover.height,'concept cover').toBe(width>700?150:200);
|
||||
expect.soft(panel.inner.paddingTop,'concept inner padding').toBe('17px');
|
||||
expect.soft(panel.heading.h2.fontSize,'concept h2').toBe('14px');
|
||||
for(const section of panel.sections) {
|
||||
expect.soft(section.label,`concept ${section.className} label`).toMatchObject({fontSize:'10px',letterSpacing:'0.7px',textTransform:'uppercase',fontWeight:'650'});
|
||||
expect.soft(section.text,`concept ${section.className} text`).toMatchObject({fontSize:'12px',lineHeight:'21px'});
|
||||
for(const button of section.actions.buttons)expect.soft(button,`concept ${section.className} action`).toMatchObject(coarse?{minHeight:'36px',minWidth:'36px'}:{minHeight:'27px',minWidth:'28px'});
|
||||
}
|
||||
expect.soft(panel.sections.map(section=>section.label.color)[0],'concept role colors').not.toBe(panel.sections[1].label.color);
|
||||
expect.soft(panel.facts,'concept facts').toMatchObject({columns:2,columnGap:'12px',rowGap:'12px',fontSize:width>700?'11px':'12px'});
|
||||
expect.soft(panel.footer,'concept footer').toMatchObject({paddingTop:'13px',paddingRight:'17px',paddingBottom:'13px',paddingLeft:'17px'});
|
||||
expect.soft(panel.badges.chips.map(chip=>chip.text),'concept badges').toEqual(['Execution recorded','Landscapes']);
|
||||
expect.soft(reference.viewerDetails.structure.inner.slice(-2),'concept full details').toEqual(['details','details']);
|
||||
|
||||
expect.soft(diff,'differences after the recorded deviations').toEqual([]);
|
||||
// Copy: the concept's toast "Prompt copied." (or its dialog), in both documents.
|
||||
expect.soft(production.copy,'copy').toEqual(concept.copy);
|
||||
if(browserName==='chromium')expect.soft(production.copy,'copy toast').toEqual({toast:'Prompt copied.',dialog:false});
|
||||
// Send: the concept previews both roles; production applies exactly those texts and ComfyUI acknowledges them.
|
||||
const [positive,negative]=concept.send;
|
||||
expect.soft(concept.send.map(([heading])=>heading),'concept send').toEqual(['+ Positive · replace','− Negative · replace']);
|
||||
expect.soft(production.send,'acknowledged send').toEqual([{positive:positive[1],negative:negative[1]}]);
|
||||
expect.soft(negative[1],'fixture negative').toBe('blurry, low quality, low resolution');
|
||||
// Tag chip: D21, the Images Tags filter (the concept searches the tag's text instead), leaving the same images.
|
||||
expect.soft(production.tagFilter,'tag chip filter').toBe('cinematic');
|
||||
// The concept's search also matches the forest prompt's text ("cinematic still"), which is not tagged cinematic
|
||||
// (app.js 73, 115, 130): production leaves the tagged prompts' images, in the concept's order.
|
||||
const tagged=concept.tag.cards.filter(card=>!card.startsWith('Open forest_'));
|
||||
expect.soft(concept.tag.count,'concept tag search').toBe('12 of 12 images · 3 positive prompts');
|
||||
expect.soft(production.tag,'tag chip collection').toEqual({count:countCopy(`${tagged.length} of ${tagged.length} images · 2 positive prompts`),cards:tagged});
|
||||
// Prompt history: the concept's `#prompt/<id>` is production's Library history of the linked positive.
|
||||
expect.soft(concept.history,'concept history').toBe('#prompt/alpine');
|
||||
expect.soft(production.history,'history').toBe(`#/library/${manifest.prompts[0].positive_id}`);
|
||||
});
|
||||
|
||||
// D39 (ruled 2026-10-21): a recipe-linked image shows its recipe's own category and tags when set, otherwise its
|
||||
// positive's, field by field. The concept fixture's recipes have neither, so this recipe-linked image shows its
|
||||
// positive's, as the concept does, and its tag chips are Tags filter buttons again (D21: the positive's own tags).
|
||||
test('a recipe-linked image without recipe metadata shows its positive prompt metadata (D39)',async({page})=>{
|
||||
await page.setViewportSize({width:1600,height:1100});
|
||||
await openProductionPanel(page,'dark');
|
||||
await page.locator(CARDS).nth(1).locator('[aria-label^="Inspect"]').click();
|
||||
await expect(panelOf(page).locator('.panel-heading h2')).toHaveText('Quiet geometry');
|
||||
const rows=panelOf(page).locator('.detail-inner > .tags');
|
||||
await expect(rows).toHaveCount(2);
|
||||
await expect(rows.first().locator('.chip')).toHaveText(['Execution recorded','Positive prompt category: Architecture']);
|
||||
await expect(rows.first().locator('.chip .ca-visually-hidden')).toHaveText('Positive prompt category: ');
|
||||
await expect(rows.nth(1).locator('button.chip')).toHaveText(['minimal','warm_light','editorial']);
|
||||
await expect(rows.nth(1).locator('span.chip')).toHaveCount(0);
|
||||
await expect(panelOf(page).locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Prompt history']);
|
||||
});
|
||||
|
||||
// Task C3 (D88, #249 PR C): the concept's chip row is the image's own tags (`i.tags`, app.js a402). With those names
|
||||
// saved as the file's current image tags, production draws the row from them (Tags filter buttons in the panel, plain
|
||||
// in the viewer, the linked positive's equal tags not repeated) and still equals the concept, in both sizes.
|
||||
for(const [width,height] of SIZES)test(`image tags saved on the concept image keep the inspector equal to the concept ${width}x${height} (D88)`,async({page,request},testInfo)=>{
|
||||
test.setTimeout(120_000);
|
||||
const viewport={width,height};await page.setViewportSize(viewport);
|
||||
await openConceptPanel(page,'dark');
|
||||
const names=await panelOf(page).locator('.detail-inner > .tags').last().locator('button.chip').allTextContents();
|
||||
expect(names).toEqual(['cinematic','blue_hour','architecture']);
|
||||
await captureCanonicalState(page,testInfo,`concept-image-tags-${width}`,'#image-inspector');
|
||||
const reference=await inventory(page,true);
|
||||
const {path}=testInfo.manifest.images.find(item=>item.filename==='alpine_00001.png');
|
||||
expect(await saveImageTags(request,path,names)).toEqual(names);
|
||||
const read=page.waitForResponse(item=>new URL(item.url()).pathname===`/prompt_manager/images/inspect/${path}`);
|
||||
await openProductionPanel(page,'dark');
|
||||
expect((await (await read).json()).image_tags).toEqual({state:'current',tags:names});
|
||||
await expect(panelOf(page).locator('.detail-inner > .tags').last().locator('.chip')).toHaveText(names);
|
||||
await captureCanonicalState(page,testInfo,`production-image-tags-${width}`,'#image-inspector');
|
||||
assertInspectorMatch(reference,await inventory(page),viewport);
|
||||
});
|
||||
|
||||
// The comparison is only worth something if it notices a change (spec §7 mutation check): on the panel one CSS
|
||||
// mutation (the role label's letter spacing) and one DOM mutation (the footer removed); in the viewer (final review m5)
|
||||
// its pair actions removed and D38's Show in folder removed; each applied after an exact match.
|
||||
test('inspector comparison detects mutations',async({page})=>{
|
||||
test.setTimeout(120_000);
|
||||
const viewport={width:1600,height:1100};
|
||||
await page.setViewportSize(viewport);
|
||||
await openConceptPanel(page,'dark');
|
||||
const reference=await inventory(page,true);
|
||||
await openProductionPanel(page,'dark');
|
||||
const {viewerDetails,fileControls}=await inventory(page);
|
||||
const measure=async()=>({panel:(await inspectorOf(page)).panel,viewerDetails,fileControls});
|
||||
assertInspectorMatch(reference,await measure(),viewport);
|
||||
|
||||
const restoreCss=await mutate(page,{css:'.prompt-heading .role-label { letter-spacing: 0 }'});
|
||||
const cssMutated=await measure();
|
||||
expect(()=>assertInspectorMatch(reference,cssMutated,viewport)).toThrow(/panel\.sections\.0\.label\.letterSpacing/);
|
||||
await restoreCss();await settle(page);
|
||||
assertInspectorMatch(reference,await measure(),viewport);
|
||||
|
||||
const panel=(await inspectorOf(page)).panel;
|
||||
const viewerMutated=async mutation=>{const {viewerDetails,fileControls}=await viewerOf(page,false,mutation);return {panel,viewerDetails,fileControls};};
|
||||
const withoutActions=await viewerMutated({remove:['#viewer-details > button']});
|
||||
expect(()=>assertInspectorMatch(reference,withoutActions,viewport)).toThrow(/viewerDetails\.actions/);
|
||||
const withoutReveal=await viewerMutated({remove:['#viewer-details button[aria-label$=" in folder"]']});
|
||||
expect(()=>assertInspectorMatch(reference,withoutReveal,viewport)).toThrow(/fileControls/);
|
||||
assertInspectorMatch(reference,await viewerMutated(),viewport);
|
||||
|
||||
await mutate(page,{remove:['#image-inspector > .detail-footer']});
|
||||
const domMutated=await measure();
|
||||
expect(()=>assertInspectorMatch(reference,domMutated,viewport)).toThrow(/panel\.footer/);
|
||||
});
|
||||
@@ -0,0 +1,344 @@
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact,countCopy,reflow} from './concept-inventory.mjs';
|
||||
|
||||
// Task E3 of #249: the production Prompt library shelf against the immutable concept (app.js renderLibrary a572–586,
|
||||
// style.css s648–708), on the concept-shaped real fixture (the concept's six prompts with their negatives, categories,
|
||||
// tags and ratings, four images each, run_count 4 and last_used_at now). Heading, toolbar, caption, grid and the six
|
||||
// cards are compared at 1600x1100 and 390x844 in both themes and motion settings, the grid and cards also at 1800x1100
|
||||
// (3 columns), and the shelf's behaviors in both documents. The topbar and sidebar are the shell spec's.
|
||||
// Paired captures are artifacts only; there are no PNG baselines.
|
||||
test.use({locale:'en-US',timezoneId:'UTC'});
|
||||
|
||||
const CARDS='.library-grid .prompt-card';
|
||||
const CONCEPT_PROMPTS=6;
|
||||
|
||||
/** Recorded Library deviations only (spec §8); everything else in the concept inventory must equal production. */
|
||||
const nocase=(a,b)=>{const x=a.toLowerCase(),y=b.toLowerCase();return x<y?-1:x>y?1:a<b?-1:a>b?1:0;};
|
||||
export function mapLibraryConcept(reference) {
|
||||
const library=reference.library;
|
||||
// Save view is the concept's own (saved views, #249 D16 retired).
|
||||
const toolbar={...library.toolbar};
|
||||
toolbar.items=toolbar.items.map(item=>{
|
||||
// D14: the Library appends its Oldest first after the concept's three sorts.
|
||||
if(item.id==='sort')return {...item,options:[...item.options,'Oldest first']};
|
||||
// D36: the category list is the Library's real list, sorted alphabetically; the concept lists prompt order.
|
||||
if(item.id==='category-filter')return {...item,options:[item.options[0],...item.options.slice(1).sort(nocase)]};
|
||||
return item;
|
||||
});
|
||||
// A shorter toolbar moves what follows it up by as much.
|
||||
const rise=library.toolbar.height-toolbar.height;
|
||||
const cards=library.cards.map((card,index)=>({...card,
|
||||
// D23: "− N negative variants" uses the singular for one; that row is the concept's own, measured so (`oracle`).
|
||||
meta:card.meta.items[0]?.text===PLURAL_ONE?reference.oracle.meta[index]:card.meta,
|
||||
// D23: a long title wraps inside the title row, so the title button may shrink below its text (`min-width: 0`).
|
||||
title:{...card.title,items:card.title.items.map(item=>item.tag==='button'?{...item,minWidth:'0px'}:item)},
|
||||
// D43: tag chips come in the store's alphabetical order; the row flows again from the concept's own chips.
|
||||
tags:sortedTags(card.tags),
|
||||
// D23: the stars are aria-hidden, with a visually hidden "Rated N out of 5" spoken in their place. The fixture's
|
||||
// run_count (4) and last_used_at (now) give the concept's own "4 runs" and "Used today". The two-line clamp is
|
||||
// applied below through `clampedCards`, measured on the concept with `CLAMP_CSS` (its samples wrap at 390px).
|
||||
footer:{...card.footer,facts:{...card.footer.facts,spoken:card.footer.facts.spoken.replace(/^(★+)/,stars=>`Rated ${stars.length} out of 5`)}}}));
|
||||
return {...reference,library:{...library,toolbar,
|
||||
// D35: the caption uses the singular for one.
|
||||
caption:{...library.caption,top:library.caption.top-rise,spans:library.caption.spans.map(span=>({...span,text:countCopy(span.text)}))},
|
||||
...(({grid,cards:clamped})=>({grid:{...grid,top:grid.top-rise},cards:clamped}))(clampedCards({grid:library.grid,cards})),
|
||||
// D40: the production-only continuation follows the grid (here the end of the shelf); the concept has none.
|
||||
trailing:[...library.trailing,{tag:'div',className:'library-continuation',text:'You’re all caught up.'}]}};
|
||||
}
|
||||
|
||||
/** D23's clamp, as workspace.css states it: the concept's own cards with it applied at runtime are the clamp oracle. */
|
||||
const CLAMP_CSS='.prompt-card .prompt-text { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden }';
|
||||
const CLAMP_LINES=2;
|
||||
/**
|
||||
* D23: the positive prompt is clamped to two lines. Where a concept text wraps to more (its samples do at 390px), the
|
||||
* text box loses the extra lines, and its section, content and card shrink by as much, as does every box below it; the
|
||||
* grid's rows then close up by their tallest card's loss.
|
||||
*/
|
||||
export function clampedCards({grid,cards}) {
|
||||
const shrunk=cards.map(card=>{
|
||||
const text=card.section.text;
|
||||
if(text.lines<=CLAMP_LINES)return {card,loss:0};
|
||||
const loss=text.height-Math.round(CLAMP_LINES*parseFloat(text.lineHeight));
|
||||
return {loss,card:{...card,height:card.height-loss,
|
||||
content:{...card.content,height:card.content.height-loss},
|
||||
section:{...card.section,height:card.section.height-loss,text:{...text,height:text.height-loss,lines:CLAMP_LINES}},
|
||||
meta:{...card.meta,top:card.meta.top-loss},tags:{...card.tags,top:card.tags.top-loss},
|
||||
footer:{...card.footer,top:card.footer.top-loss}}};
|
||||
});
|
||||
// Rows are the cards sharing a top; a row is as tall as its tallest card, and each row moves up by the rows above.
|
||||
const tops=[...new Set(cards.map(card=>card.top))].sort((a,b)=>a-b);
|
||||
const rowLoss=top=>{
|
||||
const row=cards.flatMap((card,index)=>card.top===top?[index]:[]);
|
||||
return Math.max(...row.map(index=>cards[index].height))-Math.max(...row.map(index=>shrunk[index].card.height));
|
||||
};
|
||||
const above=top=>tops.filter(other=>other<top).reduce((sum,other)=>sum+rowLoss(other),0);
|
||||
return {grid:{...grid,height:grid.height-tops.reduce((sum,top)=>sum+rowLoss(top),0)},
|
||||
cards:shrunk.map(({card},index)=>({...card,top:card.top-above(cards[index].top)}))};
|
||||
}
|
||||
|
||||
/** D43: a `.tags` row with its chips in alphabetical order, laid out again from the concept's chip boxes. */
|
||||
export function sortedTags(tags) {
|
||||
return reflow(tags,[...tags.items].sort((a,b)=>nocase(a.text,b.text)));
|
||||
}
|
||||
|
||||
const SECTIONS=['heading','library','noPageScroll'];
|
||||
function compare(reference,actual,{sections=SECTIONS,library=null}={}) {
|
||||
const only=inventory=>Object.fromEntries(sections.map(key=>[key,key==='library'&&library
|
||||
?Object.fromEntries(library.map(part=>[part,inventory.library[part]])):inventory[key]]));
|
||||
return diffInventory(stripExact(only(mapLibraryConcept(reference))),stripExact(only(actual)));
|
||||
}
|
||||
|
||||
/** The comparison as a gate: throws, naming every differing path, unless production equals the mapped concept. */
|
||||
function assertLibraryMatch(reference,actual) {
|
||||
const diff=compare(reference,actual);
|
||||
if(diff.length)throw new Error(`the Prompt library differs from the concept at ${diff.map(item=>item.path).join(', ')}`);
|
||||
}
|
||||
|
||||
// Two frames, then every running transition finished.
|
||||
const settle=page=>page.evaluate(async()=>{
|
||||
await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));
|
||||
await Promise.race([Promise.all(document.getAnimations().map(animation=>animation.finished.catch(()=>{}))),
|
||||
new Promise(resolve=>setTimeout(resolve,2000))]);
|
||||
});
|
||||
// Every card image decoded, so nothing moves while it is measured.
|
||||
const decoded=page=>page.evaluate(async()=>{
|
||||
await document.fonts.ready;
|
||||
await Promise.all([...document.querySelectorAll('.library-grid img')].map(img=>{img.loading='eager';return img.decode().catch(()=>{});}));
|
||||
});
|
||||
|
||||
const PLURAL_ONE='− 1 negative variants',SINGULAR_ONE='− 1 negative variant';
|
||||
/**
|
||||
* The concept inventory, with `oracle.meta`: every card's `.card-meta` measured once the concept's hard-coded plural
|
||||
* reads "− 1 negative variant" (D23 "singular for one"), so the shorter chip and what follows it are the browser's own
|
||||
* layout. The concept page is restored afterwards.
|
||||
*/
|
||||
async function conceptInventory(page) {
|
||||
const reference=await inventoryOf(page,'#main');
|
||||
await page.evaluate(({plural,singular})=>{
|
||||
window.__conceptPlurals=[...document.querySelectorAll('.prompt-card .card-meta > .chip.red')].filter(chip=>chip.textContent===plural);
|
||||
for(const chip of window.__conceptPlurals)chip.textContent=singular;
|
||||
},{plural:PLURAL_ONE,singular:SINGULAR_ONE});
|
||||
const meta=(await inventoryOf(page,'#main')).library.cards.map(card=>card.meta);
|
||||
await page.evaluate(plural=>{for(const chip of window.__conceptPlurals)chip.textContent=plural;delete window.__conceptPlurals;},PLURAL_ONE);
|
||||
return {...reference,oracle:{meta}};
|
||||
}
|
||||
|
||||
/** The concept page with each card's tag chips in alphabetical order (the D43 oracle); returns the undo. */
|
||||
async function sortConceptTags(page) {
|
||||
await page.evaluate(()=>{
|
||||
const nocase=(a,b)=>{const x=a.textContent.toLowerCase(),y=b.textContent.toLowerCase();return x<y?-1:x>y?1:0;};
|
||||
window.__conceptTagOrder=[...document.querySelectorAll('.prompt-card .tags')].map(row=>{
|
||||
const chips=[...row.children];row.append(...[...chips].sort(nocase));return [row,chips];});
|
||||
});
|
||||
return ()=>page.evaluate(()=>{for(const [row,chips] of window.__conceptTagOrder)row.append(...chips);delete window.__conceptTagOrder;});
|
||||
}
|
||||
|
||||
async function openConceptLibrary(page,theme,motion) {
|
||||
await openConcept(page,'library',theme,motion);
|
||||
await expect(page.locator(CARDS)).toHaveCount(CONCEPT_PROMPTS);
|
||||
await page.mouse.move(0,0);await decoded(page);await settle(page);
|
||||
}
|
||||
async function openProductionLibrary(page,theme,motion) {
|
||||
await openProduction(page,'library',theme,motion);
|
||||
await expect(page.locator(CARDS)).toHaveCount(CONCEPT_PROMPTS);
|
||||
await expect(page.locator('#workspace-main .section-caption')).toBeVisible();
|
||||
// The category list is read once per visit; it is part of the toolbar inventory.
|
||||
await expect(page.locator('#category-filter option')).toHaveCount(5);
|
||||
await page.mouse.move(0,0);await decoded(page);await settle(page);
|
||||
}
|
||||
|
||||
const titlesOf=page=>page.locator(`${CARDS} .card-title > button`).allTextContents();
|
||||
const captionOf=page=>page.locator('.section-caption > span').first().textContent();
|
||||
|
||||
/** A shelf filter: the card titles and the caption it leaves, then the filter undone. */
|
||||
async function filteredOf(page,apply,undo) {
|
||||
const before=await captionOf(page);
|
||||
await apply();
|
||||
await expect.poll(()=>captionOf(page)).not.toBe(before);
|
||||
await expect.poll(()=>page.locator(CARDS).count()).toBeLessThan(CONCEPT_PROMPTS);
|
||||
// Production empties the shelf when the filter applies, writes the answer's caption at once and places its cards
|
||||
// in the next animation frame (one paint for a viewer), so the empty shelf already counts as "fewer": read the
|
||||
// titles only after that frame, as the Images and Inspector comparisons do.
|
||||
await settle(page);
|
||||
const result={caption:await captionOf(page),titles:await titlesOf(page)};
|
||||
await undo();
|
||||
await expect.poll(()=>captionOf(page)).toBe(before);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Click `control` on the first card, record the history page's title, and come back to a settled shelf. */
|
||||
async function historyFrom(page,control,back) {
|
||||
const title=(await page.locator(CARDS).first().locator('.card-title > button').textContent()).trim();
|
||||
await page.locator(CARDS).first().locator(control).click();
|
||||
const h1=page.locator('h1#page-title');
|
||||
await expect(h1).toHaveText(title);
|
||||
const result={title,h1:(await h1.textContent()).trim()};
|
||||
await back();
|
||||
await expect(page.locator(CARDS)).toHaveCount(CONCEPT_PROMPTS);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** The behaviors §7 and Task E3 list, with identical selectors in both documents. `sent` reads what a send carried. */
|
||||
async function behaviorsOf(page,{back,sent}) {
|
||||
const first=page.locator(CARDS).first();
|
||||
const title=await historyFrom(page,'.card-title > button',back);
|
||||
const explore=await historyFrom(page,'.card-footer .row-actions > button:not(.send)',back);
|
||||
// The footer send opens the sender with the positive prompt only.
|
||||
const positive=(await first.locator('.prompt-section.positive .prompt-text').textContent()).trim();
|
||||
await page.locator(CARDS).first().locator('.card-footer .row-actions > button.send').click();
|
||||
const send={positive,roles:await sent(page)};
|
||||
// Copy writes the full positive and shows the toast.
|
||||
await page.locator(CARDS).first().locator('.prompt-section.positive .prompt-actions > button').first().click();
|
||||
await expect(page.locator('.toast')).not.toHaveText('');
|
||||
const copy={toast:(await page.locator('.toast').textContent()).trim(),copied:await page.evaluate(()=>window.copied.at(-1))};
|
||||
const search=await filteredOf(page,()=>page.locator('#search').fill('neon'),()=>page.locator('#search').fill(''));
|
||||
const category=await filteredOf(page,()=>page.locator('#category-filter').selectOption('Landscapes'),()=>page.locator('#category-filter').selectOption(''));
|
||||
return {title,explore,send,copy,search,category};
|
||||
}
|
||||
|
||||
/** The concept's demo sender: the roles its preview lists, with their text. */
|
||||
async function conceptSent(page) {
|
||||
const modal=page.locator('dialog#modal[open]');
|
||||
await expect(modal.locator('#modal-title')).toHaveText('Send to ComfyUI');
|
||||
const roles=await modal.locator('.send-preview').evaluateAll(nodes=>Object.fromEntries(nodes.map(node=>
|
||||
[node.querySelector('h3').textContent.startsWith('+')?'positive':'negative',node.querySelector('p').textContent])));
|
||||
await page.keyboard.press('Escape');await expect(modal).toHaveCount(0);
|
||||
return roles;
|
||||
}
|
||||
|
||||
/** Production's acknowledged sender against a bridge host page: the roles its preview asked the host to change. */
|
||||
function productionSent(host) {
|
||||
return async page=>{
|
||||
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
|
||||
await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'Concept ComfyUI'});
|
||||
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'Concept destination'});
|
||||
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
|
||||
await expect.poll(()=>host.evaluate(()=>window.previewed.length)).toBe(1);
|
||||
const roles=await host.evaluate(()=>window.previewed.pop());
|
||||
await page.keyboard.press('Escape');await expect(sender).toHaveCount(0);
|
||||
return roles;
|
||||
};
|
||||
}
|
||||
async function openHost(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-1',label:'Concept destination',type:'PromptManagerPair',roles:['positive','negative']};
|
||||
window.previewed=[];
|
||||
window.conceptHost=createBridgeHost({origin:location.origin,namespace:'/',label:'Concept ComfyUI',adapter:{
|
||||
listTargets:()=>[target],
|
||||
preview:(_id,roles)=>{window.previewed.push(roles);return {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=>({targetId:target.id,values:roles}),
|
||||
}});
|
||||
});
|
||||
return host;
|
||||
}
|
||||
|
||||
test.beforeEach(async({page,request},testInfo)=>{
|
||||
testInfo.manifest=await seedConcept(request);
|
||||
// Both documents copy through the clipboard; the stub records what they wrote.
|
||||
await page.addInitScript(()=>{window.copied=[];Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>{window.copied.push(text);}}});});
|
||||
});
|
||||
|
||||
for(const [width,height] of SIZES)for(const theme of THEMES)test(`library geometry, tokens, controls and behaviors match the concept ${width}x${height} ${theme}`,async({page,context},testInfo)=>{
|
||||
test.setTimeout(240_000);
|
||||
await page.setViewportSize({width,height});
|
||||
const narrow=width<=700;
|
||||
const host=await openHost(context);
|
||||
try {
|
||||
const result={};
|
||||
for(const motion of MOTIONS) {
|
||||
const capture=motion==='no-preference';
|
||||
await openConceptLibrary(page,theme,motion);
|
||||
const reference=await conceptInventory(page);
|
||||
if(capture)await captureCanonicalState(page,testInfo,`concept-library-${width}-${theme}`);
|
||||
// The browser as the re-layout's oracle: the concept page itself with the D23 clamp applied to its cards.
|
||||
const restoreClamp=await mutate(page,{css:CLAMP_CSS});
|
||||
const clamped=(await inventoryOf(page,'#main')).library;
|
||||
await restoreClamp();
|
||||
// ...and with every card's tag chips put in alphabetical order (D43).
|
||||
const restoreTags=await sortConceptTags(page);
|
||||
const sorted=(await inventoryOf(page,'#main')).library.cards.map(card=>card.tags);
|
||||
await restoreTags();
|
||||
const referenceBehaviors=capture?await behaviorsOf(page,{back:()=>page.goBack(),sent:conceptSent}):null;
|
||||
|
||||
await openProductionLibrary(page,theme,motion);
|
||||
const actual=await inventoryOf(page,'#workspace-main');
|
||||
if(capture)await captureCanonicalState(page,testInfo,`production-library-${width}-${theme}`);
|
||||
const actualBehaviors=capture?await behaviorsOf(page,{back:()=>page.getByRole('button',{name:'← Prompt library',exact:true}).click(),sent:productionSent(host)}):null;
|
||||
result[motion]={reference,sorted,clamped,actual,diff:compare(reference,actual),referenceBehaviors,actualBehaviors};
|
||||
}
|
||||
const path=testInfo.outputPath('library-geometry.json');await writeFile(path,JSON.stringify(result,null,2));
|
||||
await testInfo.attach('library-geometry',{path,contentType:'application/json'});
|
||||
|
||||
for(const [motion,{reference,sorted,clamped,actual,diff,referenceBehaviors:concept,actualBehaviors:production}] of Object.entries(result)) {
|
||||
const {grid,cards}=reference.library,card=cards[0];
|
||||
// The concept itself is what this comparison assumes (style.css s648–708, 1424–1428, 1475–1483).
|
||||
expect.soft(grid,`${motion} concept grid`).toMatchObject({columns:narrow?1:2,columnGap:'18px',rowGap:'18px',cardCount:CONCEPT_PROMPTS});
|
||||
expect.soft(card,`${motion} concept card`).toMatchObject({borderTopLeftRadius:'8px',borderTopWidth:'1px',overflow:'hidden'});
|
||||
const art=narrow?150:170;
|
||||
expect.soft(card.art,`${motion} concept art`).toMatchObject({height:art,display:'grid',columnGap:'3px'});
|
||||
const [major,minor]=card.art.gridTemplateColumns.split(' ').map(parseFloat);
|
||||
expect.soft(Math.abs(major-2*minor),`${motion} concept art 2fr/1fr`).toBeLessThan(1);
|
||||
expect.soft(card.art.items.map(item=>item.height),`${motion} concept art images`).toEqual([art,art]);
|
||||
const pad=narrow?'15px':'18px';
|
||||
expect.soft(card.content,`${motion} concept content padding`).toMatchObject({paddingTop:pad,paddingLeft:pad,paddingRight:pad,paddingBottom:pad});
|
||||
expect.soft(card.title.items[0],`${motion} concept title`).toMatchObject({tag:'button',fontSize:'16px',fontWeight:'600'});
|
||||
expect.soft(card.meta,`${motion} concept meta`).toMatchObject({fontSize:'10px',columnGap:'9px'});
|
||||
expect.soft(card.meta.items.map(item=>item.text),`${motion} concept meta text`).toEqual(['− 3 negative variants','4 runs','4 images']);
|
||||
expect.soft(card.footer,`${motion} concept footer`).toMatchObject({paddingTop:'11px',paddingRight:'18px',paddingBottom:'11px',paddingLeft:'18px',borderTopWidth:'1px',borderTopStyle:'solid'});
|
||||
expect.soft(card.footer.facts.text,`${motion} concept footer facts`).toBe('★★★★★ · Used today');
|
||||
expect.soft(diffInventory(stripExact(cards.map(item=>sortedTags(item.tags))),stripExact(sorted)),`${motion} tags in alphabetical order`).toEqual([]);
|
||||
expect.soft(diffInventory(stripExact(clampedCards(reference.library)),stripExact({grid:clamped.grid,cards:clamped.cards})),`${motion} clamped cards`).toEqual([]);
|
||||
expect.soft(diff,`${motion} differences after the recorded deviations`).toEqual([]);
|
||||
expect.soft(actual.noPageScroll,`${motion} no page-level horizontal scroll`).toBe(true);
|
||||
if(!concept)continue;
|
||||
// Title and Explore history open the prompt's history; the footer send carries the positive only; copy writes
|
||||
// the full positive with the concept's toast; search and category narrow the shelf alike.
|
||||
expect.soft(concept.title,`${motion} concept title opens history`).toEqual({title:card.title.items[0].text,h1:card.title.items[0].text});
|
||||
expect.soft(concept.send.roles,`${motion} concept send`).toEqual({positive:concept.send.positive});
|
||||
expect.soft(concept.copy,`${motion} concept copy`).toEqual({toast:'Prompt copied.',copied:concept.send.positive});
|
||||
expect.soft(concept.search.titles,`${motion} concept search`).toEqual(['After the rain']);
|
||||
// D35: the shelf caption uses the singular for one.
|
||||
const counted=filter=>({...filter,caption:countCopy(filter.caption)});
|
||||
expect.soft(production,`${motion} behaviors`).toEqual({...concept,search:counted(concept.search),category:counted(concept.category)});
|
||||
}
|
||||
} finally {await host.evaluate(()=>window.conceptHost.dispose());await host.close();}
|
||||
});
|
||||
|
||||
// The grid and cards at the concept's three-column breakpoint (style.css 1274–1276).
|
||||
for(const theme of THEMES)test(`library grid and cards match the concept 1800x1100 ${theme}`,async({page},testInfo)=>{
|
||||
await page.setViewportSize({width:1800,height:1100});
|
||||
await openConceptLibrary(page,theme,'no-preference');
|
||||
const reference=await conceptInventory(page);
|
||||
await captureCanonicalState(page,testInfo,`concept-library-1800-${theme}`);
|
||||
await openProductionLibrary(page,theme,'no-preference');
|
||||
const actual=await inventoryOf(page,'#workspace-main');
|
||||
await captureCanonicalState(page,testInfo,`production-library-1800-${theme}`);
|
||||
expect(reference.library.grid.columns,'concept columns').toBe(3);
|
||||
expect(compare(reference,actual,{sections:['library'],library:['grid','cards','trailing']})).toEqual([]);
|
||||
});
|
||||
|
||||
// The comparison is only worth something if it notices a change (spec §7 mutation check): one CSS mutation (the footer
|
||||
// padding) and one DOM mutation (the negative-variants chip removed), each applied after an exact match.
|
||||
test('library comparison detects mutations',async({page})=>{
|
||||
await page.setViewportSize({width:1600,height:1100});
|
||||
await openConceptLibrary(page,'dark','no-preference');
|
||||
const reference=await conceptInventory(page);
|
||||
await openProductionLibrary(page,'dark','no-preference');
|
||||
const measure=()=>inventoryOf(page,'#workspace-main');
|
||||
assertLibraryMatch(reference,await measure());
|
||||
|
||||
const restoreCss=await mutate(page,{css:'.card-footer { padding: 2px }'});
|
||||
const cssMutated=await measure();
|
||||
expect(()=>assertLibraryMatch(reference,cssMutated)).toThrow(/library\.cards\.0\.footer\.paddingTop/);
|
||||
await restoreCss();await settle(page);
|
||||
assertLibraryMatch(reference,await measure());
|
||||
|
||||
await mutate(page,{remove:['.card-meta .chip.red']});
|
||||
const domMutated=await measure();
|
||||
expect(()=>assertLibraryMatch(reference,domMutated)).toThrow(/library\.cards\.0\.meta\.items\.length/);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
|
||||
// Artifact-only captures of the editor states that have no concept counterpart (archive confirmation, Show archived,
|
||||
@@ -19,7 +19,7 @@ for(const [width,height,theme] of [[1600,1100,'dark'],[390,844,'light']])test(`e
|
||||
await page.setViewportSize({width,height});const f=page.fixture;
|
||||
await page.goto(`${workspace}#/library/${f.positive_id}`);await expect(variants(page)).toBeVisible();
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
const shot=name=>captureCanonicalState(page,testInfo,`production-${name}-${width}-${theme}`,name.startsWith('editor-archived')?'.library-variants':'dialog.library-editor');
|
||||
const shot=name=>captureCanonicalState(page,testInfo,`production-${name}-${width}-${theme}`,name.startsWith('editor-archived')?'.library-history .timeline-panel':'dialog.library-editor');
|
||||
|
||||
await variants(page).getByRole('button',{name:'Select negative variant 2',exact:true}).click();
|
||||
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();await expect(editor(page)).toBeVisible();
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// Files for the Inspect page workflows, as Playwright `setInputFiles` payloads ({name, mimeType, buffer}) or as the
|
||||
// bytes a page builds a File or DataTransfer from. Built from the committed fixtures and the byte-exact PNG builders
|
||||
// the node tests use (tests/js/helpers/png-bytes.mjs), so the browser reads exactly what the unit tests pin.
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import zlib from 'node:zlib';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { raw, ihdr, idat, iend, tEXt } from '../js/helpers/png-bytes.mjs';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const FIXTURES = path.join(here, '..', 'fixtures');
|
||||
const read = (...parts) => fs.readFileSync(path.join(FIXTURES, ...parts));
|
||||
const file = (name, mimeType, buffer) => ({ name, mimeType, buffer: Buffer.from(buffer) });
|
||||
|
||||
/** One committed parity PNG (tests/fixtures/inspection_parity), e.g. f02.png. */
|
||||
export const parityPng = (name, as = name) => file(as, 'image/png', read('inspection_parity', name));
|
||||
/** One committed local-inspect fixture (tests/fixtures/local-inspect). */
|
||||
export const localFixture = (name, mimeType, as = name) => file(as, mimeType, read('local-inspect', name));
|
||||
/** The committed CC0 VP8 clip with no generation metadata. */
|
||||
export const tinyWebm = () => file('tiny.webm', 'video/webm', read('tiny.webm'));
|
||||
|
||||
/** One decodable 2×2 opaque pixel row set, so a synthetic PNG previews. */
|
||||
function pixels(width = 2, height = 2) {
|
||||
const row = [0, ...Array.from({ length: width }, () => [40, 120, 200, 255]).flat()];
|
||||
return zlib.deflateSync(Buffer.from(Array.from({ length: height }, () => row).flat()));
|
||||
}
|
||||
|
||||
/**
|
||||
* A previewable PNG whose positive spans lines and whose negative is a ConditioningZeroOut: the legacy fill shows the
|
||||
* positive exactly and the ZeroOut guard makes the negative explicitly empty.
|
||||
*/
|
||||
export const POSITIVE_LINES = 'a lighthouse at dusk\nsoft rain, long exposure';
|
||||
export function sendablePng(name = 'send-me.png') {
|
||||
const prompt = JSON.stringify({
|
||||
3: { class_type: 'KSampler', inputs: { seed: 11, steps: 12, cfg: 5, sampler_name: 'euler', scheduler: 'normal', denoise: 1, model: ['4', 0], positive: ['6', 0], negative: ['8', 0], latent_image: ['5', 0] } },
|
||||
4: { class_type: 'CheckpointLoaderSimple', inputs: { ckpt_name: 'local.safetensors' } },
|
||||
5: { class_type: 'EmptyLatentImage', inputs: { width: 512, height: 512, batch_size: 1 } },
|
||||
6: { class_type: 'CLIPTextEncode', inputs: { text: POSITIVE_LINES, clip: ['4', 1] } },
|
||||
8: { class_type: 'ConditioningZeroOut', inputs: { conditioning: ['6', 0] } },
|
||||
});
|
||||
return file(name, 'image/png', raw(ihdr(2, 2), idat(pixels()), tEXt('prompt', prompt), iend()));
|
||||
}
|
||||
|
||||
/** A PNG whose text chunk fails its CRC: the reader reports the metadata unreadable; the bytes stay downloadable. */
|
||||
export const corruptPng = (name = 'corrupt.png') =>
|
||||
file(name, 'image/png', raw(ihdr(2, 2), idat(pixels()), tEXt('prompt', '{"3":{}}', { crc: 1 }), iend()));
|
||||
|
||||
/** Bytes no reader claims, under an image or video name. */
|
||||
export const disguised = () => [
|
||||
file('notes.png', 'image/png', Buffer.from('plain text, not an image')),
|
||||
file('page.png', 'image/png', Buffer.from('<!doctype html><title>x</title><p>hi')),
|
||||
file('vector.png', 'image/png', Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" width="4" height="4"/>')),
|
||||
file('stream.mp4', 'video/mp4', Buffer.from('#EXTM3U\n#EXT-X-VERSION:3\n')),
|
||||
];
|
||||
|
||||
/**
|
||||
* core-faststart.mp4 with its video sample entry renamed from `avc1` to `zzzz`: the container and its tags read as
|
||||
* before, but no browser has a decoder for the stream, so the preview fails deterministically on every engine.
|
||||
*/
|
||||
export function undecodableMp4(name = 'undecodable.mp4') {
|
||||
const bytes = Buffer.from(read('local-inspect', 'core-faststart.mp4'));
|
||||
const at = bytes.indexOf(Buffer.from('avc1', 'latin1'), bytes.indexOf(Buffer.from('stsd', 'latin1')));
|
||||
if (at < 0) throw new Error('core-faststart.mp4 has no avc1 sample entry');
|
||||
bytes.write('zzzz', at, 'latin1');
|
||||
return file(name, 'video/mp4', bytes);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
|
||||
|
||||
const pair={id:4,positive_text:'Library positive',negative_text:'Library negative',category:'Portrait',tags:['studio']};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
|
||||
const moduleUrl = '/prompt_manager/js/shared/toolkit/prompt-bridge.js';
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
|
||||
const workspace='/prompt_manager/workspace#/images';
|
||||
const details=page=>page.getByRole('region',{name:'Image details',exact:true});
|
||||
const details=page=>page.getByRole('complementary',{name:'Selected 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 select=async(page,key)=>page.locator(`article[data-image-key="${key}"]`).getByRole('button',{name:/^Inspect /}).click();
|
||||
const dialog=page=>page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
|
||||
|
||||
test.beforeEach(async({page,request,browserName})=>{
|
||||
@@ -45,10 +45,10 @@ test('collection viewer rejects stale source and sends only refreshed API eviden
|
||||
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});
|
||||
await page.locator('article[data-image-key="0:shared/same.png"] .image-open').click();const preview=page.locator('dialog#viewer-dialog');
|
||||
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});
|
||||
const original=preview.locator('#viewer-toolbar').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();
|
||||
@@ -58,8 +58,8 @@ test('collection viewer rejects stale source and sends only refreshed API eviden
|
||||
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.');
|
||||
await select(page,'0:shared/same.png');await page.locator('.media-card.selected .image-open').click();await expect(preview).toContainText(evidence.prompts.positive.text);
|
||||
await preview.getByRole('button',{name:/^Send prompt pair to ComfyUI: /}).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();}
|
||||
});
|
||||
@@ -67,17 +67,18 @@ test('collection viewer rejects stale source and sends only refreshed API eviden
|
||||
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 expect(details(page).getByRole('button',{name:/^Send prompt pair to ComfyUI: /})).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 expect(role(details(page),'Negative').locator('p.prompt-text')).toHaveText('No negative prompt (explicitly empty)');
|
||||
await expect(details(page).getByRole('button',{name:/^Send negative prompt to ComfyUI: /})).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);
|
||||
// Every inspector send is named after its prompt ("…to ComfyUI: <title>"); an untitled one ends at "ComfyUI".
|
||||
await expect(details(page).getByRole('button',{name:/^Send (positive|negative) prompt to ComfyUI(: |$)/})).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('inspection failures retry and a delayed previous selection cannot replace the current prompt',async({page})=>{
|
||||
@@ -100,7 +101,7 @@ test('inspection failures retry and a delayed previous selection cannot replace
|
||||
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();
|
||||
await page.goto(workspace);await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:/^Send prompt pair to ComfyUI: /}).click();
|
||||
const sender=dialog(page);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);
|
||||
@@ -113,10 +114,10 @@ test('sender requires explicit destination, previews full changes and acknowledg
|
||||
await page.screenshot({path:testInfo.outputPath('inspector-dark.png'),fullPage:true});
|
||||
await page.getByRole('combobox',{name:'Theme',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.locator('.media-card.selected .image-open').click();
|
||||
await expect(page.locator('dialog#viewer-dialog')).toBeVisible();
|
||||
await page.screenshot({path:testInfo.outputPath('viewer-prompts.png')});
|
||||
await page.getByRole('button',{name:'Close preview',exact:true}).click();
|
||||
await page.getByRole('button',{name:'Close image viewer',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();}
|
||||
@@ -137,7 +138,7 @@ test('ComfyUI ↗ opens the pair send for a selected resolved image, else follow
|
||||
await select(page,'0:uncertain.png');await expect(details(page)).toContainText('Not recorded in this file.');
|
||||
await link.click();await expect(dialog(page)).toHaveCount(0);
|
||||
expect(await page.evaluate(()=>window.followed)).toEqual([true,true]);
|
||||
await select(page,'0:shared/same.png');await expect(details(page).getByRole('button',{name:'Send prompt pair',exact:true})).toBeEnabled();
|
||||
await select(page,'0:shared/same.png');await expect(details(page).getByRole('button',{name:/^Send prompt pair to ComfyUI: /})).toBeEnabled();
|
||||
await link.click();
|
||||
const sender=await chooseDestination(page);
|
||||
expect(await page.evaluate(()=>window.followed)).toEqual([true,true,false]);
|
||||
@@ -157,7 +158,7 @@ test('ComfyUI ↗ opens the pair send for a selected resolved image, else follow
|
||||
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();
|
||||
await page.goto(workspace);await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:/^Send positive prompt to ComfyUI: /}).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();
|
||||
@@ -167,9 +168,9 @@ test('host apply rejection retains the current text and requires a new preview',
|
||||
|
||||
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();
|
||||
await page.locator('.media-card.selected .image-open').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();
|
||||
const send=viewer.getByRole('button',{name:/^Send positive prompt to ComfyUI: /});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);
|
||||
});
|
||||
@@ -178,7 +179,7 @@ test('empty negative send clears only the negative destination and preserves its
|
||||
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();
|
||||
await details(page).getByRole('button',{name:/^Send negative prompt to ComfyUI: /}).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:''});
|
||||
@@ -187,7 +188,7 @@ test('empty negative send clears only the negative destination and preserves its
|
||||
|
||||
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();
|
||||
await details(page).getByRole('button',{name:/^Send positive prompt to ComfyUI: /}).click();
|
||||
const sender=dialog(page);await expect(sender).toContainText('No compatible ComfyUI windows found.');
|
||||
const host=await fakeHost(context,page);
|
||||
try {
|
||||
@@ -203,12 +204,13 @@ test('mobile inspector exposes full long prompts, truthful uncertainty and copy
|
||||
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);
|
||||
const positive=role(details(page),'Positive');await expect(positive.locator('p.prompt-text')).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.');
|
||||
await expect(details(page).getByRole('button',{name:/^Send negative prompt to ComfyUI: /})).toHaveCount(0);
|
||||
await positive.getByRole('button',{name:/^Copy positive prompt: /}).click();await expect(page.getByRole('dialog',{name:'Copy prompt',exact:true})).toBeVisible();
|
||||
await page.getByRole('button',{name:'Close dialog',exact:true}).click();
|
||||
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);
|
||||
await positive.getByRole('button',{name:'Send positive prompt',exact:true}).click();
|
||||
await positive.getByRole('button',{name:/^Send positive prompt to ComfyUI: /}).click();
|
||||
await expect(dialog(page)).toBeVisible();const bounds=await dialog(page).boundingBox();
|
||||
expect(bounds.x).toBeGreaterThanOrEqual(0);expect(bounds.x+bounds.width).toBeLessThanOrEqual(391);
|
||||
});
|
||||
@@ -227,8 +229,9 @@ test('filtering away a selected image aborts its pending metadata request',async
|
||||
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 page.getByRole('searchbox',{name:'Search workspace',exact:true}).fill('no-such-file');
|
||||
await expect(page.getByText('No matching images · 0 images',{exact:true})).toBeVisible();
|
||||
await expect(page.locator('.gallery-body > .empty')).toHaveText('No matching images. Try another search or category.');
|
||||
await expect.poll(()=>page.evaluate(()=>window.inspectionAborts),{timeout:1500}).toBeGreaterThan(baseline);
|
||||
} finally {release();}
|
||||
});
|
||||
@@ -247,10 +250,10 @@ test('responsive gallery defers observed size writes and still fits the new view
|
||||
};
|
||||
});
|
||||
await page.setViewportSize({width:1280,height:900});await page.goto(workspace);
|
||||
await expect(page.locator('.image-card')).toHaveCount(4);
|
||||
await expect(page.locator('.media-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);
|
||||
await expect.poll(()=>page.locator('.media-card').first().evaluate(node=>node.getBoundingClientRect().width)).toBeLessThan(390);
|
||||
await expect.poll(()=>page.locator('.image-grid').evaluate(node=>Number.parseFloat(node.style.height))).toBeCloseTo(384.4,0); // the concept's 1.33 media plus the measured info row
|
||||
expect(await page.evaluate(()=>window.inlineResizeWrites)).toBe(0);
|
||||
});
|
||||
|
||||
@@ -276,20 +279,37 @@ test('unresolved roles name the blocking node as text and offer no prompt action
|
||||
await expect(details(page).getByRole('button',{name:/^(Send|Copy) /})).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('prompt role headings contain compact real actions and honest source evidence',async({page})=>{
|
||||
test('prompt role headings contain the concept label and compact real actions without a source line',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();
|
||||
for (const [name,label] of [['Positive','+ Positive prompt'],['Negative','\u2212 Negative prompt']]) {
|
||||
const block=role(details(page),name),heading=block.locator('.prompt-heading');
|
||||
await expect(block).toHaveClass(new RegExp(`^prompt-section ${name.toLowerCase()}$`));
|
||||
await expect(heading.locator('span.role-label')).toHaveText(label);
|
||||
await expect(heading.locator('.prompt-actions button.icon-btn').first()).toHaveAccessibleName(new RegExp(`^Copy ${name.toLowerCase()} prompt: same\\.png$`));
|
||||
await expect(heading.locator('.prompt-actions button.send.icon-btn')).toHaveAccessibleName(new RegExp(`^Send ${name.toLowerCase()} prompt to ComfyUI: same\\.png$`));
|
||||
await expect(heading.locator('[data-copy-role] svg')).toHaveCount(1);
|
||||
expect(await heading.evaluate(node=>node.nextElementSibling.classList.contains('prompt-source'))).toBe(true);
|
||||
expect(await heading.evaluate(node=>node.nextElementSibling.matches('p.prompt-text')&&!node.nextElementSibling.nextElementSibling)).toBe(true);
|
||||
}
|
||||
await expect(role(details(page),'Positive').locator('.prompt-source')).toHaveText('Inferred from the saved workflow');
|
||||
await expect(details(page)).not.toContainText('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 expect(role(details(page),'Negative').locator('p.prompt-text')).toHaveText('No negative prompt (explicitly empty)');
|
||||
await expect(role(details(page),'Negative').locator('.prompt-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.');
|
||||
await expect(details(page).locator('p.prompt-text.muted').first()).toHaveText('Not recorded in this file.');
|
||||
});
|
||||
|
||||
test('copy toast and fallback dialog',async({page})=>{
|
||||
await page.addInitScript(()=>{window.copied=[];window.clipboardFails=false;Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>{if(window.clipboardFails)throw new Error('Permission denied');window.copied.push(text);}}});});
|
||||
await page.goto(workspace);await select(page,'0:shared/same.png');
|
||||
const positive=role(details(page),'Positive'),text=await positive.locator('p.prompt-text').textContent();
|
||||
const copy=positive.getByRole('button',{name:/^Copy positive prompt: /});await copy.click();
|
||||
await expect(page.locator('#workspace-toast')).toHaveText('Prompt copied.');expect(await page.evaluate(()=>window.copied)).toEqual([text]);
|
||||
await expect(positive.getByRole('status')).toHaveCount(0);
|
||||
await page.evaluate(()=>{window.clipboardFails=true;});await copy.click();
|
||||
const fallback=page.getByRole('dialog',{name:'Copy prompt',exact:true});await expect(fallback).toBeVisible();
|
||||
const area=fallback.getByRole('textbox',{name:'Select and copy this text',exact:true});
|
||||
await expect(area).toHaveValue(text);await expect(area).toBeFocused();
|
||||
expect(await area.evaluate(node=>[node.readOnly,node.selectionStart,node.selectionEnd])).toEqual([true,0,text.length]);
|
||||
await expect(fallback).toContainText('Automatic clipboard access requires a secure browser context.');
|
||||
await page.keyboard.press('Escape');await expect(fallback).toBeHidden();await expect(copy).toBeFocused();
|
||||
});
|
||||
|
||||
+230
-13
@@ -1,21 +1,27 @@
|
||||
"""Serve production pages and API routes with disposable state for browser tests."""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import types
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.web_runner import GracefulExit
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
from tests.browser.workspace_activity_fixture import register_activity_commands
|
||||
from tests.browser.workspace_concept_fixture import (
|
||||
MAX_EXTRA_IMAGES as CONCEPT_MAX_EXTRA_IMAGES,
|
||||
MAX_SEEDLESS_IMAGES as CONCEPT_MAX_SEEDLESS_IMAGES,
|
||||
seed_workspace_concept,
|
||||
)
|
||||
from tests.browser.workspace_visual_fixture import seed_workspace_visual
|
||||
@@ -29,6 +35,7 @@ from tests.browser.workspace_library_editor_fixture import (
|
||||
register_library_editor_commands,
|
||||
)
|
||||
from tests.browser.workspace_vocabulary_fixture import register_vocabulary_commands
|
||||
from tests.browser.workspace_tagging_fixture import register_tagging_commands
|
||||
|
||||
CANONICAL_ROOT = Path(__file__).resolve().parents[2]
|
||||
CANONICAL_ASSETS = {
|
||||
@@ -51,11 +58,19 @@ class TestModel:
|
||||
wd14_character_threshold = 0.85
|
||||
custom_prompt = ""
|
||||
loaded = None
|
||||
# Models the Tag image dialog workflows mark as not downloaded.
|
||||
unavailable: set = set()
|
||||
# Scored names the concept comparisons answer instead of the fixed list
|
||||
# (``/__test__/tagging/suggestions``); None keeps the fixed list.
|
||||
suggestions: list | None = None
|
||||
|
||||
def get_models_status(self) -> dict:
|
||||
return {
|
||||
name: {"downloaded": True, "loaded": self.loaded == name}
|
||||
for name in ("gguf", "wd14-vit", "wd14-convnext", "wd14-swinv2")
|
||||
name: {
|
||||
"downloaded": name not in self.unavailable,
|
||||
"loaded": self.loaded == name,
|
||||
}
|
||||
for name in ("gguf", "hf", "wd14-vit", "wd14-convnext", "wd14-swinv2")
|
||||
}
|
||||
|
||||
def is_model_loaded(self) -> bool:
|
||||
@@ -74,6 +89,73 @@ class TestModel:
|
||||
def generate_tags(self, image_path: str, **kwargs: object) -> list[str]:
|
||||
return ["generated", Path(image_path).parent.parent.name]
|
||||
|
||||
def generate_tag_suggestions(
|
||||
self, image_path: str, *, limit: int, **kwargs: object
|
||||
) -> list[tuple[str, float | None]]:
|
||||
"""Fixed scored suggestions (WD14) or unscored ones (language models)."""
|
||||
scored = self.loaded is not None and self.loaded.startswith("wd14")
|
||||
if self.suggestions is not None:
|
||||
return [
|
||||
(name, confidence if scored else None)
|
||||
for name, confidence in self.suggestions[:limit]
|
||||
]
|
||||
names = ["sandstone", "desert", "arch", "sky", "rock", "dune", "canyon"]
|
||||
names += [f"tag_{index:02d}" for index in range(100)]
|
||||
return [
|
||||
(name, round(0.98 - index * 0.008, 3) if scored else None)
|
||||
for index, name in enumerate(names[:limit])
|
||||
]
|
||||
|
||||
def download_model(self, model_type: str, progress_callback=None) -> bool:
|
||||
raise AssertionError("browser tests never download a model")
|
||||
|
||||
|
||||
REVEAL_OUTCOMES = ("opened", "unavailable", "fail")
|
||||
|
||||
|
||||
def register_reveal_stub(routes, api, root: Path):
|
||||
"""D38: Show in folder never opens a real file manager here.
|
||||
|
||||
The API's opener records the resolved path; ``POST /__test__/reveal``
|
||||
sets the next outcome and ``GET /__test__/reveal`` lists the opened paths.
|
||||
"""
|
||||
from utils.file_reveal import OpenerUnavailable, RevealGate
|
||||
|
||||
state = {"outcome": "opened", "opened": []}
|
||||
|
||||
def opener(path: Path, gallery_root: Path) -> None:
|
||||
if state["outcome"] == "unavailable":
|
||||
raise OpenerUnavailable()
|
||||
if state["outcome"] == "fail":
|
||||
raise OSError("stubbed start failure")
|
||||
state["opened"].append(Path(path).relative_to(root.resolve()).as_posix())
|
||||
|
||||
api._reveal_opener = opener
|
||||
# Each click gets an answer here; the cooldown is a unit-tested default.
|
||||
api._reveal_gate = RevealGate(cooldown=0)
|
||||
|
||||
@routes.post("/__test__/reveal")
|
||||
async def set_reveal_outcome(request: web.Request) -> web.Response:
|
||||
data = await request.json() if request.can_read_body else None
|
||||
if (
|
||||
not isinstance(data, dict)
|
||||
or set(data) != {"outcome"}
|
||||
or data["outcome"] not in REVEAL_OUTCOMES
|
||||
):
|
||||
raise web.HTTPBadRequest()
|
||||
state["outcome"] = data["outcome"]
|
||||
return web.json_response({"success": True})
|
||||
|
||||
@routes.get("/__test__/reveal")
|
||||
async def reveal_calls(request: web.Request) -> web.Response:
|
||||
return web.json_response({"opened": state["opened"]})
|
||||
|
||||
def reset() -> None:
|
||||
state["outcome"] = "opened"
|
||||
state["opened"].clear()
|
||||
|
||||
return types.SimpleNamespace(reset=reset)
|
||||
|
||||
|
||||
def create_app(root: Path) -> web.Application:
|
||||
os.environ["PROMPT_MANAGER_CONFIG_PATH"] = str(root / "config.json")
|
||||
@@ -110,6 +192,9 @@ def create_app(root: Path) -> web.Application:
|
||||
history_commands = register_workspace_history_commands(routes, api, root)
|
||||
library_editor_commands = register_library_editor_commands(routes, api, root)
|
||||
register_vocabulary_commands(routes, api, root)
|
||||
register_tagging_commands(routes, api, root, model)
|
||||
reveal_stub = register_reveal_stub(routes, api, root)
|
||||
register_activity_commands(app, routes, api)
|
||||
|
||||
async def canonical_asset(request: web.Request) -> web.Response:
|
||||
filename, content_type = CANONICAL_ASSETS[request.path]
|
||||
@@ -132,6 +217,11 @@ def create_app(root: Path) -> web.Application:
|
||||
) -> None:
|
||||
with api.db.model.get_connection() as conn:
|
||||
for table in (
|
||||
"saved_views",
|
||||
"image_favorites",
|
||||
"media_image_tags",
|
||||
"media_annotation_bytes",
|
||||
"media_annotation_files",
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
"prompt_pairs",
|
||||
@@ -309,6 +399,7 @@ def create_app(root: Path) -> web.Application:
|
||||
history_fixture = manifest
|
||||
return web.json_response({"success": True, **manifest})
|
||||
history_commands.reset()
|
||||
reveal_stub.reset()
|
||||
workspace_visual = data.get("workspace_visual", False)
|
||||
full_inspector = data.get("full_inspector", False)
|
||||
extra = data.get("extra_images", 0)
|
||||
@@ -388,25 +479,61 @@ def create_app(root: Path) -> web.Application:
|
||||
"""Seed the concept-shaped fixture (D30); only a bounded image count."""
|
||||
nonlocal history_fixture
|
||||
data = await request.json() if request.can_read_body else {}
|
||||
limits = {
|
||||
"extra_images": CONCEPT_MAX_EXTRA_IMAGES,
|
||||
"seedless_images": CONCEPT_MAX_SEEDLESS_IMAGES,
|
||||
}
|
||||
if (
|
||||
request.query
|
||||
or not isinstance(data, dict)
|
||||
or set(data) - {"extra_images"}
|
||||
or isinstance(data.get("extra_images", 0), bool)
|
||||
or not isinstance(data.get("extra_images", 0), int)
|
||||
or not 0 <= data.get("extra_images", 0) <= CONCEPT_MAX_EXTRA_IMAGES
|
||||
or set(data) - set(limits) - {"short_seedless"}
|
||||
or not isinstance(data.get("short_seedless", False), bool)
|
||||
or any(
|
||||
isinstance(data.get(key, 0), bool)
|
||||
or not isinstance(data.get(key, 0), int)
|
||||
or not 0 <= data.get(key, 0) <= limit
|
||||
for key, limit in limits.items()
|
||||
)
|
||||
):
|
||||
raise web.HTTPBadRequest()
|
||||
history_commands.reset()
|
||||
library_editor_commands.reset()
|
||||
reveal_stub.reset()
|
||||
history_fixture = None
|
||||
manifest = await api._run_in_executor(
|
||||
lambda: seed_workspace_concept(
|
||||
api, root, extra_images=data.get("extra_images", 0)
|
||||
api,
|
||||
root,
|
||||
extra_images=data.get("extra_images", 0),
|
||||
seedless_images=data.get("seedless_images", 0),
|
||||
short_seedless=data.get("short_seedless", False),
|
||||
)
|
||||
)
|
||||
return web.json_response({"success": True, **manifest})
|
||||
|
||||
@routes.post("/__test__/saved-views/raw")
|
||||
async def insert_raw_saved_view(request: web.Request) -> web.Response:
|
||||
"""Store a view row as an older build wrote it, bypassing the API's rules."""
|
||||
data = await request.json()
|
||||
if (
|
||||
not isinstance(data, dict)
|
||||
or set(data) != {"page", "name", "state"}
|
||||
or not all(isinstance(data[key], str) for key in data)
|
||||
):
|
||||
raise web.HTTPBadRequest()
|
||||
|
||||
def insert() -> int:
|
||||
with api.db.model.get_connection() as conn:
|
||||
return conn.execute(
|
||||
"INSERT INTO saved_views (page, name, state, position, "
|
||||
"created_at, updated_at) VALUES (?, ?, ?, 0, 'x', 'x')",
|
||||
(data["page"], data["name"], data["state"]),
|
||||
).lastrowid
|
||||
|
||||
return web.json_response(
|
||||
{"success": True, "id": await api._run_in_executor(insert)}
|
||||
)
|
||||
|
||||
@routes.post("/__test__/add-nested-image")
|
||||
async def add_nested_image(request: web.Request) -> web.Response:
|
||||
destination = root / "output" / "project" / "day" / "new-output.png"
|
||||
@@ -414,6 +541,33 @@ def create_app(root: Path) -> web.Application:
|
||||
shutil.copyfile(root / "output" / "shared" / "same.png", destination)
|
||||
return web.json_response({"success": True})
|
||||
|
||||
@routes.post("/__test__/add-local-inspect-file")
|
||||
async def add_local_inspect_file(request: web.Request) -> web.Response:
|
||||
"""Serve one committed Inspect fixture from the output folder.
|
||||
|
||||
The local Inspect page reads a chosen file in the browser; its page
|
||||
workflow compares that rail with the Images viewer for the same bytes.
|
||||
Only a committed fixture name is accepted.
|
||||
"""
|
||||
data = await request.json() if request.can_read_body else None
|
||||
fixtures = CANONICAL_ROOT / "tests" / "fixtures"
|
||||
allowed = {
|
||||
path.name: path
|
||||
for folder in ("inspection_parity", "local-inspect")
|
||||
for path in (fixtures / folder).iterdir()
|
||||
if path.suffix.lower() in {".png", ".jpg", ".webp", ".webm", ".mp4"}
|
||||
}
|
||||
if (
|
||||
not isinstance(data, dict)
|
||||
or set(data) != {"name"}
|
||||
or data["name"] not in allowed
|
||||
):
|
||||
raise web.HTTPBadRequest()
|
||||
destination = root / "output" / "local-inspect" / data["name"]
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(allowed[data["name"]], destination)
|
||||
return web.json_response({"success": True})
|
||||
|
||||
@routes.get("/api/settings/Comfy.ColorPalette")
|
||||
async def palette(request: web.Request) -> web.Response:
|
||||
return web.json_response("dark")
|
||||
@@ -427,14 +581,77 @@ def create_app(root: Path) -> web.Application:
|
||||
return app
|
||||
|
||||
|
||||
def main() -> None:
|
||||
HOST = "127.0.0.1"
|
||||
PORT_ANNOUNCEMENT = "PM_TEST_SERVER_PORT="
|
||||
|
||||
|
||||
def bind_listener(port: int) -> socket.socket:
|
||||
"""Bind the server's socket before the app is built.
|
||||
|
||||
``port`` 0 lets the OS choose a free port, which this process then holds,
|
||||
so parallel launchers never race for one between choosing and binding.
|
||||
"""
|
||||
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
try:
|
||||
if port and os.name != "nt":
|
||||
# aiohttp's own default on POSIX; on Windows it would let a
|
||||
# second process share the port.
|
||||
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
listener.bind((HOST, port))
|
||||
listener.listen(128)
|
||||
except OSError:
|
||||
listener.close()
|
||||
raise
|
||||
return listener
|
||||
|
||||
|
||||
def _graceful_exit() -> None:
|
||||
raise GracefulExit()
|
||||
|
||||
|
||||
def exit_when_stdin_closes(loop: asyncio.AbstractEventLoop) -> threading.Thread:
|
||||
"""Stop the app gracefully once stdin reaches end of file.
|
||||
|
||||
The launcher keeps the write end of this process's stdin pipe, and the OS
|
||||
closes it however the launcher dies, SIGKILL included. GracefulExit runs
|
||||
aiohttp's cleanup (closing the database) and lets the temp root be removed,
|
||||
on every OS, where a signal from a dead parent would not.
|
||||
"""
|
||||
|
||||
def watch() -> None:
|
||||
while sys.stdin.buffer.read(65536):
|
||||
pass
|
||||
loop.call_soon_threadsafe(_graceful_exit)
|
||||
|
||||
thread = threading.Thread(target=watch, name="stdin-eof", daemon=True)
|
||||
thread.start()
|
||||
return thread
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--port", type=int, default=8765)
|
||||
args = parser.parse_args()
|
||||
parser.add_argument(
|
||||
"--port", type=int, default=8765, help="0 binds an OS-chosen free port"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--exit-on-stdin-eof",
|
||||
action="store_true",
|
||||
help="shut down when stdin closes (the launcher's parent-death guard)",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
listener = bind_listener(args.port)
|
||||
# One machine-readable line tells the launcher which port this process holds.
|
||||
print(f"{PORT_ANNOUNCEMENT}{listener.getsockname()[1]}", flush=True)
|
||||
loop = asyncio.new_event_loop()
|
||||
if args.exit_on_stdin_eof:
|
||||
exit_when_stdin_closes(loop)
|
||||
original_cwd = os.getcwd()
|
||||
with tempfile.TemporaryDirectory(prefix="pm-browser-") as directory:
|
||||
web.run_app(
|
||||
create_app(Path(directory).resolve()), host="127.0.0.1", port=args.port
|
||||
)
|
||||
try:
|
||||
web.run_app(create_app(Path(directory).resolve()), sock=listener, loop=loop)
|
||||
finally:
|
||||
# create_app works inside the root; Windows cannot remove a process's cwd.
|
||||
os.chdir(original_cwd)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,focusRingOf,hoverStateOf,mutate} from './concept-inventory.mjs';
|
||||
import {SIZES,THEMES,MOTIONS,seedConcept,openConcept as openConceptPage,openProduction,inventoryOf,diffInventory,focusRingOf,hoverStateOf,mutate} from './concept-inventory.mjs';
|
||||
|
||||
// Task A6 of #249: the production shell (sidebar, topbar, heading, footer) against the immutable concept, with the
|
||||
// concept-shaped real fixture so the sidebar counts are the concept's own (24 images, 6 prompts, 15 tags). Both themes,
|
||||
@@ -16,7 +16,16 @@ const FULL_HEADING=new Set(['tags']);
|
||||
|
||||
const rowNamed=(nav,text)=>nav.rows.find(row=>row.text===text);
|
||||
|
||||
const removedItem=item=>String(item.className).includes('concept-badge')||(item.tag==='button'&&item.text.startsWith('Activity'));
|
||||
const removedItem=item=>String(item.className).includes('concept-badge');
|
||||
|
||||
/**
|
||||
* The concept with its Activity count hidden (D52): production counts running jobs only and hides the count at 0, as
|
||||
* the comparison fixture has none. The concept's own CSS then lays out its button without the count.
|
||||
*/
|
||||
async function openConcept(page,route,theme,motion) {
|
||||
await openConceptPage(page,route,theme,motion);
|
||||
await mutate(page,{css:'#activity-count { display: none }'});
|
||||
}
|
||||
|
||||
/** Left offsets of flex items packed along a row of `width` by its `justify-content` and `gap`. */
|
||||
function packedLefts(items,width,justify,gap) {
|
||||
@@ -37,10 +46,12 @@ function remainingTopActions(actions) {
|
||||
const gap=parseFloat(actions.columnGap);
|
||||
// A full-width row (<=700px) keeps its width; otherwise it shrinks to its remaining items.
|
||||
const width=actions.fullWidth?actions.width:shown.reduce((sum,item)=>sum+item.width,0)+gap*(shown.length-1);
|
||||
// At <=700px the concept spreads three items with `space-between`, so its Theme select sits in the middle; with
|
||||
// Activity removed (D5) the same rule puts Theme at the row's start. Offsets come from the concept's own sizes.
|
||||
// At <=700px the concept spreads its shown items with `space-between`; the hidden pill (D53) takes no place.
|
||||
// Offsets come from the concept's own sizes.
|
||||
const lefts=new Map(shown.map((item,index)=>[item,packedLefts(shown,width,actions.justifyContent,gap)[index]]));
|
||||
return {...actions,height,width,
|
||||
// D52: the full-width row (<=700px) wraps its items when a zoomed phone is narrower than they are.
|
||||
flexWrap:actions.fullWidth?'wrap':actions.flexWrap,
|
||||
items:kept.map(item=>item.shown?{...item,top:Math.round((height-item.height)/2),left:lefts.get(item)}:item)};
|
||||
}
|
||||
|
||||
@@ -53,20 +64,32 @@ function remainingFooter(footer,spans) {
|
||||
}
|
||||
|
||||
// D28 ruling (final review A, Minor 1): Overview gets a distinct outline home glyph. The concept has no home icon, and
|
||||
// its `activity` pulse stays reserved for the Activity row D5 omits, so the two entries never share one glyph.
|
||||
// its `activity` pulse belongs to the Activity row, so the two entries never share one glyph.
|
||||
const OVERVIEW_GLYPH='M3 11 12 4l9 7M5 10v10h14V10M10 20v-6h4v6';
|
||||
|
||||
/**
|
||||
* The concept's SAVED VIEWS rows (app.js a331–335) as production lists them: Favorites with its count (D51), then the
|
||||
* saved views. D50: no "Needs review" row until the tag-review slice (maintainer ruling 2026-10-21).
|
||||
*/
|
||||
function savedNav(nav) {
|
||||
const rows=nav.rows.filter(row=>row.text!=='Needs review');
|
||||
return {...nav,rows:rows.map((row,index)=>index===rows.length-1?{...row,gapAfter:null}:row)};
|
||||
}
|
||||
|
||||
/** Recorded shell deviations only (spec §8). Everything else in the concept inventory must equal production. */
|
||||
function mapShellConcept(reference) {
|
||||
const {chrome}=reference;
|
||||
const navs=chrome.navs
|
||||
// D28: SAVED VIEWS and COLLECTIONS are proposals with no persisted store, so their navs are not rendered.
|
||||
.filter(nav=>nav.id!=='saved-nav'&&nav.id!=='collections-nav')
|
||||
// D28: COLLECTIONS is a proposal with no persisted store, so its nav is not rendered.
|
||||
.filter(nav=>nav.id!=='collections-nav')
|
||||
.map(nav=>{
|
||||
if(nav.id==='saved-nav')return savedNav(nav);
|
||||
if(nav.id!=='system-nav')return nav;
|
||||
const system=rowNamed(nav,'System');
|
||||
return {...nav,rows:[
|
||||
// D5: no Activity row until an activity center exists. D28: no Design review (prototype page).
|
||||
// D52: the Activity row counts running jobs only, so with none it has no count.
|
||||
{...rowNamed(nav,'Activity'),count:null},
|
||||
// D28: no Design review (prototype page).
|
||||
system,
|
||||
// D28: the production Overview hub follows System, as a concept nav row without a count. Its icon is
|
||||
// the ruling's own outline home glyph (OVERVIEW_GLYPH), never the concept's `activity` pulse.
|
||||
@@ -76,27 +99,27 @@ function mapShellConcept(reference) {
|
||||
return {...reference,chrome:{...chrome,
|
||||
// D29: no prototype suffix in the document title.
|
||||
title:chrome.title.replace(/ · Prompt Manager concept$/,' · Prompt Manager'),
|
||||
// D28: only the WORKSPACE group label remains.
|
||||
labels:chrome.labels.filter(label=>label.text==='WORKSPACE'),
|
||||
// D28: no COLLECTIONS label. D49: SAVED VIEWS without its "PROPOSED" badge.
|
||||
labels:chrome.labels.filter(label=>label.text!=='COLLECTIONS PROPOSED')
|
||||
.map(label=>label.text==='SAVED VIEWS PROPOSED'?{...label,text:'SAVED VIEWS'}:label),
|
||||
navs,
|
||||
connection:{...chrome.connection,
|
||||
// D28: truthful copy instead of "Demo connection · Classic / Nodes 2.0".
|
||||
small:'Your library, on your server',
|
||||
// D28: no fake connection state, so the dot is the neutral muted token (the concept's label color).
|
||||
dot:{...chrome.connection.dot,backgroundColor:chrome.labels[0].color}},
|
||||
// D29: no "Interactive concept" badge. D5: no Activity button. The row's size and the remaining items'
|
||||
// centered offsets follow from the concept's own item sizes.
|
||||
topActions:remainingTopActions(chrome.topActions),
|
||||
// D29: no "Interactive concept" badge. D53: the progress pill before the Activity button, hidden with no running
|
||||
// job. D52: the button's count is hidden at 0. The row's size and the remaining items' centered offsets follow
|
||||
// from the concept's own item sizes.
|
||||
topActions:remainingTopActions({...chrome.topActions,items:chrome.topActions.items.flatMap(item=>
|
||||
item.tag==='button'&&item.text==='Activity'?[{tag:'a',className:'activity-pill',shown:false},{...item,count:'0'}]:[item])}),
|
||||
footer:(()=>{
|
||||
// D29: production footer copy (one line, as the concept's own copy is), and no "What changes & why"
|
||||
// (a prototype review page); the footer then holds only its span.
|
||||
const spans=chrome.footer.spans.map(span=>({...span,text:'ComfyAssets · Local tools. Your library.'}));
|
||||
return {...chrome.footer,spans,buttons:[],height:remainingFooter(chrome.footer,spans)};
|
||||
})(),
|
||||
},
|
||||
heading:reference.heading&&{...reference.heading,actions:reference.heading.actions&&{...reference.heading.actions,
|
||||
// #245 vocabulary ruling D2 (mapVocabularyConcept): Auto-tag images is omitted until the image-annotation slice.
|
||||
buttons:reference.heading.actions.buttons.filter(item=>item.text!=='Auto-tag images')}}};
|
||||
}};
|
||||
}
|
||||
|
||||
/** The heading without its page-owned description copy and actions; the h1 text is the shell's and stays compared. */
|
||||
@@ -199,6 +222,30 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`shell geometry
|
||||
}
|
||||
});
|
||||
|
||||
// Saved views (#249 D16): a view saved from the Images toolbar adds the concept's own saved row (search icon, name, no
|
||||
// count) to SAVED VIEWS. The concept's demo action reads "Apply in prototype"; production's is "Save" (D29).
|
||||
for(const [width,height] of SIZES)test(`a saved view row matches the concept's ${width}x${height}`,async({page})=>{
|
||||
await page.setViewportSize({width,height});
|
||||
const save=async(action,mainSelector)=>{
|
||||
await page.locator(`${mainSelector} .toolbar`).getByRole('button',{name:'Save view',exact:true}).click();
|
||||
const dialog=page.getByRole('dialog',{name:'Save this view',exact:true});
|
||||
await expect(dialog.getByRole('textbox',{name:'View name',exact:true})).toHaveValue('My workspace view');
|
||||
await dialog.getByRole('textbox',{name:'View name',exact:true}).fill('Golden hour');
|
||||
await dialog.getByRole('button',{name:action,exact:true}).click();
|
||||
await expect(page.locator('#saved-nav').getByRole('link',{name:'Golden hour',exact:true})).toHaveCount(1);
|
||||
await page.mouse.move(0,0);
|
||||
return inventoryOf(page,mainSelector);
|
||||
};
|
||||
await openConcept(page,'images','dark');
|
||||
const reference=await save('Apply in prototype','#main');
|
||||
expect(reference.chrome.navs.find(nav=>nav.id==='saved-nav').rows.map(row=>row.text)).toEqual(['Favorites','Needs review','Golden hour']);
|
||||
await openProduction(page,'images','dark');
|
||||
const actual=await save('Save','#workspace-main');
|
||||
expect(diffInventory(mapShellConcept(reference).chrome.navs,actual.chrome.navs)).toEqual([]);
|
||||
expect(actual.chrome.navs.find(nav=>nav.id==='saved-nav').rows.map(row=>[row.text,row.count]))
|
||||
.toEqual([['Favorites',reference.chrome.navs.find(nav=>nav.id==='saved-nav').rows[0].count],['Golden hour',null]]);
|
||||
});
|
||||
|
||||
test('the inventory reports a missing shell element by its path instead of failing',async({page})=>{
|
||||
await page.setViewportSize({width:1600,height:1100});
|
||||
await openConcept(page,'tags','dark');await page.mouse.move(0,0);
|
||||
@@ -220,13 +267,13 @@ test('the concept fixture renders the concept inspector facts',async({page})=>{
|
||||
await page.setViewportSize({width:1600,height:1100});
|
||||
await openProduction(page,'images','dark');
|
||||
// The first image is the concept's first sample, selected into the panel on first load (D19).
|
||||
const facts=page.locator('#workspace-main .generation-facts');
|
||||
const facts=page.locator('#workspace-main #image-inspector .meta-grid');
|
||||
await expect(facts).toBeVisible();
|
||||
const pairs=await facts.evaluate(list=>Object.fromEntries([...list.querySelectorAll('dt')].map(term=>[term.textContent.trim(),term.nextElementSibling.textContent.trim()])));
|
||||
// app.js:404-408: the concept's facts for alpine_00001.png.
|
||||
expect(pairs).toEqual({Seed:'42001',Steps:'28',CFG:'6.5',Sampler:'dpmpp_2m',Scheduler:'karras',Model:'illustrative-model.safetensors'});
|
||||
// D21 maps execution evidence to "Execution recorded" in PR D; today's panel names the same source this way.
|
||||
await expect(page.locator('#workspace-main').getByText('Execution prompt evidence',{exact:true})).toBeVisible();
|
||||
// D21: execution evidence is the concept's "Execution recorded" badge.
|
||||
await expect(page.locator('#image-inspector span.chip.blue')).toHaveText('Execution recorded');
|
||||
});
|
||||
|
||||
// Task A7: the comparison is only worth something if it notices a change. One CSS mutation (a nav-row height the
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// Concept-vs-production inventory of the Tag image dialog (plan Mock inventory M2: docs/concepts/workspace/app.js
|
||||
// tagReview a1037–1061, renderTagModelOptions a1062–1076, generateTags a1077–1135; style.css s1598–1694) and of the
|
||||
// card's Info and Tag group (M1: app.js mediaCard a398, style.css s1589–1597). The *Of functions run in the page.
|
||||
// The concept is never rewritten: mapConceptTagging applies only the recorded deviations, each named with its D-number
|
||||
// or ruling; everything else must be equal.
|
||||
export {diffInventory as diffTaggingInventory} from './library-editor-canonical-comparison.mjs';
|
||||
|
||||
/** The open Tag image dialog (`dialog#modal`), its controls in order with their concept tokens. */
|
||||
export function taggingInventory(dialog) {
|
||||
const css=(node,...keys)=>{const style=getComputedStyle(node);return Object.fromEntries(keys.map(key=>[key,style[key]]));};
|
||||
const px=value=>Math.round(value*2)/2;
|
||||
const box=node=>{const rect=node.getBoundingClientRect();return {width:px(rect.width),height:px(rect.height)};};
|
||||
const font=node=>css(node,'fontSize','fontWeight','lineHeight','color','marginTop','marginBottom');
|
||||
const edges=node=>css(node,'paddingTop','paddingRight','paddingBottom','paddingLeft','borderTopWidth','borderTopStyle','borderTopColor',
|
||||
'borderTopLeftRadius','backgroundColor');
|
||||
const buttonOf=node=>({text:node.textContent.trim(),label:node.getAttribute('aria-label'),primary:node.classList.contains('ca-primary'),
|
||||
icon:node.querySelector('svg')!==null,disabled:node.getAttribute('aria-disabled')==='true'||node.disabled,
|
||||
...css(node,'minHeight','fontSize','color','backgroundColor','borderTopColor','borderTopLeftRadius')});
|
||||
const controlOf=node=>({tag:node.tagName.toLowerCase(),id:node.id,type:node.type??null,value:node.value,
|
||||
min:node.getAttribute('min'),max:node.getAttribute('max'),step:node.getAttribute('step'),
|
||||
describedBy:node.getAttribute('aria-describedby'),
|
||||
options:node.tagName==='SELECT'?[...node.options].map(option=>[option.value,option.textContent]):null,
|
||||
...css(node,'fontSize','minHeight','backgroundColor','color','borderTopColor','borderTopLeftRadius','paddingLeft')});
|
||||
const fieldOf=field=>{const label=field.querySelector('label'),control=field.querySelector('input,textarea,select');
|
||||
return {label:label.textContent,labelled:label.htmlFor===control.id,...css(field,'display','rowGap','marginTop','marginBottom'),control:controlOf(control)};};
|
||||
const header=dialog.querySelector('.dialog-header'),title=header.querySelector('h2'),close=header.querySelector('button');
|
||||
const body=dialog.querySelector('#modal-body');
|
||||
const heading=body.querySelector('.tag-image-heading');
|
||||
const preview=heading.querySelector('.tag-image-preview'),copy=heading.querySelector(':scope > div');
|
||||
const sizeChip=copy.querySelector('.chip');
|
||||
const note=body.querySelector('.tag-scope-note');
|
||||
const manual=body.querySelector('#manual-image-tags'),manualField=manual.closest('.field'),help=body.querySelector('#manual-tags-help');
|
||||
const panel=body.querySelector('.tag-model-panel'),options=panel.querySelector('#image-tag-model-options');
|
||||
const generate=[...panel.querySelectorAll('button')].at(-1);
|
||||
const suggestions=body.querySelector('section.tag-suggestion-panel');
|
||||
const list=suggestions.querySelector('#tag-suggestions'),status=suggestions.querySelector('#tag-generation-status');
|
||||
const count=suggestions.querySelector('#suggestion-count');
|
||||
const actions=body.querySelector('.dialog-actions');
|
||||
// A suggestion is a concept checkbox row (`label.tag-suggestion`) or a production chip (`span.chip.tag-suggestion`).
|
||||
const rows=[...list.querySelectorAll('.tag-suggestion')].map(row=>{
|
||||
const name=row.querySelector('span:not(.tag-confidence)'),confidence=row.querySelector('.tag-confidence');
|
||||
const checkbox=row.querySelector('input[type=checkbox]'),remove=row.querySelector('button');
|
||||
return {name:name.textContent,confidence:confidence.textContent,
|
||||
control:checkbox?{kind:'checkbox',checked:checkbox.checked}:remove?{kind:'remove',label:remove.getAttribute('aria-label')}:null,
|
||||
...css(row,'display','alignItems','borderTopColor','backgroundColor','borderTopWidth','borderTopStyle','borderTopLeftRadius','fontSize','paddingTop','paddingLeft'),
|
||||
confidenceToken:css(confidence,'color','fontSize')};
|
||||
});
|
||||
return {
|
||||
dialog:{...css(dialog,'paddingTop','paddingRight','paddingBottom','paddingLeft','borderTopLeftRadius','backgroundColor','color','overflowY'),
|
||||
width:px(dialog.getBoundingClientRect().width),modal:dialog.matches(':modal'),tagging:dialog.classList.contains('tagging-modal'),
|
||||
labelledBy:document.getElementById(dialog.getAttribute('aria-labelledby'))===title,
|
||||
noHorizontalOverflow:dialog.scrollWidth<=dialog.clientWidth+1},
|
||||
header:{title:{text:title.textContent,...font(title)},close:{label:close.getAttribute('aria-label'),text:close.textContent.trim()}},
|
||||
demo:body.querySelector('.demo-note')?.textContent??null,
|
||||
order:[...body.children].map(node=>node.className),
|
||||
heading:{...css(heading,'display','columnGap','alignItems','marginBottom'),
|
||||
preview:{tag:preview.tagName.toLowerCase(),alt:preview.getAttribute('alt'),...box(preview),...css(preview,'objectFit','borderTopLeftRadius','borderTopWidth','borderTopStyle','borderTopColor','flexShrink')},
|
||||
h3:{text:copy.querySelector('h3').textContent,...font(copy.querySelector('h3'))},
|
||||
filename:{text:copy.querySelector('p.small').textContent,...font(copy.querySelector('p.small'))},
|
||||
size:sizeChip?{text:sizeChip.textContent,...css(sizeChip,'marginTop','fontSize','borderTopColor','borderTopLeftRadius','paddingLeft')}:null},
|
||||
scopeNote:note?{text:note.textContent,...edges(note),...css(note,'borderLeftWidth','borderLeftStyle','borderLeftColor','fontSize','color')}:null,
|
||||
manual:{...fieldOf(manualField),help:{text:help.textContent,...font(help)},described:help.id===manual.getAttribute('aria-describedby')},
|
||||
modelPanel:{...edges(panel),...css(panel,'marginTop'),h3:{text:panel.querySelector('h3').textContent,...font(panel.querySelector('h3'))},
|
||||
fields:[...panel.querySelectorAll('.field')].map(fieldOf),
|
||||
twoCol:options.querySelector('.two-col')?{columns:getComputedStyle(options.querySelector('.two-col')).gridTemplateColumns.split(' ').length,
|
||||
...css(options.querySelector('.two-col'),'display','columnGap')}:null,
|
||||
generate:buttonOf(generate)},
|
||||
suggestions:{labelled:suggestions.getAttribute('aria-labelledby')===suggestions.querySelector('h3').id,...css(suggestions,'marginTop'),
|
||||
title:{text:suggestions.querySelector('h3').textContent,...font(suggestions.querySelector('h3'))},
|
||||
count:{text:count.textContent,...font(count)},
|
||||
copy:list.querySelector(':scope > p.small')?.textContent??null,
|
||||
rows,
|
||||
status:{text:status.textContent,role:status.getAttribute('role'),...css(status,'marginTop','fontSize','color')}},
|
||||
actions:{...css(actions,'position','bottom','marginLeft','marginRight','marginBottom','paddingTop','paddingLeft','backgroundColor',
|
||||
'borderTopWidth','borderTopStyle','borderTopColor','zIndex','display','justifyContent','columnGap'),
|
||||
buttons:[...actions.querySelectorAll('button')].map(buttonOf)},
|
||||
noPageScroll:document.documentElement.scrollWidth<=innerWidth+1,
|
||||
};
|
||||
}
|
||||
|
||||
/** The first image card's Info and Tag group (M1), with its card's filename. */
|
||||
export function cardActionsInventory(card) {
|
||||
const css=(node,...keys)=>{const style=getComputedStyle(node);return Object.fromEntries(keys.map(key=>[key,style[key]]));};
|
||||
const info=card.querySelector('.media-info'),actions=info.querySelector(':scope > .image-card-actions');
|
||||
return {
|
||||
info:css(info,'display','justifyContent','alignItems','columnGap'),
|
||||
actions:{...css(actions,'display','alignItems','columnGap','flexShrink','minWidth'),lastInInfo:info.lastElementChild===actions},
|
||||
buttons:[...actions.querySelectorAll(':scope > button')].map(node=>{const rect=node.getBoundingClientRect();return {
|
||||
className:node.className,label:node.getAttribute('aria-label'),icon:node.querySelector('svg')?.innerHTML.replace(/\s+/g,' ').trim()??null,
|
||||
width:Math.round(rect.width),height:Math.round(rect.height),
|
||||
...css(node,'minWidth','minHeight','paddingLeft','paddingTop','borderTopLeftRadius','borderTopColor','backgroundColor','color')};}),
|
||||
};
|
||||
}
|
||||
|
||||
// D84 (plan D61): the legacy engines' values, labels and sizes, GGUF JoyCaption in the concept's Ollama slot.
|
||||
const MODEL_LABELS=Object.freeze({'wd14-swinv2':['wd14-swinv2','WD14 SwinV2 · ~400 MB'],'wd14-vit':['wd14-vit','WD14 ViT · ~400 MB'],
|
||||
ollama:['gguf','GGUF JoyCaption (Recommended) · ~4.5 GB'],huggingface:['hf','HuggingFace JoyCaption · ~16 GB']});
|
||||
const CONCEPT_LABELS=Object.freeze({'WD14 SwinV2 · fast classifier':'WD14 SwinV2 · ~400 MB','WD14 ViT · visual tags':'WD14 ViT · ~400 MB',
|
||||
'Ollama · local language model':'GGUF JoyCaption (Recommended) · ~4.5 GB','HuggingFace · language model':'HuggingFace JoyCaption · ~16 GB'});
|
||||
|
||||
/**
|
||||
* The concept inventory with the recorded deviations applied:
|
||||
* - no prototype note (Global Constraints: `Prototype · suggestions are simulated…` is not shipped);
|
||||
* - D83 (ruling T4): while suggestions exist, "Remove anything you do not want to add." leads them, and every
|
||||
* suggestion is a chip with a `Remove <name>` × instead of a checked checkbox row: the concept's own `.chip` corner
|
||||
* (`chip`: a concept `.chip` inventory, the heading's size chip) around the row's own text (12px) in the checked
|
||||
* row's colours (accent border, `--soft-blue`);
|
||||
* - D84 (plan D61): the legacy engine labels and values, and the server's default instructions (`instructions`);
|
||||
* - D91 (plan D68): a Suggestions count field after the model's own fields, on the concept's number field tokens
|
||||
* (`numberField`: the concept's General confidence field) with the configured count (`limit`);
|
||||
* - D29: the prototype's "Sample suggestions from …" status reads "Suggestions from …".
|
||||
* The opened state is compared unmapped: both show the concept's on-screen "Generate suggestions with the selected
|
||||
* model before approving." (its renderTagModelOptions, a1062–1076, replaces the markup's sentence at once).
|
||||
*/
|
||||
export function mapConceptTagging(reference,{instructions,limit=10,chip,numberField}) {
|
||||
const [select,...own]=reference.modelPanel.fields;
|
||||
const suggestionsField={...numberField,label:'Suggestions',control:{...numberField.control,id:'image-suggestion-limit',
|
||||
value:String(limit),min:'1',max:'100',step:'1'}};
|
||||
const mappedOwn=own.map(field=>field.control.id==='image-tag-instructions'?{...field,control:{...field.control,value:instructions}}:field);
|
||||
const rows=reference.suggestions.rows.map(row=>({...row,
|
||||
control:row.control?.kind==='checkbox'&&row.control.checked?{kind:'remove',label:`Remove ${row.name}`}:row.control,
|
||||
borderTopLeftRadius:chip.borderTopLeftRadius}));
|
||||
const copy=rows.length&&reference.suggestions.copy===null?'Remove anything you do not want to add.':reference.suggestions.copy;
|
||||
const status=reference.suggestions.status.text.replace(/^Sample suggestions from (.+)\. Approve tags to save\.$/,
|
||||
(_,label)=>`Suggestions from ${CONCEPT_LABELS[label]??label}. Approve tags to save.`);
|
||||
return {...reference,
|
||||
demo:null,
|
||||
order:reference.order.filter(name=>name!=='demo-note'),
|
||||
modelPanel:{...reference.modelPanel,
|
||||
fields:[{...select,control:{...select.control,value:MODEL_LABELS[select.control.value]?.[0]??select.control.value,
|
||||
options:select.control.options.map(([value])=>MODEL_LABELS[value])}},...mappedOwn,suggestionsField]},
|
||||
suggestions:{...reference.suggestions,copy,rows,status:{...reference.suggestions.status,text:status}},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Production's own chip geometry beyond the concept's `.chip` (D83): the chip's padding hugs its × button. Only the
|
||||
* leaves D83 replaces are dropped from both sides; names, confidences, colours, radius and font stay compared.
|
||||
*/
|
||||
export function withoutChipGeometry(inventory) {
|
||||
return {...inventory,suggestions:{...inventory.suggestions,rows:inventory.suggestions.rows.map(({paddingTop,paddingLeft,alignItems,...row})=>row)}};
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
// The Tag image dialog and the card's Info and Tag group against the approved concept (#249 PR C, plan Task C4, Mock
|
||||
// inventory M1 and M2). Production runs on the concept-shaped fixture with the concept image's own tags saved as its
|
||||
// image tags and the injected fake model answering the concept's own sample (alpine: mountain 98%, observatory 95%,
|
||||
// snow 88%, blue_hour 81%), so every remaining difference is a recorded deviation applied by mapConceptTagging
|
||||
// (D29, D83, D84, D91) or a test failure. No model is ever downloaded: every test checks no download request was sent.
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
import { seedConcept, openConcept, openProduction, saveImageTags, mutate, SIZES, THEMES } from './concept-inventory.mjs';
|
||||
import { taggingInventory, cardActionsInventory, mapConceptTagging, diffTaggingInventory, withoutChipGeometry } from './tagging-canonical-comparison.mjs';
|
||||
|
||||
const MODULE = '/prompt_manager/js/workspace/image-tagging.mjs';
|
||||
// The concept's first image (app.js images[0]) and its sample suggestions (generateTags, scene "alpine").
|
||||
const CONCEPT_FILE = 'alpine_00001.png';
|
||||
const CONCEPT_TAGS = ['cinematic', 'blue_hour', 'architecture'];
|
||||
const ALPINE = [['mountain', 0.98], ['observatory', 0.95], ['snow', 0.88], ['blue_hour', 0.81]];
|
||||
// The fake model's server default instructions (tests/browser/server.py TestModel.default_prompt), D84.
|
||||
const INSTRUCTIONS = 'Describe the image';
|
||||
const CONCEPT_MODEL = { gguf: 'ollama' };
|
||||
|
||||
const tagDialog = page => page.getByRole('dialog', { name: 'Tag image', exact: true });
|
||||
const firstCard = (page, side) => page.locator(side === 'concept' ? '#image-grid > article.media-card' : '#workspace-main article[data-image-key]').first();
|
||||
|
||||
test.beforeEach(async ({ page, request }) => {
|
||||
const manifest = await seedConcept(request);
|
||||
expect(manifest).toBeTruthy();
|
||||
await expect(await request.post('/__test__/tagging/suggestions', { data: { scored: ALPINE } })).toBeOK();
|
||||
page.downloads = [];
|
||||
page.on('request', item => { if (/\/autotag\/download\//.test(item.url())) page.downloads.push(item.url()); });
|
||||
page.errors = [];
|
||||
page.on('pageerror', error => page.errors.push(error.message));
|
||||
});
|
||||
test.afterEach(async ({ page, request }) => {
|
||||
await leave(page);
|
||||
expect(page.downloads).toEqual([]);
|
||||
expect(page.errors).toEqual([]);
|
||||
await request.post('/__test__/tagging/suggestions', { data: { scored: null } });
|
||||
});
|
||||
|
||||
async function conceptPath(request) {
|
||||
const listing = await (await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();
|
||||
const image = listing.images.find(item => item.filename === CONCEPT_FILE);
|
||||
expect(image).toBeTruthy();
|
||||
return image.path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close an open Tag dialog before opening the next page, so each comparison starts from a settled page. Leaving with
|
||||
* the dialog open is covered on its own (workspace-tagging-lifecycle.spec.mjs: the keepalive release on pagehide).
|
||||
*/
|
||||
async function leave(page) {
|
||||
if (await tagDialog(page).count()) {
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(tagDialog(page)).toHaveCount(0);
|
||||
}
|
||||
}
|
||||
/** Open `side`'s Tag image dialog from its first card, settled (production: the capture preview and saved tags). */
|
||||
async function openTagDialog(page, side, theme) {
|
||||
await leave(page);
|
||||
if (side === 'concept') await openConcept(page, 'images', theme);
|
||||
else await openProduction(page, 'images', theme);
|
||||
await firstCard(page, side).getByRole('button', { name: `Tag or auto-tag ${CONCEPT_FILE}`, exact: true }).click();
|
||||
await expect(tagDialog(page)).toBeVisible();
|
||||
await settled(page, side);
|
||||
}
|
||||
async function settled(page, side) {
|
||||
if (side === 'production') {
|
||||
await expect(tagDialog(page).locator('img.tag-image-preview')).toHaveAttribute('src', /\/image-annotations\/capture\//);
|
||||
await expect(tagDialog(page).locator('#manual-image-tags')).toHaveValue(CONCEPT_TAGS.join(', '));
|
||||
}
|
||||
await page.evaluate(async () => {
|
||||
await document.fonts.ready;
|
||||
await Promise.all([...document.querySelectorAll('#modal img')].map(image => image.decode().catch(() => {})));
|
||||
});
|
||||
}
|
||||
async function generateWith(page, side, model) {
|
||||
const select = tagDialog(page).getByRole('combobox', { name: 'Tagging model', exact: true });
|
||||
if (model) await select.selectOption(side === 'concept' ? CONCEPT_MODEL[model] ?? model : model);
|
||||
await tagDialog(page).getByRole('button', { name: 'Generate suggestions', exact: true }).click();
|
||||
await expect(tagDialog(page).locator('#suggestion-count')).not.toHaveText('Not generated');
|
||||
}
|
||||
// The pointer rests outside the dialog, so no control is measured in its hover state.
|
||||
const inventory = async page => {
|
||||
await page.mouse.move(0, 0);
|
||||
return page.locator('dialog#modal').evaluate(taggingInventory);
|
||||
};
|
||||
|
||||
/** The concept and production inventories of one dialog state, the concept mapped (D29, D83, D84, D91). */
|
||||
async function compareState(page, { theme, act = async () => {} }) {
|
||||
await openTagDialog(page, 'concept', theme);
|
||||
const conceptOpened = await inventory(page);
|
||||
await act(page, 'concept');
|
||||
const concept = await inventory(page);
|
||||
await openTagDialog(page, 'production', theme);
|
||||
await act(page, 'production');
|
||||
const actual = await inventory(page);
|
||||
const numberField = conceptOpened.modelPanel.fields.find(field => field.control.id === 'image-general-threshold');
|
||||
const reference = mapConceptTagging(concept, { instructions: INSTRUCTIONS, chip: concept.heading.size, numberField });
|
||||
return { reference: withoutChipGeometry(reference), actual: withoutChipGeometry(actual) };
|
||||
}
|
||||
|
||||
for (const [width, height] of SIZES) for (const theme of THEMES) {
|
||||
test(`concept and production Tag dialog match at ${width}x${height} ${theme}`, async ({ page, request }) => {
|
||||
test.setTimeout(90_000);
|
||||
await saveImageTags(request, await conceptPath(request), CONCEPT_TAGS);
|
||||
await page.setViewportSize({ width, height });
|
||||
// Opened, generated with WD14 (the concept's default), then generated with a language model.
|
||||
for (const [state, act] of [
|
||||
['opened', async () => {}],
|
||||
['generated WD14', (page, side) => generateWith(page, side)],
|
||||
['generated GGUF', (page, side) => generateWith(page, side, 'gguf')],
|
||||
]) {
|
||||
const { reference, actual } = await compareState(page, { theme, act });
|
||||
expect(diffTaggingInventory(reference, actual), `${state} at ${width}x${height} ${theme}`).toEqual([]);
|
||||
}
|
||||
// Pinned concept geometry (M2), so a mapping mistake on both sides cannot pass unseen.
|
||||
const { actual } = await compareState(page, { theme, act: (page, side) => generateWith(page, side) });
|
||||
const small = width <= 700;
|
||||
expect([actual.heading.preview.width, actual.heading.preview.height]).toEqual(small ? [86, 86] : [112, 100]);
|
||||
expect(actual.heading.columnGap).toBe('16px');
|
||||
expect([actual.modelPanel.paddingTop, actual.modelPanel.borderTopLeftRadius]).toEqual(['16px', '6px']);
|
||||
expect(actual.actions.position).toBe('sticky');
|
||||
expect(actual.suggestions.rows.map(row => [row.name, row.confidence, row.control.label])).toEqual(
|
||||
ALPINE.map(([name, confidence]) => [name, `${Math.round(confidence * 100)}%`, `Remove ${name}`]));
|
||||
const accent = await page.evaluate(() => {
|
||||
const probe = document.createElement('i');
|
||||
probe.style.cssText = 'border-top:1px solid var(--ca-accent);background:var(--soft-blue)';
|
||||
document.body.append(probe);
|
||||
const style = getComputedStyle(probe), tokens = [style.borderTopColor, style.backgroundColor];
|
||||
probe.remove();
|
||||
return tokens;
|
||||
});
|
||||
for (const row of actual.suggestions.rows) expect([row.borderTopColor, row.backgroundColor]).toEqual(accent);
|
||||
expect(actual.dialog.noHorizontalOverflow).toBe(true);
|
||||
expect(actual.noPageScroll).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
test('an empty result matches the concept below its threshold', async ({ page, request }) => {
|
||||
await saveImageTags(request, await conceptPath(request), CONCEPT_TAGS);
|
||||
await expect(await request.post('/__test__/tagging/suggestions', { data: { scored: [] } })).toBeOK();
|
||||
const { reference, actual } = await compareState(page, { theme: 'dark', act: async (page, side) => {
|
||||
// The concept filters its sample by the General confidence; above every sample nothing is shown.
|
||||
if (side === 'concept') await tagDialog(page).getByRole('spinbutton', { name: 'General confidence', exact: true }).fill('1');
|
||||
await generateWith(page, side);
|
||||
if (side === 'concept') await tagDialog(page).getByRole('spinbutton', { name: 'General confidence', exact: true }).fill('0.35');
|
||||
} });
|
||||
expect(actual.suggestions.copy).toBe('No suggestions met this threshold. Adjust it or enter tags manually.');
|
||||
expect(diffTaggingInventory(reference, actual)).toEqual([]);
|
||||
});
|
||||
|
||||
test('review-scoped dialog shows the scope note', async ({ page, request }) => {
|
||||
const path = await conceptPath(request);
|
||||
await saveImageTags(request, path, CONCEPT_TAGS);
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
await openConcept(page, 'review', 'dark');
|
||||
await page.locator('#main section.surface').getByRole('button', { name: 'Tag image', exact: true }).click();
|
||||
await expect(tagDialog(page)).toBeVisible();
|
||||
await settled(page, 'concept');
|
||||
const opened = await inventory(page);
|
||||
// Needs review ships with PR F; until then the review source opens the same dialog from the module.
|
||||
await leave(page);
|
||||
await openProduction(page, 'images', 'dark');
|
||||
await page.evaluate(async ({ module, path }) => {
|
||||
const { openImageTagging } = await import(module);
|
||||
const { createScope } = await import('/prompt_manager/js/shared/toolkit/core.js');
|
||||
const api = window.ApiClient.createApiClient();
|
||||
const listing = await api.get('/prompt_manager/images/output?mode=workspace&limit=40');
|
||||
openImageTagging({ api, scope: createScope(), image: listing.images.find(item => item.path === path), source: 'review' });
|
||||
}, { module: MODULE, path });
|
||||
await expect(tagDialog(page)).toBeVisible();
|
||||
await settled(page, 'production');
|
||||
const actual = await inventory(page);
|
||||
expect(actual.scopeNote.text).toBe('Tags belong to this image. Resolve its prompt association separately in Needs review.');
|
||||
expect(actual.order).toEqual(['tag-image-heading', 'tag-scope-note small', 'field', 'tag-model-panel', 'tag-suggestion-panel', 'dialog-actions']);
|
||||
const numberField = opened.modelPanel.fields.find(field => field.control.id === 'image-general-threshold');
|
||||
const reference = mapConceptTagging(opened, { instructions: INSTRUCTIONS, chip: opened.heading.size, numberField });
|
||||
expect(diffTaggingInventory(reference, actual)).toEqual([]);
|
||||
});
|
||||
|
||||
for (const [width, height] of SIZES) test(`card action group matches M1 at ${width}x${height}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height });
|
||||
await openConcept(page, 'images', 'dark');
|
||||
const reference = await firstCard(page, 'concept').evaluate(cardActionsInventory);
|
||||
await openProduction(page, 'images', 'dark');
|
||||
const actual = await firstCard(page, 'production').evaluate(cardActionsInventory);
|
||||
expect(actual.buttons.map(button => button.label)).toEqual([`Inspect ${CONCEPT_FILE}`, `Tag or auto-tag ${CONCEPT_FILE}`]);
|
||||
expect(diffTaggingInventory(reference, actual)).toEqual([]);
|
||||
});
|
||||
|
||||
test('the comparison notices a changed panel border or primary label', async ({ page, request }) => {
|
||||
await saveImageTags(request, await conceptPath(request), CONCEPT_TAGS);
|
||||
const { reference, actual } = await compareState(page, { theme: 'dark' });
|
||||
expect(diffTaggingInventory(reference, actual)).toEqual([]);
|
||||
const undo = await mutate(page, { css: '.tag-model-panel { border: 0 !important; }' });
|
||||
const borderless = withoutChipGeometry(await inventory(page));
|
||||
expect(diffTaggingInventory(reference, borderless).map(item => item.path)).toEqual(
|
||||
expect.arrayContaining(['modelPanel.borderTopWidth', 'modelPanel.borderTopStyle']));
|
||||
await undo();
|
||||
await tagDialog(page).locator('.dialog-actions .ca-primary').evaluate(node => { node.textContent = 'Save'; });
|
||||
const relabelled = withoutChipGeometry(await inventory(page));
|
||||
expect(diffTaggingInventory(reference, relabelled).map(item => item.path)).toEqual(['actions.buttons.1.text']);
|
||||
});
|
||||
|
||||
// Paired captures for the PR (no screenshot baselines): every production state beside the concept's nearest one. The
|
||||
// states the concept lacks (unavailable model, saving, conflict, file changed) pair with the concept's opened dialog.
|
||||
test('paired Tag dialog captures', async ({ page, request }, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
const path = await conceptPath(request);
|
||||
await saveImageTags(request, path, CONCEPT_TAGS);
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
const shot = async name => {
|
||||
await page.evaluate(async () => { await document.fonts.ready; await Promise.all([...document.images].map(image => image.decode().catch(() => {}))); });
|
||||
const file = testInfo.outputPath(`${name}.png`);
|
||||
await page.screenshot({ path: file });
|
||||
await testInfo.attach(name, { path: file, contentType: 'image/png' });
|
||||
};
|
||||
const both = async (name, act) => {
|
||||
for (const side of ['concept', 'production']) {
|
||||
await openTagDialog(page, side, 'dark');
|
||||
await act(page, side);
|
||||
await shot(`${side}-${name}`);
|
||||
}
|
||||
};
|
||||
await both('opened', async () => {});
|
||||
await both('generated-wd14', (page, side) => generateWith(page, side));
|
||||
await both('generated-gguf', (page, side) => generateWith(page, side, 'gguf'));
|
||||
await expect(await request.post('/__test__/tagging/suggestions', { data: { scored: [] } })).toBeOK();
|
||||
await both('empty', async (page, side) => {
|
||||
if (side === 'concept') await tagDialog(page).getByRole('spinbutton', { name: 'General confidence', exact: true }).fill('1');
|
||||
await generateWith(page, side);
|
||||
});
|
||||
await expect(await request.post('/__test__/tagging/suggestions', { data: { scored: ALPINE } })).toBeOK();
|
||||
const save = () => tagDialog(page).locator('.dialog-actions .ca-primary').click();
|
||||
const status = () => tagDialog(page).locator('#tag-generation-status');
|
||||
|
||||
// Unavailable model.
|
||||
await expect(await request.post('/__test__/tagging/models', { data: { unavailable: ['wd14-swinv2'] } })).toBeOK();
|
||||
await openTagDialog(page, 'production', 'dark');
|
||||
await generateWith(page, 'production').catch(() => {});
|
||||
await expect(status()).toHaveText('WD14 SwinV2 is not downloaded. Download it from the legacy Auto-tag page.');
|
||||
await shot('production-unavailable');
|
||||
await expect(await request.post('/__test__/tagging/models', { data: { unavailable: [] } })).toBeOK();
|
||||
|
||||
// Saving: the write is held until the capture is taken.
|
||||
let release;
|
||||
const held = new Promise(resolve => { release = resolve; });
|
||||
const writes = url => url.pathname === '/prompt_manager/image-annotations/tags';
|
||||
await page.route(writes, async route => { await held; await route.continue(); });
|
||||
await openTagDialog(page, 'production', 'dark');
|
||||
const sent = page.waitForRequest(item => writes(new URL(item.url())));
|
||||
await save();
|
||||
await sent;
|
||||
await expect(tagDialog(page).locator('.dialog-actions .ca-primary')).toHaveAttribute('aria-disabled', 'true');
|
||||
await shot('production-saving');
|
||||
release();
|
||||
await expect(tagDialog(page)).toHaveCount(0);
|
||||
await page.unroute(writes);
|
||||
|
||||
// Conflict: another writer saved this file's set after the dialog observed it.
|
||||
await openTagDialog(page, 'production', 'dark');
|
||||
await saveImageTags(request, path, ['changed_elsewhere']);
|
||||
await save();
|
||||
await expect(status()).not.toHaveText('');
|
||||
await shot('production-conflict');
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// File changed: the server's own envelope for bytes replaced after the preview (B1 `file-changed`).
|
||||
await page.route(writes, route => route.fulfill({ status: 409, contentType: 'application/json',
|
||||
body: JSON.stringify({ success: false, code: 'file-changed', error: 'The file changed since it was previewed. Reopen the Tag dialog.' }) }));
|
||||
await saveImageTags(request, path, CONCEPT_TAGS);
|
||||
await openTagDialog(page, 'production', 'dark');
|
||||
await save();
|
||||
await expect(status()).not.toHaveText('');
|
||||
await shot('production-file-changed');
|
||||
await page.unroute(writes);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Unconfirmed save (a 504): D116's Retry leads the actions, before Cancel and the disabled primary.
|
||||
await page.route(writes, route => route.fulfill({ status: 504, body: 'Gateway Timeout' }));
|
||||
await openTagDialog(page, 'production', 'dark');
|
||||
await save();
|
||||
await expect(status()).toHaveText('The save could not be confirmed. Retry sends the same tags again.');
|
||||
expect((await inventory(page)).actions.buttons.map(button => [button.text, button.primary, button.disabled])).toEqual(
|
||||
[['Retry', false, false], ['Cancel', false, false], ['Save manual tags', true, true]]);
|
||||
await shot('production-unknown-outcome');
|
||||
await page.unroute(writes);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Review scope: the concept's Needs review dialog and production's review-sourced dialog.
|
||||
await leave(page);
|
||||
await openConcept(page, 'review', 'dark');
|
||||
await page.locator('#main section.surface').getByRole('button', { name: 'Tag image', exact: true }).click();
|
||||
await expect(tagDialog(page)).toBeVisible();
|
||||
await shot('concept-review');
|
||||
await leave(page);
|
||||
await openProduction(page, 'images', 'dark');
|
||||
await page.evaluate(async ({ module, path }) => {
|
||||
const { openImageTagging } = await import(module);
|
||||
const { createScope } = await import('/prompt_manager/js/shared/toolkit/core.js');
|
||||
const api = window.ApiClient.createApiClient();
|
||||
const listing = await api.get('/prompt_manager/images/output?mode=workspace&limit=40');
|
||||
openImageTagging({ api, scope: createScope(), image: listing.images.find(item => item.path === path), source: 'review' });
|
||||
}, { module: MODULE, path });
|
||||
await expect(tagDialog(page).locator('.tag-scope-note')).toBeVisible();
|
||||
await settled(page, 'production');
|
||||
await shot('production-review');
|
||||
await leave(page);
|
||||
});
|
||||
@@ -64,12 +64,10 @@ export function pageInventoryOf(main) {
|
||||
// Recorded deviations only (plan "Mock inventory" + decisions D1-D15 + ledger rulings). Everything else must be equal.
|
||||
export function mapVocabularyConcept(reference) {
|
||||
return {...reference,
|
||||
heading:{...reference.heading,actions:{...reference.heading.actions,
|
||||
// D2: Auto-tag images is omitted until the image-annotation slice owns image tags.
|
||||
buttons:reference.heading.actions.buttons.filter(item=>item.text!=='Auto-tag images')}},
|
||||
table:{...reference.table,rows:reference.table.rows.map(row=>({...row,
|
||||
// D3: no image-tag owner yet, so the Images cell shows "—" with a visually hidden "Not available yet".
|
||||
images:'—',imagesHidden:'Not available yet'}))},
|
||||
// Data, not a deviation: the concept tags each sample image with its prompt's tags; the vocabulary fixture
|
||||
// tags no image, so every Images count is 0 (#249 C2 retired #245 D3).
|
||||
images:'0'}))},
|
||||
operations:reference.operations.map(row=>({...row,p:{...row.p,
|
||||
// Ledger ruling: "1 positive prompts" -> singular, consistent with the shared plural helper (#238/#243).
|
||||
text:row.p.text.replace(/^1 positive prompts$/,'1 positive prompt')}})),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
|
||||
// Artifact-only captures of Tags & categories states that have no concept counterpart: the Manage categories
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// Each Playwright worker owns its server: `request`, `page` and manual
|
||||
// `browser.newContext()` all reach it through `baseURL`, and another server
|
||||
// process never sees its database.
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
import { startBrowserServer } from '../../tools/vendor/start-browser-server.mjs';
|
||||
|
||||
const search = text => `/prompt_manager/search?text=${encodeURIComponent(text)}`;
|
||||
|
||||
test('every fixture reaches this worker\'s own server and another server keeps its own database', async ({ request, page, browser, baseURL, workerServer }, testInfo) => {
|
||||
expect(baseURL).toBe(workerServer.url);
|
||||
await expect(await request.post('/__test__/reset')).toBeOK();
|
||||
const marker = `worker ${testInfo.workerIndex} isolation ${testInfo.project.name} ${testInfo.repeatEachIndex}`;
|
||||
await expect(await request.post('/prompt_manager/save', { data: { text: marker } })).toBeOK();
|
||||
expect((await (await request.get(search(marker))).json()).count).toBe(1);
|
||||
|
||||
const viaPage = await page.request.get(search(marker));
|
||||
expect(viaPage.url().startsWith(`${baseURL}/`)).toBe(true);
|
||||
expect((await viaPage.json()).count).toBe(1);
|
||||
const context = await browser.newContext();
|
||||
try {
|
||||
const response = await (await context.newPage()).goto(search(marker));
|
||||
expect(response.url().startsWith(`${baseURL}/`)).toBe(true);
|
||||
expect((await response.json()).count).toBe(1);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
|
||||
const other = await startBrowserServer();
|
||||
try {
|
||||
expect(other.url).not.toBe(baseURL);
|
||||
expect((await (await fetch(`${other.url}${search(marker)}`)).json()).count).toBe(0);
|
||||
} finally {
|
||||
await other.stop();
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
|
||||
test.beforeEach(async ({ request, page, browserName }) => {
|
||||
await startCoverage(page, browserName);
|
||||
@@ -64,11 +64,16 @@ test('actual Tags page reloads the selected filter and opens its image viewer',
|
||||
await page.goto('/prompt_manager/admin#/tags');
|
||||
await page.locator('[data-tag-name="cats"]').click();
|
||||
await expect(page.locator('#tagPromptsGrid')).toContainText('Cat portrait');
|
||||
const dogs = page.waitForResponse(response => response.url().includes('/tags/dogs/prompts?'));
|
||||
await page.locator('[data-tag-name="dogs"]').click();
|
||||
const [shown] = (await (await dogs).json()).prompts[0].images;
|
||||
await expect(page.locator('#tagPromptsGrid')).toContainText('Dog landscape');
|
||||
await expect(page.locator('#tagPromptsGrid')).not.toContainText('Cat portrait');
|
||||
await page.locator('#tagPromptsGrid img').first().click();
|
||||
await expect(page.locator('.viewer-canvas img')).toBeVisible();
|
||||
// The viewer's title names the shown image through the page's title callback. ViewerJS renders it only once the
|
||||
// image is viewed, so closing at "visible" alone left the callback unrun on slower hosts (CI coverage missed it).
|
||||
await expect(page.locator('.viewer-title')).toHaveText(shown.filename);
|
||||
await page.locator('.viewer-container').getByRole('button', { name: 'Close', exact: true }).click();
|
||||
await expect(page.locator('.viewer-container')).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,801 @@
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
import { seedConcept, openConcept, openProduction } from './concept-inventory.mjs';
|
||||
|
||||
// Activity (#249 Activity-1): the concept's Activity page (app.js renderActivity 732–738), its top-bar button
|
||||
// (index.html 51–52) and sidebar row (app.js ~341), with the recorded D52 (counts of running jobs), D53 (the progress
|
||||
// pill) and D54 (real job items). Fake jobs come from tests/browser/workspace_activity_fixture.py.
|
||||
const workspace = '/prompt_manager/workspace';
|
||||
const main = page => page.locator('#workspace-main');
|
||||
const pill = page => page.locator('.top-actions #activity-pill');
|
||||
const activityButton = page => page.locator('.top-actions button#activity-open');
|
||||
const topCount = page => page.locator('#activity-count');
|
||||
const navRow = page => page.locator('#system-nav a[data-route="activity"]');
|
||||
const toast = page => page.locator('#workspace-toast');
|
||||
const items = page => main(page).locator('section.surface > .activity-item');
|
||||
const LOST = 'This job ended when ComfyUI restarted.';
|
||||
// The job view (Task 3, D56): the page heading, then the Progress and Results surfaces and the Scan log.
|
||||
const heading = page => main(page).locator(':scope > .heading');
|
||||
const progressSection = page => main(page).getByRole('region', { name: 'Progress', exact: true });
|
||||
const resultsSection = page => main(page).getByRole('region', { name: 'Results', exact: true });
|
||||
const tiles = page => progressSection(page).locator('dl.stat-row > div.stat');
|
||||
const outcomeButtons = page => resultsSection(page).locator('.outcome-list > button');
|
||||
const fileRows = page => resultsSection(page).locator('.outcome-files > .file-row');
|
||||
const resultsNote = page => resultsSection(page).locator('.outcome-files > p.small');
|
||||
const filter = page => resultsSection(page).getByRole('searchbox', { name: 'Filter results', exact: true });
|
||||
const scanLog = page => main(page).locator('details.term');
|
||||
const TILE_LABELS = ['Added to Library', 'Already known', 'Needs review', 'No usable metadata', 'Errors'];
|
||||
const VIEWER_HINT = 'each row opens the image in the viewer';
|
||||
|
||||
async function tileValues(page) {
|
||||
return tiles(page).evaluateAll(nodes => nodes.map(node => [node.className, node.querySelector('dt').textContent,
|
||||
node.querySelector('dd').textContent]));
|
||||
}
|
||||
|
||||
async function openJob(page, id) {
|
||||
await page.goto(`${workspace}#/activity/${id}`);
|
||||
await expect(progressSection(page)).toBeVisible();
|
||||
}
|
||||
|
||||
async function startJob(request, data = {}) {
|
||||
const response = await request.post('/__test__/jobs/start', { data: { total: 19622, ...data } });
|
||||
await expect(response).toBeOK();
|
||||
return (await response.json()).job;
|
||||
}
|
||||
|
||||
async function step(request, id, data) {
|
||||
await expect(await request.post(`/__test__/jobs/${id}/step`, { data })).toBeOK();
|
||||
}
|
||||
|
||||
async function finish(request, id, state = 'finished') {
|
||||
const response = await request.post(`/__test__/jobs/${id}/finish`, { data: { state } });
|
||||
await expect(response).toBeOK();
|
||||
return (await response.json()).job;
|
||||
}
|
||||
|
||||
async function listed(request) {
|
||||
const response = await request.get('/prompt_manager/jobs');
|
||||
await expect(response).toBeOK();
|
||||
return (await response.json()).jobs;
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page, request, browserName }) => {
|
||||
await startCoverage(page, browserName);
|
||||
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
|
||||
await expect(await request.post('/__test__/reset')).toBeOK();
|
||||
page.activityErrors = [];
|
||||
page.on('pageerror', error => page.activityErrors.push(error.message));
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page, request, browserName }) => {
|
||||
// The store's own reads can meet a route handler at teardown; that must not fail the test it outlived.
|
||||
await page.unrouteAll({ behavior: 'ignoreErrors' });
|
||||
await saveCoverage(page, browserName);
|
||||
expect(page.activityErrors).toEqual([]);
|
||||
await request.post('/__test__/jobs/restart');
|
||||
});
|
||||
|
||||
/** The concept's item structure and the computed styles its CSS gives it, measured the same way in both documents. */
|
||||
function measureItems(selector) {
|
||||
return [...document.querySelectorAll(selector)].map(item => {
|
||||
const css = (node, ...keys) => node && Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]]));
|
||||
const tree = node => ({ tag: node.localName, className: [...node.classList].sort().join(' '), children: [...node.children].map(tree) });
|
||||
const progress = item.querySelector('progress');
|
||||
return {
|
||||
tree: tree(item),
|
||||
title: item.querySelector('h3').textContent,
|
||||
chip: item.querySelector('.chip').textContent,
|
||||
buttons: [...item.querySelectorAll('.row-actions button')].map(button => button.textContent),
|
||||
styles: {
|
||||
item: css(item, 'paddingTop', 'paddingBottom', 'borderBottomWidth', 'borderBottomStyle', 'borderBottomColor'),
|
||||
heading: css(item.querySelector('.panel-heading'), 'display', 'justifyContent', 'alignItems', 'columnGap'),
|
||||
h3: css(item.querySelector('h3'), 'fontSize', 'fontWeight', 'color', 'marginTop', 'marginBottom'),
|
||||
chip: css(item.querySelector('.chip'), 'fontSize', 'color', 'backgroundColor', 'paddingTop', 'paddingLeft', 'borderTopLeftRadius'),
|
||||
p: css(item.querySelector('p'), 'fontSize', 'color', 'marginTop', 'lineHeight'),
|
||||
progress: progress && { ...css(progress, 'height', 'marginTop', 'marginBottom', 'accentColor'),
|
||||
fills: Math.abs(progress.getBoundingClientRect().width - item.getBoundingClientRect().width) < 1,
|
||||
max: progress.max, label: Boolean(progress.getAttribute('aria-label')) },
|
||||
actions: css(item.querySelector('.row-actions'), 'display', 'columnGap', 'marginTop'),
|
||||
button: css(item.querySelector('.row-actions button'), 'fontSize', 'paddingTop', 'paddingLeft', 'minHeight', 'borderTopLeftRadius'),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** D56: an item tree whose `h3` holds the link to the job's view. */
|
||||
function withTitleLink(tree) {
|
||||
const [heading, ...rest] = tree.children;
|
||||
const link = { tag: 'a', className: '', children: [] };
|
||||
return { ...tree, children: [{ ...heading, children: heading.children.map(node => node.tag === 'h3' ? { ...node, children: [link] } : node) }, ...rest] };
|
||||
}
|
||||
|
||||
function measurePage(mainSelector) {
|
||||
const root = document.querySelector(mainSelector);
|
||||
const section = root.querySelector(':scope > section.surface');
|
||||
return {
|
||||
h1: root.querySelector('.heading h1').textContent,
|
||||
description: root.querySelector('.heading p.muted').textContent,
|
||||
actions: [...root.querySelector('.heading .heading-actions').children].map(node => node.textContent),
|
||||
h2: section.querySelector(':scope > h2').textContent,
|
||||
surface: Object.fromEntries(['paddingTop', 'borderTopWidth', 'backgroundColor', 'borderTopLeftRadius']
|
||||
.map(key => [key, getComputedStyle(section)[key]])),
|
||||
};
|
||||
}
|
||||
|
||||
for (const theme of ['dark', 'light']) test(`activity page matches the concept list markup ${theme}`, async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
await seedConcept(request);
|
||||
// The concept: its finished sample, then a running job started from System (startJob, app.js 940–952).
|
||||
await openConcept(page, 'activity', theme);
|
||||
const conceptPage = await page.evaluate(measurePage, '#main');
|
||||
const [conceptDone] = await page.evaluate(measureItems, '#main .activity-item');
|
||||
await page.goto('/__test__/canonical/workspace#system');
|
||||
await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption(theme);
|
||||
await page.getByRole('button', { name: 'Review thumbnails', exact: true }).click();
|
||||
await expect(page.locator('#main .activity-item progress')).toHaveCount(1);
|
||||
const [conceptRunning] = await page.evaluate(measureItems, '#main .activity-item');
|
||||
|
||||
const done = await startJob(request);
|
||||
await step(request, done.id, { processed: 19622, counters: { added: 1420, errors: 18 } });
|
||||
await finish(request, done.id);
|
||||
const running = await startJob(request);
|
||||
await step(request, running.id, { processed: 8240 });
|
||||
await openProduction(page, 'activity', theme);
|
||||
await expect(items(page)).toHaveCount(2);
|
||||
const productionPage = await page.evaluate(measurePage, '#workspace-main');
|
||||
const [productionRunning, productionDone] = await page.evaluate(measureItems, '#workspace-main .activity-item');
|
||||
|
||||
// D52: no "Unified activity · proposed" chip (prototype copy); the rest of the heading and the section are the concept's.
|
||||
expect(conceptPage.actions).toEqual(['Unified activity · proposed']);
|
||||
expect(productionPage).toEqual({ ...conceptPage, actions: [] });
|
||||
// A completed job is the concept's completed item; D56: its title is a link to the job's view, in the title's colour.
|
||||
expect(conceptDone.title).toBe('✓ Library metadata enriched');
|
||||
expect(productionDone).toEqual({ ...conceptDone, tree: withTitleLink(conceptDone.tree), title: '✓ Scan for metadata' });
|
||||
const links = await items(page).locator('h3 > a').evaluateAll(nodes => nodes.map(node => [node.getAttribute('href'),
|
||||
getComputedStyle(node).color === getComputedStyle(node.parentElement).color, getComputedStyle(node).textDecorationLine]));
|
||||
expect(links).toEqual([[`#/activity/${running.id}`, true, 'none'], [`#/activity/${done.id}`, true, 'none']]);
|
||||
// D54: a running job's chip names its state ("Running", where the concept's sample reads "Needs attention"), and
|
||||
// its actions are Cancel job only (no "Simulate completion").
|
||||
expect(conceptRunning.buttons).toEqual(['Simulate completion', 'Cancel job']);
|
||||
const [, ...cancelOnly] = conceptRunning.tree.children.at(-1).children;
|
||||
const conceptActions = { ...conceptRunning.tree.children.at(-1), children: cancelOnly };
|
||||
expect(productionRunning).toEqual({ ...conceptRunning,
|
||||
tree: withTitleLink({ ...conceptRunning.tree, children: [...conceptRunning.tree.children.slice(0, -1), conceptActions] }),
|
||||
title: '○ Scan for metadata', chip: 'Running', buttons: ['Cancel job'] });
|
||||
await expect(items(page).nth(1).locator('p')).toHaveText(/^Finished \d\d:\d\d · 1,420 added · 18 errors$/);
|
||||
await expect(items(page).first().locator('p')).toHaveText(/^Started \d\d:\d\d · 2 output folders · closing this view does not cancel it$/);
|
||||
await expect(items(page).first().locator('progress')).toHaveAttribute('aria-label', 'Scan for metadata progress');
|
||||
expect(await items(page).first().locator('progress').evaluate(node => node.value)).toBe(41);
|
||||
});
|
||||
|
||||
test('pill shows while a job runs and opens its view', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
await page.goto(`${workspace}#/images`);
|
||||
// D52: no running job, so neither count shows; the button is the concept's.
|
||||
await expect(activityButton(page)).toHaveAccessibleName('Activity');
|
||||
await expect(topCount(page)).toBeHidden();
|
||||
await expect(navRow(page)).toHaveText('Activity');
|
||||
await expect(navRow(page).locator('.nav-count')).toHaveCount(0);
|
||||
await expect(pill(page)).toBeHidden();
|
||||
|
||||
const job = await startJob(request);
|
||||
// The list is read again on its own (15 s), or at once on a reload or when Activity opens.
|
||||
await page.reload();
|
||||
await expect(pill(page)).toBeVisible();
|
||||
await expect(pill(page)).toHaveText('Scanning · 0% · 0 / 19,622');
|
||||
await step(request, job.id, { processed: 8240 });
|
||||
const text = 'Scanning · 41% · 8,240 / 19,622';
|
||||
await expect(pill(page)).toHaveText(text);
|
||||
await expect(pill(page)).toHaveAttribute('aria-label', text);
|
||||
await expect(pill(page)).toHaveAttribute('href', `#/activity/${job.id}`);
|
||||
expect(await pill(page).locator('.activity-pill-bar i').evaluate(node => node.style.width)).toBe('41%');
|
||||
// The pill sits before the button, in the mockup's place, and both counts read 1.
|
||||
expect(await page.locator('.top-actions > *').evaluateAll(nodes => nodes.map(node => node.id || node.className)))
|
||||
.toEqual(['activity-pill', 'activity-open', 'theme-label', 'canvas-link']);
|
||||
await expect(topCount(page)).toBeVisible();
|
||||
await expect(topCount(page)).toHaveText('1');
|
||||
await expect(navRow(page).locator('.nav-count')).toHaveText('1');
|
||||
|
||||
// The pill opens the job's own view (Task 3, D56), named by the job.
|
||||
await pill(page).click();
|
||||
await expect(page).toHaveURL(new RegExp(`#/activity/${job.id}$`));
|
||||
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Scan for metadata');
|
||||
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Activity');
|
||||
await expect(page).toHaveTitle('Scan for metadata · Prompt Manager');
|
||||
await expect(navRow(page)).toHaveAttribute('aria-current', 'page');
|
||||
await expect(progressSection(page).locator('progress')).toHaveJSProperty('value', 41);
|
||||
|
||||
// The Activity button opens the list itself from any page.
|
||||
await page.locator('nav [data-route="library"]').click();
|
||||
await activityButton(page).click();
|
||||
await expect(page).toHaveURL(/#\/activity$/);
|
||||
await expect(items(page)).toHaveCount(1);
|
||||
|
||||
await finish(request, job.id);
|
||||
await expect(pill(page)).toBeHidden();
|
||||
await expect(topCount(page)).toBeHidden();
|
||||
await expect(navRow(page).locator('.nav-count')).toHaveCount(0);
|
||||
await expect(items(page).first().locator('.chip.green')).toHaveText('Completed');
|
||||
await expect(items(page).first().locator('progress, .row-actions')).toHaveCount(0);
|
||||
// A finished job's title still opens its view (D56).
|
||||
await items(page).first().locator('h3 > a').click();
|
||||
await expect(page).toHaveURL(new RegExp(`#/activity/${job.id}$`));
|
||||
await expect(heading(page).locator('p.muted')).toHaveText(/^Finished \d\d:\d\d · 0 added · 0 errors$/);
|
||||
});
|
||||
|
||||
test('a phone keeps the pill and its controls inside the topbar', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const job = await startJob(request);
|
||||
await step(request, job.id, { processed: 8240 });
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(pill(page)).toHaveText('Scanning · 41% · 8,240 / 19,622');
|
||||
await expect(pill(page)).toHaveAttribute('aria-label', 'Scanning · 41% · 8,240 / 19,622');
|
||||
await expect(pill(page).locator('.activity-pill-files, .activity-pill-bar')).toHaveCount(2);
|
||||
await expect(pill(page).locator('.activity-pill-files')).toBeHidden();
|
||||
const layout = await page.locator('.top-actions').evaluate(row => ({
|
||||
scroll: document.documentElement.scrollWidth <= document.documentElement.clientWidth,
|
||||
inside: [...row.children].filter(node => node.getBoundingClientRect().width)
|
||||
.every(node => node.getBoundingClientRect().right <= row.getBoundingClientRect().right + 0.5
|
||||
&& node.getBoundingClientRect().left >= row.getBoundingClientRect().left - 0.5),
|
||||
}));
|
||||
expect(layout).toEqual({ scroll: true, inside: true });
|
||||
});
|
||||
|
||||
test('reload during a scan reattaches to the running job', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
const job = await startJob(request);
|
||||
await step(request, job.id, { processed: 1000 });
|
||||
await page.goto(`${workspace}#/activity`);
|
||||
await expect(items(page)).toHaveCount(1);
|
||||
await expect(pill(page)).toHaveText('Scanning · 5% · 1,000 / 19,622');
|
||||
await page.reload();
|
||||
await expect(pill(page)).toHaveText('Scanning · 5% · 1,000 / 19,622');
|
||||
// The reloaded page follows the same job live and started nothing.
|
||||
await step(request, job.id, { processed: 9811 });
|
||||
await expect(pill(page)).toHaveText('Scanning · 50% · 9,811 / 19,622');
|
||||
expect(await items(page).first().locator('progress').evaluate(node => node.value)).toBe(50);
|
||||
await expect(items(page)).toHaveCount(1);
|
||||
expect((await listed(request)).map(item => [item.id, item.state])).toEqual([[job.id, 'running']]);
|
||||
|
||||
// A second tab attaches to the same job and sees the same progress.
|
||||
const other = await page.context().newPage();
|
||||
await other.goto(`${workspace}#/activity`);
|
||||
await expect(pill(other)).toHaveText('Scanning · 50% · 9,811 / 19,622');
|
||||
await expect(other.locator('#workspace-main .activity-item')).toHaveCount(1);
|
||||
await other.close();
|
||||
expect((await listed(request)).length).toBe(1);
|
||||
|
||||
// Cancel job asks the server to stop at a file boundary; the job ends cancelled with its partial counts.
|
||||
await step(request, job.id, { counters: { added: 7 } });
|
||||
await items(page).first().getByRole('button', { name: 'Cancel job', exact: true }).focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(toast(page)).toHaveText('Scan cancelled · 7 added');
|
||||
// The removed Cancel job hands its focus to the item's title link (review M4).
|
||||
await expect(items(page).first().locator('h3 > a')).toBeFocused();
|
||||
await expect(items(page).first().locator('.chip.blue')).toHaveText('Cancelled');
|
||||
await expect(items(page).first().locator('p')).toHaveText(/^Cancelled \d\d:\d\d at a file boundary · 7 added · completed work is kept$/);
|
||||
await expect(pill(page)).toBeHidden();
|
||||
});
|
||||
|
||||
test('lost server clears the pill and marks jobs ended', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
const job = await startJob(request);
|
||||
await step(request, job.id, { processed: 4000 });
|
||||
await page.goto(`${workspace}#/activity`);
|
||||
await expect(pill(page)).toBeVisible();
|
||||
await expect(topCount(page)).toHaveText('1');
|
||||
const reads = [];
|
||||
page.on('request', item => { if (new URL(item.url()).pathname === '/prompt_manager/jobs') reads.push(Date.now()); });
|
||||
// ComfyUI restarts: the stream drops without a final event and the new registry is empty.
|
||||
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
|
||||
await expect(pill(page)).toBeHidden();
|
||||
await expect(topCount(page)).toBeHidden();
|
||||
await expect(items(page).first().locator('.chip.blue')).toHaveText('Ended');
|
||||
await expect(items(page).first().locator('p')).toHaveText(LOST);
|
||||
await expect(items(page).first().locator('progress, .row-actions')).toHaveCount(0);
|
||||
await expect(navRow(page).locator('.nav-count')).toHaveCount(0);
|
||||
// One re-read found the restart; there is no retry storm and no finish toast for a job that only vanished.
|
||||
await page.waitForTimeout(2500);
|
||||
expect(reads.length).toBeLessThanOrEqual(2);
|
||||
await expect(toast(page)).toHaveText('');
|
||||
|
||||
// A server that cannot be read at all also ends the running job, then is read again with a growing backoff.
|
||||
const second = await startJob(request);
|
||||
await page.reload();
|
||||
await expect(pill(page)).toBeVisible();
|
||||
const failed = [];
|
||||
await page.route('**/prompt_manager/jobs**', route => { failed.push(Date.now()); return route.abort(); });
|
||||
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
|
||||
await expect(pill(page)).toBeHidden();
|
||||
await expect(items(page).filter({ hasText: LOST })).toHaveCount(1);
|
||||
await page.waitForTimeout(3500);
|
||||
// 1 s, then 2 s: at most two re-reads in 3.5 s.
|
||||
expect(failed.length).toBeLessThanOrEqual(2);
|
||||
await page.unroute('**/prompt_manager/jobs**');
|
||||
expect(second.id).not.toBe(job.id);
|
||||
});
|
||||
|
||||
test('start and finish toasts', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
const job = await startJob(request);
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(pill(page)).toBeVisible();
|
||||
await step(request, job.id, { processed: 19622, counters: { added: 1420, errors: 18 } });
|
||||
await finish(request, job.id);
|
||||
await expect(toast(page)).toHaveText('Scan finished · 1,420 added · 18 errors');
|
||||
await expect(pill(page)).toBeHidden();
|
||||
|
||||
const single = await startJob(request);
|
||||
await page.reload();
|
||||
await expect(pill(page)).toBeVisible();
|
||||
await step(request, single.id, { processed: 19622, counters: { added: 1, errors: 1 } });
|
||||
await finish(request, single.id, 'failed');
|
||||
await expect(toast(page)).toHaveText('Scan failed · 1 added · 1 error');
|
||||
|
||||
// The store's own start: the real metadata scan over this server's outputs, announced, followed and finished.
|
||||
await page.goto(`${workspace}#/activity`);
|
||||
const started = await page.evaluate(async () => {
|
||||
const { createActivityStore } = await import('/prompt_manager/js/workspace/activity-store.mjs');
|
||||
const controller = new AbortController();
|
||||
const store = createActivityStore({ api: window.ApiClient.createApiClient(), pollMs: 0,
|
||||
scope: { signal: controller.signal, cleanup: () => {} } });
|
||||
const result = await store.start('scan');
|
||||
// Read before the busy attempt: its list read may already see the small scan finished and toast that.
|
||||
const toast = document.getElementById('workspace-toast').textContent;
|
||||
const busy = await store.start('scan').catch(error => ({ error: error.message }));
|
||||
return { id: result.job.id, title: result.job.title, toast, busy: busy.busy?.id ?? null, message: busy.message ?? null };
|
||||
});
|
||||
expect(started.title).toBe('Scan for metadata');
|
||||
expect(started.toast).toBe('Scan started.');
|
||||
// A second start while the scan runs is refused and names it (unless the small fixture scan already ended).
|
||||
if (started.busy !== null) expect(started.busy).toBe(started.id);
|
||||
await expect(toast(page)).toHaveText(/^Scan (started\.|finished · \d+ added · \d+ errors?)$/);
|
||||
await expect.poll(async () => (await listed(request)).find(item => item.id === started.id)?.state, { timeout: 15000 }).toBe('finished');
|
||||
});
|
||||
|
||||
// ── Task 3: Scan for metadata, its review dialog and the job view ─────────────────────────────
|
||||
|
||||
async function startFromImport(page) {
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await page.getByRole('button', { name: 'Import', exact: true }).click();
|
||||
const importDialog = page.getByRole('dialog', { name: 'Import & inspect', exact: true });
|
||||
const row = importDialog.locator('.operation').filter({ hasText: 'Scan for metadata' });
|
||||
await row.getByRole('button', { name: 'Review scan', exact: true }).click();
|
||||
const review = page.getByRole('dialog', { name: 'Review metadata scan', exact: true });
|
||||
await expect(review).toBeVisible();
|
||||
await review.getByRole('button', { name: 'Start scan', exact: true }).click();
|
||||
await expect(review).toHaveCount(0);
|
||||
}
|
||||
|
||||
test('start scan creates a job, toasts, and opens its view', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
const posted = [];
|
||||
page.on('request', item => { if (item.method() === 'POST' && new URL(item.url()).pathname === '/prompt_manager/jobs/scan') posted.push(item); });
|
||||
await startFromImport(page);
|
||||
await expect(page).toHaveURL(/#\/activity\/[0-9a-f]{32}$/);
|
||||
const id = page.url().split('/').at(-1);
|
||||
expect(posted).toHaveLength(1);
|
||||
// The fixture scan is small and may already have finished; the start toast came first either way.
|
||||
await expect(toast(page)).toHaveText(/^Scan (started\.|finished · \d+ added · \d+ errors?)$/);
|
||||
await expect(heading(page).locator('h1')).toHaveText('Scan for metadata');
|
||||
await expect(heading(page).locator('h1')).toBeFocused();
|
||||
expect((await listed(request)).map(item => item.id)).toEqual([id]);
|
||||
await expect.poll(async () => (await listed(request))[0].state, { timeout: 15000 }).toBe('finished');
|
||||
await expect(heading(page).locator('p.muted')).toHaveText(/^Finished \d\d:\d\d · \d+ added · \d+ errors?$/);
|
||||
// The finished scan's tiles add up to the files it processed, and its log is there to open.
|
||||
const job = (await listed(request))[0];
|
||||
const values = await tileValues(page);
|
||||
expect(values.map(([, label]) => label)).toEqual(TILE_LABELS);
|
||||
expect(values.reduce((sum, [, , value]) => sum + Number(value.replaceAll(',', '')), 0)).toBe(job.processed);
|
||||
// Opening the log reads its last lines (review M12).
|
||||
await scanLog(page).locator('summary').click();
|
||||
await expect(scanLog(page).locator('pre')).toContainText('Scan finished');
|
||||
});
|
||||
|
||||
// ── Task 4 (Activity-2): scan scope, new and unresolved files by default ─────────────────────
|
||||
|
||||
test('all files checkbox changes the scope line and the request', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
const bodies = [];
|
||||
page.on('request', item => {
|
||||
if (item.method() === 'POST' && new URL(item.url()).pathname === '/prompt_manager/jobs/scan') bodies.push(item.postDataJSON());
|
||||
});
|
||||
const review = page.getByRole('dialog', { name: 'Review metadata scan', exact: true });
|
||||
const scopeValue = review.locator('.meta-grid > div').filter({ has: page.locator('dt', { hasText: 'Scope' }) }).locator('dd');
|
||||
// The concept's switch: a checkbox with role=switch.
|
||||
const allFiles = review.getByRole('switch', { name: 'All files', exact: true });
|
||||
const openReview = async () => {
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await page.getByRole('button', { name: 'Import', exact: true }).click();
|
||||
await page.getByRole('dialog', { name: 'Import & inspect', exact: true }).locator('.operation')
|
||||
.filter({ hasText: 'Scan for metadata' }).getByRole('button', { name: 'Review scan', exact: true }).click();
|
||||
await expect(review).toBeVisible();
|
||||
};
|
||||
|
||||
// The default: the concept's "New and unresolved files", the box unchecked; the request asks for "new".
|
||||
await openReview();
|
||||
await expect(scopeValue).toHaveText('New and unresolved files');
|
||||
await expect(allFiles).not.toBeChecked();
|
||||
await expect(review.locator('.setting-row p')).toHaveText('Also read the files already linked to their prompt again.');
|
||||
await expect(allFiles).toHaveAccessibleDescription('Also read the files already linked to their prompt again.');
|
||||
// Checking and unchecking switches the Scope value both ways.
|
||||
await allFiles.check();
|
||||
await expect(scopeValue).toHaveText('All files');
|
||||
await allFiles.uncheck();
|
||||
await expect(scopeValue).toHaveText('New and unresolved files');
|
||||
await review.getByRole('button', { name: 'Start scan', exact: true }).click();
|
||||
await expect(page).toHaveURL(/#\/activity\/[0-9a-f]{32}$/);
|
||||
await expect.poll(() => bodies.length).toBe(1);
|
||||
expect(bodies[0]).toEqual({ scope: 'new' });
|
||||
await expect(heading(page).locator('h1')).toHaveText('Scan for metadata');
|
||||
// Wait for the first scan to end so the second is not refused as busy.
|
||||
await scanLog(page).locator('summary').click();
|
||||
await expect(scanLog(page).locator('pre')).toContainText('Scan finished', { timeout: 15000 });
|
||||
await expect(scanLog(page).locator('pre')).toContainText('new and unresolved files');
|
||||
|
||||
// Checked: "All files", and the request asks for "all".
|
||||
await openReview();
|
||||
await allFiles.check();
|
||||
await expect(scopeValue).toHaveText('All files');
|
||||
await review.getByRole('button', { name: 'Start scan', exact: true }).click();
|
||||
await expect.poll(() => bodies.length).toBe(2);
|
||||
expect(bodies[1]).toEqual({ scope: 'all' });
|
||||
await scanLog(page).locator('summary').click();
|
||||
await expect(scanLog(page).locator('pre')).toContainText('Scan started · ', { timeout: 15000 });
|
||||
await expect(scanLog(page).locator('pre')).toContainText('all files');
|
||||
});
|
||||
|
||||
test('job view counters follow the mapping and update live', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
const job = await startJob(request);
|
||||
await step(request, job.id, { processed: 8240, counters: { added: 1420, known: 6511, review: 37, no_metadata: 254, errors: 18 } });
|
||||
await openJob(page, job.id);
|
||||
// The header: the title, the detail line, then Download report (enabled in Task 5) and Cancel scan (danger).
|
||||
await expect(heading(page).locator('h1')).toHaveText('Scan for metadata');
|
||||
await expect(heading(page).locator('p.muted')).toHaveText(/^Started \d\d:\d\d · 2 output folders · closing this view does not cancel it$/);
|
||||
await expect(heading(page).locator('.heading-actions > button')).toHaveText(['Download report', 'Cancel scan']);
|
||||
await expect(heading(page).getByRole('button', { name: 'Download report', exact: true })).toBeEnabled();
|
||||
await expect(heading(page).getByRole('button', { name: 'Cancel scan', exact: true })).toHaveClass(/danger/);
|
||||
// Progress: the bar, the server's line and the five tiles in order and colour.
|
||||
await expect(progressSection(page).locator('.panel-heading > span.small')).toHaveText('Processing file 8,240 of 19,622');
|
||||
await expect(progressSection(page).locator('progress')).toHaveAttribute('aria-label', 'Scan progress');
|
||||
await expect(progressSection(page).locator('progress')).toHaveJSProperty('value', 41);
|
||||
expect(await tileValues(page)).toEqual([
|
||||
['stat added', 'Added to Library', '1,420'], ['stat', 'Already known', '6,511'], ['stat warn', 'Needs review', '37'],
|
||||
['stat', 'No usable metadata', '254'], ['stat bad', 'Errors', '18'],
|
||||
]);
|
||||
const colours = await tiles(page).evaluateAll(nodes => nodes.map(node => getComputedStyle(node.querySelector('dd')).color));
|
||||
const expected = await page.evaluate(() => ['--green', '--ca-text', '--warning', '--ca-text', '--red'].map(name => {
|
||||
const probe = document.createElement('span');
|
||||
probe.style.color = `var(${name})`;
|
||||
document.querySelector('#workspace-main').append(probe);
|
||||
const colour = getComputedStyle(probe).color;
|
||||
probe.remove();
|
||||
return colour;
|
||||
}));
|
||||
expect([colours[0], colours[2], colours[4]]).toEqual([expected[0], expected[2], expected[4]]);
|
||||
expect(colours[1]).toBe(colours[3]);
|
||||
// The outcome list follows the mockup's order with the same counts.
|
||||
await expect(outcomeButtons(page)).toHaveText(['Added to Library1,420', 'Needs review37', 'Errors18', 'No usable metadata254', 'Already known6,511']);
|
||||
|
||||
// Live: the next step updates the bar, the line and the tiles without a reload.
|
||||
await step(request, job.id, { processed: 9811, counters: { added: 1500, errors: 19 }, records: [
|
||||
{ group: 'added', path: 'live/new.png', detail: 'Added to the Library as “Live”' },
|
||||
] });
|
||||
await expect(progressSection(page).locator('.panel-heading > span.small')).toHaveText('Processing file 9,811 of 19,622');
|
||||
await expect(progressSection(page).locator('progress')).toHaveJSProperty('value', 50);
|
||||
await expect(tiles(page).first().locator('dd')).toHaveText('1,500');
|
||||
await expect(tiles(page).last().locator('dd')).toHaveText('19');
|
||||
await expect(fileRows(page)).toHaveCount(1);
|
||||
// A focused row keeps its focus while later reads append rows.
|
||||
await fileRows(page).first().focus();
|
||||
await step(request, job.id, { records: [{ group: 'added', path: 'live/next.png', detail: 'Added' }] });
|
||||
await expect(fileRows(page)).toHaveCount(2);
|
||||
await expect(fileRows(page).first()).toBeFocused();
|
||||
// The log is collapsed by default and holds the raw timestamped lines.
|
||||
await expect(scanLog(page)).not.toHaveAttribute('open', '');
|
||||
await expect(scanLog(page).locator('summary')).toContainText('Scan log');
|
||||
await scanLog(page).locator('summary').click();
|
||||
await expect(scanLog(page).locator('pre')).toHaveAttribute('tabindex', '0');
|
||||
await expect(scanLog(page).locator('pre')).toHaveText(/^\d\d:\d\d:\d\d \+ live\/new\.png · Added to the Library as “Live”\n\d\d:\d\d:\d\d \+ live\/next\.png · Added$/);
|
||||
|
||||
// Finished: the header says so, Cancel scan is gone and the counters stay.
|
||||
await finish(request, job.id);
|
||||
await expect(heading(page).locator('p.muted')).toHaveText(/^Finished \d\d:\d\d · 1,501 added · 19 errors$/);
|
||||
await expect(heading(page).locator('.heading-actions > button')).toHaveText(['Download report']);
|
||||
await expect(tiles(page).first().locator('dd')).toHaveText('1,501');
|
||||
});
|
||||
|
||||
test('added rows open the viewer, error rows are plain', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
const job = await startJob(request);
|
||||
// The fixture's two listed files share a name in the output and input roots; the row's root label tells them apart.
|
||||
await step(request, job.id, { processed: 3, records: [
|
||||
{ group: 'added', path: 'shared/same.png', root: 'output', detail: 'Added to the Library as “Cat portrait”' },
|
||||
{ group: 'known', path: 'shared/same.png', root: 'input', detail: 'Already linked to “Dog portrait”' },
|
||||
{ group: 'errors', path: 'broken/truncated.png', root: 'output', detail: 'Error: the file could not be processed (see the server log)' },
|
||||
] });
|
||||
await openJob(page, job.id);
|
||||
await expect(outcomeButtons(page).first()).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(fileRows(page)).toHaveCount(1);
|
||||
const added = fileRows(page).first();
|
||||
expect(await added.evaluate(node => node.localName)).toBe('button');
|
||||
await expect(added.locator('.path')).toHaveText('shared/same.png');
|
||||
await expect(added.locator('.why')).toHaveText('Added to the Library as “Cat portrait”');
|
||||
await expect(resultsNote(page)).toHaveText('Each row opens the image in the viewer.');
|
||||
await added.click();
|
||||
const viewer = page.locator('dialog#viewer-dialog[open]');
|
||||
await expect(viewer).toBeVisible();
|
||||
await expect(viewer.locator('#viewer-title')).toContainText('same.png');
|
||||
await expect(viewer.locator('#viewer-stage img').first()).toHaveAttribute('src', /[?&]root=0(&|$)/);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(viewer).toHaveCount(0);
|
||||
await expect(added).toBeFocused();
|
||||
|
||||
// Already known opens the input root's file.
|
||||
await outcomeButtons(page).filter({ hasText: 'Already known' }).click();
|
||||
await expect(outcomeButtons(page).filter({ hasText: 'Already known' })).toHaveAttribute('aria-pressed', 'true');
|
||||
await fileRows(page).first().click();
|
||||
await expect(page.locator('dialog#viewer-dialog[open] #viewer-stage img').first()).toHaveAttribute('src', /[?&]root=1(&|$)/);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Errors: plain text rows, no viewer hint.
|
||||
await outcomeButtons(page).filter({ hasText: 'Errors' }).click();
|
||||
await expect(fileRows(page)).toHaveCount(1);
|
||||
expect(await fileRows(page).first().evaluate(node => node.localName)).toBe('div');
|
||||
await expect(fileRows(page).first().locator('.path')).toHaveText('broken/truncated.png');
|
||||
await expect(resultsNote(page)).toHaveCount(0);
|
||||
// An empty group says so.
|
||||
await outcomeButtons(page).filter({ hasText: 'Needs review' }).click();
|
||||
await expect(resultsSection(page).locator('.outcome-files > p.empty-state')).toHaveText('No files in this group.');
|
||||
|
||||
// A listed row whose file has left the listing toasts instead of opening an empty viewer.
|
||||
await step(request, job.id, { records: [{ group: 'added', path: 'gone/missing.png', root: 'output', detail: 'Added' }] });
|
||||
await outcomeButtons(page).first().click();
|
||||
await expect(fileRows(page)).toHaveCount(2);
|
||||
await fileRows(page).nth(1).click();
|
||||
await expect(toast(page)).toHaveText('This file is no longer in Images. Rescan from Import and try again.');
|
||||
await expect(page.locator('dialog#viewer-dialog')).toHaveCount(0);
|
||||
// A row opens only its own root's file: a root whose label no longer matches (a replaced source) never opens the
|
||||
// other root's same path, and a row without a root index opens nothing (review M8, M9).
|
||||
const lookups = [];
|
||||
page.on('request', item => { const url = new URL(item.url()); if (url.pathname === '/prompt_manager/images/output' && url.searchParams.get('path')) lookups.push(url.search); });
|
||||
await step(request, job.id, { records: [
|
||||
{ group: 'added', path: 'shared/same.png', root: 'output', root_index: 1, detail: 'Added' },
|
||||
{ group: 'added', path: 'shared/same.png', root: 'output', root_index: null, detail: 'Added' },
|
||||
] });
|
||||
await expect(fileRows(page)).toHaveCount(4);
|
||||
for (const index of [2, 3]) {
|
||||
await fileRows(page).nth(index).click();
|
||||
await expect(toast(page)).toHaveText('This file is no longer in Images. Rescan from Import and try again.');
|
||||
await expect(page.locator('dialog#viewer-dialog')).toHaveCount(0);
|
||||
}
|
||||
expect(lookups).toEqual([]);
|
||||
// The lookup is the exact identity, not a name search.
|
||||
await fileRows(page).first().click();
|
||||
await expect(page.locator('dialog#viewer-dialog[open]')).toBeVisible();
|
||||
expect(lookups).toEqual(['?mode=workspace&root=0&path=shared%2Fsame.png&limit=5']);
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
test('filter narrows the selected outcome', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
const job = await startJob(request);
|
||||
await step(request, job.id, { processed: 4, records: [
|
||||
{ group: 'added', path: 'CCC/portrait_0001.png', detail: 'Added' },
|
||||
{ group: 'added', path: 'CCC/portrait_0002.png', detail: 'Added' },
|
||||
{ group: 'added', path: 'forest/pines_0008.png', detail: 'Added' },
|
||||
{ group: 'errors', path: 'CCC/broken.png', detail: 'Error' },
|
||||
] });
|
||||
await openJob(page, job.id);
|
||||
await expect(fileRows(page)).toHaveCount(3);
|
||||
await expect(filter(page)).toHaveAttribute('placeholder', 'Filter by file path…');
|
||||
await filter(page).fill('ccc/');
|
||||
await expect(fileRows(page).locator('.path')).toHaveText(['CCC/portrait_0001.png', 'CCC/portrait_0002.png']);
|
||||
await expect(resultsNote(page)).toHaveText(`Showing 2 of 3 · ${VIEWER_HINT}`);
|
||||
// The filter stays while another outcome is chosen.
|
||||
await outcomeButtons(page).filter({ hasText: 'Errors' }).click();
|
||||
await expect(fileRows(page).locator('.path')).toHaveText(['CCC/broken.png']);
|
||||
await filter(page).fill('pines');
|
||||
await expect(fileRows(page)).toHaveCount(0);
|
||||
await expect(resultsSection(page).locator('.outcome-files > p.empty-state')).toHaveText('No listed file matches this filter.');
|
||||
await outcomeButtons(page).first().click();
|
||||
await expect(fileRows(page).locator('.path')).toHaveText(['forest/pines_0008.png']);
|
||||
await filter(page).fill('');
|
||||
await expect(fileRows(page)).toHaveCount(3);
|
||||
});
|
||||
|
||||
test('job view renders 200-row cap note', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
const job = await startJob(request);
|
||||
await step(request, job.id, { processed: 260, bulk: { group: 'added', count: 250 }, records: [
|
||||
...Array.from({ length: 10 }, (_, index) => ({ group: 'no_metadata', path: `misc/plain_${index}.png`, detail: 'No prompt metadata in the file' })),
|
||||
] });
|
||||
// Every detail read of this job: in flight, and whether one has returned the full listing.
|
||||
const detailUrl = `/prompt_manager/jobs/${job.id}`;
|
||||
const isDetail = item => new URL(item.url()).pathname === detailUrl;
|
||||
const detailReads = { inFlight: 0, full: false };
|
||||
page.on('request', item => { if (isDetail(item)) detailReads.inFlight += 1; });
|
||||
page.on('requestfailed', item => { if (isDetail(item)) detailReads.inFlight -= 1; });
|
||||
page.on('response', async response => {
|
||||
if (!isDetail(response)) return;
|
||||
const body = await response.json().catch(() => null);
|
||||
const outcomes = body?.job?.outcomes ?? {};
|
||||
if (outcomes.added?.length === 200 && outcomes.no_metadata?.length === 10) detailReads.full = true;
|
||||
detailReads.inFlight -= 1;
|
||||
});
|
||||
await openJob(page, job.id);
|
||||
await expect(tiles(page).first().locator('dd')).toHaveText('250');
|
||||
await expect(fileRows(page)).toHaveCount(200);
|
||||
await expect(resultsNote(page)).toHaveText(`Showing 200 of 250 · ${VIEWER_HINT}`);
|
||||
await outcomeButtons(page).filter({ hasText: 'No usable metadata' }).click();
|
||||
await expect(fileRows(page)).toHaveCount(10);
|
||||
await expect(resultsNote(page)).toHaveCount(0);
|
||||
// Every listed row is in the page and it stays responsive: a filter answers at once.
|
||||
await outcomeButtons(page).first().click();
|
||||
await filter(page).fill('file_00199');
|
||||
await expect(fileRows(page)).toHaveCount(1);
|
||||
await expect(resultsNote(page)).toHaveText(`Showing 1 of 250 · ${VIEWER_HINT}`);
|
||||
|
||||
// Review M12: with the list capped and the log closed, more files only update the counters; the detail is read
|
||||
// again only when the log opens with new lines.
|
||||
// The reads from opening the view have settled: one returned the full listing and none is in flight. The view
|
||||
// re-reads only while what the summary lists differs from the last read, so no further read is due.
|
||||
await expect.poll(() => detailReads.full && detailReads.inFlight === 0).toBe(true);
|
||||
const reads = [];
|
||||
page.on('request', item => { if (new URL(item.url()).pathname === `/prompt_manager/jobs/${job.id}`) reads.push(Date.now()); });
|
||||
await step(request, job.id, { processed: 300, bulk: { group: 'added', count: 40 } });
|
||||
await expect(tiles(page).first().locator('dd')).toHaveText('290');
|
||||
await page.waitForTimeout(1500);
|
||||
expect(reads).toEqual([]);
|
||||
await scanLog(page).locator('summary').click();
|
||||
await expect.poll(() => reads.length).toBe(1);
|
||||
await expect(scanLog(page).locator('pre')).toContainText('bulk/file_00039.png');
|
||||
});
|
||||
|
||||
test('cancel scan ends cancelled with partial counts', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
const job = await startJob(request);
|
||||
await step(request, job.id, { processed: 9, counters: { added: 7, errors: 2 } });
|
||||
await openJob(page, job.id);
|
||||
// From the keyboard: when the job ends and Cancel scan goes away, focus moves to the job's heading (review M4).
|
||||
await heading(page).getByRole('button', { name: 'Cancel scan', exact: true }).focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(toast(page)).toHaveText('Scan cancelled · 7 added');
|
||||
await expect(heading(page).locator('h1')).toBeFocused();
|
||||
await expect(heading(page).locator('p.muted')).toHaveText(/^Cancelled \d\d:\d\d at a file boundary · 7 added · completed work is kept$/);
|
||||
await expect(heading(page).getByRole('button', { name: 'Cancel scan', exact: true })).toHaveCount(0);
|
||||
await expect(progressSection(page).locator('.panel-heading > span.small')).toHaveText('Cancelled after 9 of 19,622 files');
|
||||
expect((await tileValues(page)).map(([, , value]) => value)).toEqual(['7', '0', '0', '0', '2']);
|
||||
expect((await listed(request))[0].state).toBe('cancelled');
|
||||
|
||||
// A failed cancel request keeps the button and says so.
|
||||
const second = await startJob(request);
|
||||
await openJob(page, second.id);
|
||||
await page.route('**/cancel', route => route.fulfill({ status: 500, json: { success: false, error: 'nope' } }));
|
||||
await heading(page).getByRole('button', { name: 'Cancel scan', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('The job could not be cancelled. Try again.');
|
||||
await expect(heading(page).getByRole('button', { name: 'Cancel scan', exact: true })).not.toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
|
||||
test('busy start names the running job', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
const running = await startJob(request, { title: 'Database maintenance', kind: 'maintenance' });
|
||||
await startFromImport(page);
|
||||
await expect(toast(page)).toHaveText('Another job is running: Database maintenance.');
|
||||
await expect(toast(page).getByRole('link', { name: 'Database maintenance', exact: true })).toHaveAttribute('href', `#/activity/${running.id}`);
|
||||
await expect(page).toHaveURL(/#\/images$/);
|
||||
expect((await listed(request)).map(item => item.id)).toEqual([running.id]);
|
||||
// Review M14: a toast with a link outlasts the plain 6.5 s, so the link stays reachable.
|
||||
await page.waitForTimeout(7000);
|
||||
await expect(toast(page).getByRole('link', { name: 'Database maintenance', exact: true })).toBeVisible();
|
||||
await toast(page).getByRole('link', { name: 'Database maintenance', exact: true }).click();
|
||||
await expect(heading(page).locator('h1')).toHaveText('Database maintenance');
|
||||
// A job of another kind cancels as "Cancel job".
|
||||
await expect(heading(page).locator('.heading-actions > button')).toHaveText(['Download report', 'Cancel job']);
|
||||
});
|
||||
|
||||
test('an unknown or restarted job view says so', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
await page.goto(`${workspace}#/activity/${'0'.repeat(32)}`);
|
||||
await expect(main(page).locator('section.surface p.empty-state')).toHaveText('This job is not in this session’s Activity. Jobs are kept until ComfyUI restarts.');
|
||||
await expect(main(page).getByRole('link', { name: 'All activity', exact: true })).toHaveAttribute('href', '#/activity');
|
||||
|
||||
const job = await startJob(request);
|
||||
await step(request, job.id, { processed: 5, records: [{ group: 'added', path: 'kept/row.png', detail: 'Added' }] });
|
||||
await openJob(page, job.id);
|
||||
await expect(fileRows(page)).toHaveCount(1);
|
||||
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
|
||||
await expect(heading(page).locator('p.muted')).toHaveText(LOST);
|
||||
await expect(heading(page).getByRole('button', { name: 'Cancel scan', exact: true })).toHaveCount(0);
|
||||
// A lost job has no report on the server (review M9): Download report is disabled rather than failing.
|
||||
await expect(heading(page).getByRole('button', { name: 'Download report', exact: true })).toBeDisabled();
|
||||
// What was read before the restart stays on the page.
|
||||
await expect(fileRows(page)).toHaveCount(1);
|
||||
await expect(pill(page)).toBeHidden();
|
||||
});
|
||||
|
||||
// Review M3: a finished job the server no longer lists is gone from this tab too, and its open view says so.
|
||||
test('a finished job the server pruned leaves its open view honest', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
const job = await startJob(request);
|
||||
await step(request, job.id, { processed: 1, records: [{ group: 'added', path: 'kept/row.png', detail: 'Added' }] });
|
||||
await finish(request, job.id);
|
||||
await openJob(page, job.id);
|
||||
await expect(fileRows(page)).toHaveCount(1);
|
||||
await filter(page).focus();
|
||||
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
|
||||
// The next periodic list read (15 s) no longer has the finished job.
|
||||
test.slow();
|
||||
await expect(main(page).locator('section.surface p.empty-state')).toHaveText('This job is not in this session’s Activity. Jobs are kept until ComfyUI restarts.', { timeout: 20000 });
|
||||
await expect(progressSection(page)).toHaveCount(0);
|
||||
await expect(heading(page).locator('h1')).toHaveText('Activity');
|
||||
await expect(heading(page).locator('h1')).toBeFocused();
|
||||
await expect(page).toHaveTitle('Activity · Prompt Manager');
|
||||
});
|
||||
|
||||
// Activity-2 Task 5 (D59): Download report and the log's scroll position.
|
||||
test('download report saves the job JSON', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
const job = await startJob(request);
|
||||
await step(request, job.id, { processed: 2, records: [
|
||||
{ group: 'added', path: 'sub/a.png', root: 'output', detail: 'Added' },
|
||||
{ group: 'errors', path: 'broken/b.png', root: 'output', detail: 'Error' },
|
||||
] });
|
||||
await finish(request, job.id);
|
||||
await openJob(page, job.id);
|
||||
const button = heading(page).getByRole('button', { name: 'Download report', exact: true });
|
||||
await expect(button).toBeEnabled();
|
||||
const [download] = await Promise.all([page.waitForEvent('download'), button.click()]);
|
||||
expect(download.suggestedFilename()).toMatch(/^prompt-manager-scan-\d{8}-\d{4}\.json$/);
|
||||
const path = await download.path();
|
||||
const report = JSON.parse(await (await import('node:fs/promises')).readFile(path, 'utf8'));
|
||||
expect(report.job.id).toBe(job.id);
|
||||
expect(report.outcomes.added.rows.map(row => row.path)).toEqual(['sub/a.png']);
|
||||
expect(report.outcomes.errors.truncated).toBe(false);
|
||||
expect(report.log.length).toBeGreaterThan(0);
|
||||
await expect(toast(page)).toHaveText('Job report downloaded.');
|
||||
});
|
||||
|
||||
test('log keeps the reader\'s scroll position', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
const job = await startJob(request);
|
||||
const more = (from, count) => Array.from({ length: count }, (_, index) => ({ group: 'known', path: `log/f_${from + index}.png`,
|
||||
root: 'output', detail: 'Known' }));
|
||||
await step(request, job.id, { processed: 60, records: more(0, 60) });
|
||||
await openJob(page, job.id);
|
||||
await scanLog(page).locator('summary').click();
|
||||
const pre = scanLog(page).locator('pre');
|
||||
await expect(pre).toContainText('log/f_59.png');
|
||||
const metrics = () => pre.evaluate(node => ({ top: node.scrollTop, max: node.scrollHeight - node.clientHeight }));
|
||||
// Opening starts at the newest line.
|
||||
await expect.poll(async () => { const m = await metrics(); return m.max > 0 && m.top >= m.max - 2; }).toBe(true);
|
||||
// Pinned to the bottom: new lines are followed.
|
||||
await step(request, job.id, { processed: 90, records: more(60, 30) });
|
||||
await expect(pre).toContainText('log/f_89.png');
|
||||
await expect.poll(async () => { const m = await metrics(); return m.top >= m.max - 2; }).toBe(true);
|
||||
// Scrolled up: the position stays while more lines arrive.
|
||||
await pre.evaluate(node => { node.scrollTop = 40; });
|
||||
await step(request, job.id, { processed: 120, records: more(90, 30) });
|
||||
await expect(pre).toContainText('log/f_119.png');
|
||||
expect((await metrics()).top).toBe(40);
|
||||
// Closed and opened again (review M6): every open starts at the newest line, also after lines arrived meanwhile.
|
||||
await scanLog(page).locator('summary').click();
|
||||
await expect(scanLog(page)).not.toHaveAttribute('open', '');
|
||||
await step(request, job.id, { processed: 150, records: more(120, 30) });
|
||||
await scanLog(page).locator('summary').click();
|
||||
await expect(pre).toContainText('log/f_149.png');
|
||||
await expect.poll(async () => { const m = await metrics(); return m.max > 0 && m.top >= m.max - 2; }).toBe(true);
|
||||
});
|
||||
|
||||
test('a failed report download says so and leaves the button usable', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
const job = await startJob(request);
|
||||
await finish(request, job.id);
|
||||
await openJob(page, job.id);
|
||||
await page.route(`**/prompt_manager/jobs/${job.id}/report`, route => route.fulfill({ status: 500, body: 'no' }));
|
||||
const button = heading(page).getByRole('button', { name: 'Download report', exact: true });
|
||||
await button.click();
|
||||
await expect(toast(page)).toHaveText('The report could not be downloaded. Try again.');
|
||||
await expect(button).not.toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {readFile,writeFile} from 'node:fs/promises';
|
||||
import {createHash} from 'node:crypto';
|
||||
|
||||
@@ -9,8 +9,9 @@ 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();};
|
||||
// A history opens on its first negative variant, as in the concept; these contracts start from the whole shared gallery.
|
||||
const showAllImages=async page=>{const all=page.getByRole('button',{name:'All images for this positive',exact:true});await all.click();await expect(all).toHaveAttribute('aria-pressed','true');};
|
||||
// A history opens on its first negative variant, as in the concept; these contracts start from the lane that links the
|
||||
// fixture image (#249 D26: All images for this positive is a dialog now, not a gallery filter).
|
||||
const showLinkedImage=async page=>{const dot=page.getByRole('button',{name:'Select negative Saved exact negative',exact:true});await dot.click();await expect(dot).toHaveAttribute('aria-pressed','true');};
|
||||
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);
|
||||
|
||||
@@ -93,7 +94,7 @@ test('same-path replacement is current content and a during-inspection change is
|
||||
|
||||
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 showAllImages(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
|
||||
await open(page,f.detail_id);await showLinkedImage(page);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 {
|
||||
@@ -128,10 +129,10 @@ test('archive delete merge and prune preserve frozen evidence without repairing
|
||||
});
|
||||
|
||||
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 f=page.fixture;await open(page,f.detail_id);const original=await exact(saved(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'));
|
||||
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(page.locator('.library-parent-status')).toContainText('changed');expect(await exact(saved(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'))).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);
|
||||
@@ -163,8 +164,8 @@ test('strict reset seeds real A,A,B positions, two contexts and independently se
|
||||
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();
|
||||
const selected=detail.variants.find(v=>v.negative_text==='A negative').title;
|
||||
await page.getByRole('button',{name:`Select negative ${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');
|
||||
@@ -182,7 +183,7 @@ test('strict reset seeds real A,A,B positions, two contexts and independently se
|
||||
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');
|
||||
await expect(page.getByRole('button',{name:`Select negative ${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);
|
||||
});
|
||||
|
||||
@@ -199,18 +200,20 @@ test('frozen exact roles distinguish unknown, explicit empty, whitespace, mode a
|
||||
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 exact(pos.locator('p.prompt-text'))).toBe(chosen.positive.raw);expect(await exact(neg.locator('p.prompt-text'))).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:'Effective',exact:true}).click();expect(await exact(neg.locator('p.prompt-text'))).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:'Effective',exact:true}).click();expect(await exact(unknown.locator('p.prompt-text'))).toBe(' \n\t ');
|
||||
// D22: the frozen block has no source line; each field's source stays in "Frozen field provenance".
|
||||
await expect(unknown).not.toContainText('host_cache_output');await expect(dialog(page).locator('.recorded-provenance')).toContainText(/Effective · \w+ · 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 marker(page,f.runs.empty).click();await expect(dialog(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText('No negative prompt (explicitly empty)');
|
||||
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 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('p.prompt-text'))).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']);
|
||||
@@ -224,7 +227,7 @@ test('every ordered receipt remains selected through distinct current locations,
|
||||
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();
|
||||
const viewer=page.locator('dialog#viewer-dialog');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();
|
||||
@@ -245,7 +248,7 @@ test('inspection first discovers a real restore and immediately retires every su
|
||||
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(page.locator('dialog#viewer-dialog')).toBeVisible();
|
||||
await expect.poll(async()=> (await(await request.get('/__test__/history/checkpoint')).json()).entered).toBe(true);
|
||||
await page.evaluate(()=>{
|
||||
window.recordedRecoveryStates=[];
|
||||
@@ -259,7 +262,7 @@ test('inspection first discovers a real restore and immediately retires every su
|
||||
} 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});
|
||||
const viewer=page.locator('dialog#viewer-dialog');
|
||||
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);
|
||||
@@ -280,7 +283,7 @@ test('active stale inspection echo reaches history ownership without a second me
|
||||
});
|
||||
|
||||
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 f=page.fixture;await open(page,f.detail_id);await page.getByRole('button',{name:'Select negative Saved empty',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++;});
|
||||
@@ -297,9 +300,9 @@ test('unavailable exact receipt selection and targeted retry observe a newly ava
|
||||
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 missing.getByRole('button',{name:'View current file',exact:true}).click();await expect(page.locator('dialog#viewer-dialog')).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');
|
||||
await expect(page.getByRole('button',{name:'Select negative Saved empty',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);
|
||||
});
|
||||
|
||||
@@ -314,6 +317,19 @@ test('marker labels disclose exact observed dequeue and outcome including times
|
||||
}
|
||||
});
|
||||
|
||||
// A timeline scroll re-renders the markers on the next frame. On a slow runner that frame lands between the press and
|
||||
// the release of a marker tap (Playwright's own scroll-into-view scrolls the timeline first); the re-render must leave
|
||||
// the pressed marker's nodes alone, or Chromium touch drops the click. The scroll event is dispatched so the marker
|
||||
// stays under the pointer, and the two frames are the re-render itself.
|
||||
test('a timeline re-render between press and release keeps the marker tap',async({page})=>{
|
||||
const f=page.fixture;await open(page,f.detail_id);
|
||||
const target=marker(page,f.runs.shared,'z');await expect(target).toHaveAttribute('aria-disabled','false');await target.scrollIntoViewIfNeeded();
|
||||
const box=await target.boundingBox();await page.mouse.move(box.x+box.width/2,box.y+box.height/2);await page.mouse.down();
|
||||
await recorded(page).locator('.history-timeline').evaluate(timeline=>{timeline.dispatchEvent(new Event('scroll'));return new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));});
|
||||
await page.mouse.up();
|
||||
await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
|
||||
});
|
||||
|
||||
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();
|
||||
@@ -378,7 +394,7 @@ for(const [width,height] of [[1600,1100],[390,844]])for(const theme of ['dark','
|
||||
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');
|
||||
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/workspace.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'});
|
||||
@@ -397,7 +413,7 @@ test('history, run and output failures retry their own reads and route exit igno
|
||||
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 showAllImages(page);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 showLinkedImage(page);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();
|
||||
@@ -447,7 +463,7 @@ test('frozen Effective pair uses the shared acknowledged sender and Escape close
|
||||
});
|
||||
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 positive=await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'));
|
||||
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();
|
||||
@@ -466,16 +482,16 @@ test('the inline current file names its prompts apart from the frozen run fields
|
||||
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).getByRole('button',{name:'Raw',exact:true}).click();
|
||||
const frozen=dialog(page).getByRole('region',{name:'Frozen run prompt texts',exact:true});
|
||||
const before=await exact(frozen.getByRole('region',{name:'Positive prompt',exact:true}).locator('pre'));
|
||||
const before=await exact(frozen.getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'));
|
||||
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.keyboard.press('Escape');await expect(dialog(page)).toBeVisible();
|
||||
await expect(page.locator('dialog#viewer-dialog')).toBeVisible();await page.keyboard.press('Escape');await expect(dialog(page)).toBeVisible();
|
||||
const current=dialog(page).getByRole('region',{name:'Current reported file',exact:true});await expect(current).toBeVisible();
|
||||
await expect(current.getByRole('region',{name:'Current file positive prompt',exact:true}).locator('pre')).toHaveText('current file positive');
|
||||
await expect(current.getByRole('region',{name:'Current file negative prompt',exact:true}).locator('pre')).toHaveText('current file negative');
|
||||
await expect(current.getByRole('button',{name:'Copy current file positive prompt',exact:true})).toBeVisible();
|
||||
await expect(current.getByRole('region',{name:'Current file positive prompt',exact:true}).locator('p.prompt-text')).toHaveText('current file positive');
|
||||
await expect(current.getByRole('region',{name:'Current file negative prompt',exact:true}).locator('p.prompt-text')).toHaveText('current file negative');
|
||||
await expect(current.getByRole('button',{name:/^Copy current file positive prompt: /})).toBeVisible();
|
||||
for(const role of ['Positive','Negative'])await expect(dialog(page).getByRole('region',{name:`${role} prompt`,exact:true})).toHaveCount(1);
|
||||
await expect(current.getByRole('region',{name:/^(Positive|Negative) prompt$/})).toHaveCount(0);
|
||||
expect(await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('pre'))).toBe(before);
|
||||
expect(await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'))).toBe(before);
|
||||
});
|
||||
|
||||
test('the recorded run dialog with a resolved current file has no duplicate button or region names',async({page})=>{
|
||||
@@ -485,7 +501,7 @@ test('the recorded run dialog with a resolved current file has no duplicate butt
|
||||
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).getByRole('button',{name:'Raw',exact:true}).click();
|
||||
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.keyboard.press('Escape');
|
||||
await expect(page.locator('dialog#viewer-dialog')).toBeVisible();await page.keyboard.press('Escape');
|
||||
await expect(dialog(page).getByRole('region',{name:'Current reported file',exact:true})).toBeVisible();
|
||||
await expect(dialog(page).getByRole('region',{name:'Current file positive prompt',exact:true})).toBeVisible();
|
||||
// Receipt rows repeat per-row actions by design; every other control and region in the dialog must be uniquely named.
|
||||
@@ -500,7 +516,7 @@ test('the recorded run dialog with a resolved current file has no duplicate butt
|
||||
|
||||
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 dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();await expect(page.locator('dialog#viewer-dialog')).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(()=>{});});
|
||||
@@ -562,7 +578,7 @@ test('capture recovery reloads only reported outputs while retaining frozen run
|
||||
});
|
||||
|
||||
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 showAllImages(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
|
||||
const f=page.fixture;await open(page,f.detail_id);await showLinkedImage(page);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'}}}));
|
||||
@@ -573,8 +589,8 @@ test('history epoch recovery reports saved-parent read failures and retries with
|
||||
});
|
||||
|
||||
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 showAllImages(page);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 f=page.fixture;await open(page,f.detail_id);await showLinkedImage(page);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('p.prompt-text'));
|
||||
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();
|
||||
@@ -583,7 +599,7 @@ test('changed saved-owner identity is disclosed without silently relabeling or r
|
||||
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);
|
||||
await expect(page.locator('.library-parent-status')).toContainText('changed');expect(await exact(positive.locator('p.prompt-text'))).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');
|
||||
@@ -593,7 +609,7 @@ test('a bounded saved-parent prefix leaves previously loaded variants unverified
|
||||
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 choices=page.getByRole('button',{name:/^Select negative /});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');
|
||||
@@ -606,7 +622,7 @@ test('an authoritative save after a stale revalidation makes the saved view curr
|
||||
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 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();
|
||||
const choices=page.getByRole('button',{name:/^Select negative /});await expect(choices).toHaveCount(43);await choices.last().click();
|
||||
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');
|
||||
@@ -615,7 +631,7 @@ test('an authoritative save after a stale revalidation makes the saved view curr
|
||||
await expect(editor.getByRole('button',{name:'Save',exact:true})).toBeEnabled();await editor.getByLabel('Notes',{exact:true}).fill('Saved after stale revalidation');
|
||||
await editor.getByRole('button',{name:'Save',exact:true}).click();await expect(editor).toHaveCount(0);
|
||||
await expect(page.locator('.library-parent-status')).toBeHidden();await expect(page.getByRole('button',{name:'Reload saved Library view',exact:true})).toBeHidden();
|
||||
await expect(choices.first()).toBeEnabled();await expect(page.locator('.library-variant-dot[aria-pressed="true"]')).toHaveCount(1);
|
||||
await expect(choices.first()).toBeEnabled();await expect(page.locator('.variant-dot[aria-pressed="true"]')).toHaveCount(1);
|
||||
expect(await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).textContent()).toBe(negative);
|
||||
const names=await choices.evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')));expect(new Set(names).size).toBe(names.length);
|
||||
});
|
||||
@@ -623,15 +639,15 @@ test('an authoritative save after a stale revalidation makes the saved view curr
|
||||
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();
|
||||
const name=details.variants.find(variant=>variant.id===fixture.saved_pair).title;
|
||||
await page.getByRole('button',{name:`Select negative ${name}`,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'));
|
||||
const negative=await exact(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text'));
|
||||
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);
|
||||
await expect(page.getByRole('button',{name:`Select negative ${name}`,exact:true})).toHaveAttribute('aria-pressed','true');
|
||||
expect(await exact(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text'))).toBe(negative);
|
||||
expect(await gallery(page).locator('article[data-image-key]').first().evaluate(node=>node===window.fix1SavedGalleryCard)).toBe(true);expect(reads).toBe(0);
|
||||
};
|
||||
}
|
||||
@@ -702,7 +718,7 @@ test('capture-only recovery uses the changed real output capture without re-read
|
||||
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();
|
||||
expect(await exact(dialog(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text'))).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})=>{
|
||||
@@ -719,3 +735,310 @@ test('completed page one stays bounded across mode changes until explicit output
|
||||
await page.keyboard.press('Escape');await retained();
|
||||
} finally {releaseNext();}
|
||||
});
|
||||
|
||||
// #249 PR F1: the concept heading (app.js a591–597) and summary surface (a598) with real counts (D24).
|
||||
const historyHeading=page=>page.locator('#workspace-main > .heading');
|
||||
const summary=page=>page.locator('#workspace-main .library-history article.surface');
|
||||
test('history heading actions stay on one row at 1600 and are enabled after load',async({page,request})=>{
|
||||
await page.setViewportSize({width:1600,height:1100});const f=page.fixture;
|
||||
let releaseDetail;const held=new Promise(resolve=>{releaseDetail=resolve;});
|
||||
await page.route(new RegExp(`/prompt_manager/library/${f.positive_id}\\?`),async route=>{await held;await route.continue().catch(()=>{});});
|
||||
try {
|
||||
await page.goto(`/prompt_manager/workspace#/library/${f.positive_id}`);
|
||||
const heading=historyHeading(page),send=heading.locator('.heading-actions > button.send');
|
||||
await expect(heading.locator('p.muted')).toHaveText('Prompt history · one positive, one shared gallery');
|
||||
// D24: disabled only while the selected pair's roles are loading.
|
||||
await expect(send).toBeDisabled();releaseDetail();
|
||||
await expect(recorded(page)).toBeVisible();await expect(send).toBeEnabled();
|
||||
} finally {releaseDetail();}
|
||||
const heading=historyHeading(page),title=await heading.locator('h1').textContent();
|
||||
const actions=heading.locator('.heading-actions > button');
|
||||
await expect(actions).toHaveCount(3);
|
||||
expect(await actions.evaluateAll(nodes=>nodes.map(node=>node.textContent.trim()))).toEqual(['← Prompt library','Edit metadata','Send to ComfyUI']);
|
||||
await expect(actions.nth(2)).toHaveClass('send');
|
||||
await expect(actions.nth(2)).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${title}`);
|
||||
for(const action of [actions.nth(0),actions.nth(1)]) await expect(action).toBeEnabled();
|
||||
const boxes=await actions.evaluateAll(nodes=>nodes.map(node=>{const box=node.getBoundingClientRect();return {y:box.y,height:box.height};}));
|
||||
expect(new Set(boxes.map(box=>box.y)).size).toBe(1);
|
||||
const row=await heading.locator('.heading-actions').evaluate(node=>node.getBoundingClientRect().height);
|
||||
expect(row).toBe(boxes[0].height);
|
||||
});
|
||||
test('summary meta uses recorded runs',async({page,request})=>{
|
||||
const f=page.fixture;await open(page,f.positive_id);
|
||||
const detail=await(await request.get(`/prompt_manager/library/${f.positive_id}`)).json(),runs=(await historyRead(request,f.positive_id)).recorded_run_count;
|
||||
const surface=summary(page),title=await historyHeading(page).locator('h1').textContent();
|
||||
await expect(surface).toHaveCount(1);
|
||||
await expect(surface.locator(':scope > span.chip')).toHaveText(detail.positive.category || 'Uncategorized');
|
||||
const plural=(count,word)=>`${count} ${count===1?word:`${word}s`}`;
|
||||
await expect(surface.locator(':scope > .card-meta > span')).toHaveText([plural(runs,'tracked run'),plural(detail.positive.image_count,'image'),plural(detail.positive.variant_count,'negative variant')]);
|
||||
// The concept order: category chip, positive block, selected negative block, then the meta line.
|
||||
expect(await surface.evaluate(node=>[...node.querySelectorAll('.chip, .prompt-section, .card-meta')].filter(item=>item.checkVisibility()).map(item=>item.className))).toEqual(['chip','prompt-section positive','prompt-section negative','card-meta']);
|
||||
for(const name of ['Copy positive prompt','Send positive prompt to ComfyUI','Copy negative prompt','Send negative prompt to ComfyUI'])
|
||||
await expect(saved(page).getByRole('button',{name:`${name}: ${title}`,exact:true})).toBeVisible();
|
||||
await expect(surface.locator('.prompt-section').first()).toHaveCSS('border-top-width','1px');
|
||||
});
|
||||
|
||||
// #249 F2: the concept's negative variants panel (app.js a598, style.css 1059-1154) and its two dialogs (D26).
|
||||
const variantsPanel=page=>page.getByRole('region',{name:'Saved negative variants',exact:true});
|
||||
const modalDialog=(page,name)=>page.getByRole('dialog',{name,exact:true});
|
||||
test('variants panel matches the concept',async({page})=>{
|
||||
const f=page.fixture;await open(page,f.canonical_id);const panel=variantsPanel(page);
|
||||
await expect(panel).toHaveClass('surface timeline-panel');
|
||||
const heading=panel.locator(':scope > .panel-heading');
|
||||
await expect(heading.locator(':scope > div > h2')).toHaveText('Negative variants');
|
||||
await expect(heading.locator(':scope > div > p.small')).toHaveText('Choose a red dot to change the negative and filter this gallery.');
|
||||
const actions=heading.locator(':scope > .row-actions > button');
|
||||
expect(await actions.evaluateAll(nodes=>nodes.map(node=>node.textContent))).toEqual(['Compare negatives','+ Add negative','Show archived (0)']);
|
||||
const compare=actions.nth(0);await expect(compare).toHaveAttribute('aria-pressed','false');
|
||||
await compare.click();await expect(compare).toHaveAttribute('aria-pressed','true');await compare.click();await expect(compare).toHaveAttribute('aria-pressed','false');
|
||||
const timeline=panel.locator(':scope > .timeline');
|
||||
await expect(timeline.locator(':scope > .positive-lane > span')).toHaveText('Positive prompt · unchanged');
|
||||
const lanes=timeline.locator(':scope > .variant-lane');await expect(lanes).toHaveCount(3);
|
||||
// Newest saved pair first, as the bounded variant page reads; every lane is offset 38 + 47i.
|
||||
const names=['No negative','Clean geometry','Quality baseline'],runs=['0 runs','2 runs','2 runs'];
|
||||
for(const [index,name] of names.entries()) {
|
||||
const lane=lanes.nth(index);
|
||||
await expect(lane).toHaveAttribute('style',`--lane-offset: ${38+index*47}px;`);
|
||||
const dot=lane.locator(':scope > button.variant-dot');
|
||||
await expect(dot).toHaveText('−');await expect(dot).toHaveAccessibleName(`Select negative ${name}`);
|
||||
await expect(dot).toHaveAttribute('aria-pressed',String(index===0));
|
||||
await expect(lane.locator(':scope > span')).toHaveText(`${name} · ${runs[index]}`);
|
||||
expect(await lane.evaluate(node=>[...node.children].filter(child=>!child.hidden).map(child=>child.tagName.toLowerCase()+'.'+child.className))).toEqual(index===0?['button.variant-dot','span.']:['button.variant-dot','span.','button.run-dot']);
|
||||
}
|
||||
await lanes.nth(2).locator('.variant-dot').click();await expect(lanes.nth(2).locator('.variant-dot')).toHaveAttribute('aria-pressed','true');
|
||||
await expect(lanes.nth(0).locator('.variant-dot')).toHaveAttribute('aria-pressed','false');
|
||||
await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(2);
|
||||
const lower=panel.locator(':scope > .timeline-lower');
|
||||
await expect(lower.locator(':scope > span.small')).toHaveText('Green + positive · red − negative · small dot repeat run');
|
||||
await expect(lower.locator(':scope > button')).toHaveText('All images for this positive');
|
||||
await expect(lower.locator(':scope > button')).not.toHaveAttribute('aria-pressed',/.*/);
|
||||
await expect(panel).not.toContainText('Saved combinations, not an execution timeline.');
|
||||
// The concept tokens reach the lanes (style.css 1096-1137).
|
||||
await expect(lanes.first()).toHaveCSS('height','57px');await expect(lanes.nth(1)).toHaveCSS('margin-left','85px');
|
||||
await expect(lanes.first().locator('.variant-dot')).toHaveCSS('border-radius','50%');
|
||||
await expect(lanes.nth(1).locator('.run-dot')).toHaveCSS('left','190px');
|
||||
});
|
||||
test('run dot appears only for pairs with more than one run and opens its run',async({page})=>{
|
||||
const f=page.fixture;await open(page,f.canonical_id);const panel=variantsPanel(page);
|
||||
await expect(panel.getByRole('button',{name:/^Show repeat run for /})).toHaveCount(2);
|
||||
await expect(panel.getByRole('button',{name:'Show repeat run for No negative',exact:true})).toHaveCount(0);
|
||||
// D45: the lane text wraps before the run dot, and a coarse pointer reaches the 12px dot through a 44px target.
|
||||
for(const lane of await panel.locator('.variant-lane:has(> .run-dot:not([hidden]))').all()) {
|
||||
const [text,dot]=await Promise.all([lane.locator(':scope > span').boundingBox(),lane.locator(':scope > .run-dot').boundingBox()]);
|
||||
expect(text.width).toBeLessThanOrEqual(139.5);expect(text.x+text.width).toBeLessThan(dot.x);
|
||||
}
|
||||
if(await page.evaluate(()=>matchMedia('(pointer: coarse)').matches)) {
|
||||
const dot=panel.getByRole('button',{name:'Show repeat run for Quality baseline',exact:true});await dot.scrollIntoViewIfNeeded();
|
||||
const reach=await dot.evaluate(node=>{
|
||||
const box=node.getBoundingClientRect(),x=box.x+box.width/2,y=box.y+box.height/2;
|
||||
return [[x-20,y],[x+20,y],[x,y-20],[x,y+20]].map(([px,py])=>{const hit=document.elementFromPoint(px,py);return hit?.closest('button')===node?'dot':`${hit?.tagName}.${hit?.className}`;});
|
||||
});
|
||||
expect(reach).toEqual(['dot','dot','dot','dot']);
|
||||
}
|
||||
// The baseline has a recorded execution: the dot opens Recorded run for it.
|
||||
const baseline=panel.getByRole('button',{name:'Show repeat run for Quality baseline',exact:true});await baseline.click();
|
||||
await expect(dialog(page)).toBeVisible();await expect(dialog(page)).toContainText(f.canonical_run);
|
||||
await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();await expect(dialog(page)).toHaveCount(0);
|
||||
// Clean geometry was counted but never recorded: Repeat run shows its newest linked image only.
|
||||
const clean=panel.getByRole('button',{name:'Show repeat run for Clean geometry',exact:true});
|
||||
const reads=[];page.on('request',request=>{const url=new URL(request.url());if(url.pathname===`/prompt_manager/library/${f.canonical_id}/images`)reads.push(url.searchParams);});
|
||||
await clean.click();const repeat=modalDialog(page,'Repeat run');await expect(repeat).toBeVisible();
|
||||
await expect(repeat.locator('#modal-body > p.small')).toHaveText('Same positive and negative, another execution.');
|
||||
await expect(repeat.locator('#modal-body > .spaced-15 article[data-image-key]')).toHaveCount(1);
|
||||
// D47: as in the concept (a1291-1304), the dialog title names the image; no collection heading or count repeats it.
|
||||
await expect(repeat.locator('.section-caption')).toHaveCount(0);
|
||||
await expect(repeat.getByRole('heading',{name:'Linked images',exact:true})).toHaveCount(0);
|
||||
await expect(repeat.getByText(/ loaded · /)).toHaveCount(0);
|
||||
expect(await repeat.evaluate(node=>[...node.querySelectorAll('h1, h2')].filter(item=>!item.closest('aside')).map(item=>item.textContent))).toEqual(['Repeat run']);
|
||||
expect(reads.at(-1).get('pair_id')).toBe(String(f.clean_pair));expect(reads.at(-1).get('limit')).toBe('1');
|
||||
await page.keyboard.press('Escape');await expect(repeat).toHaveCount(0);await expect(clean).toBeFocused();
|
||||
});
|
||||
test('all images dialog lists every image for the positive',async({page,request})=>{
|
||||
const f=page.fixture;await open(page,f.detail_id);
|
||||
const detail=await(await request.get(`/prompt_manager/library/${f.detail_id}`)).json();
|
||||
const all=variantsPanel(page).getByRole('button',{name:'All images for this positive',exact:true});
|
||||
// Opening on the first lane, the shared gallery shows that lane only.
|
||||
await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(0);
|
||||
await all.click();const listing=modalDialog(page,'All images for this positive');await expect(listing).toBeVisible();
|
||||
await expect(listing.locator('article[data-image-key]')).toHaveCount(detail.positive.image_count);
|
||||
await page.keyboard.press('Escape');await expect(listing).toHaveCount(0);await expect(all).toBeFocused();
|
||||
await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(0);
|
||||
// Leaving the route closes an open dialog and its gallery.
|
||||
await all.click();await expect(listing.locator('article[data-image-key]')).toHaveCount(detail.positive.image_count);
|
||||
await page.evaluate(()=>{location.hash='#/overview';});await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
|
||||
await expect(page.locator('dialog[open]')).toHaveCount(0);await expect(page.locator('#modal')).toHaveCount(0);
|
||||
});
|
||||
test('compare toggles, follows the selected dot and stacks at 390',async({page})=>{
|
||||
const f=page.fixture;await open(page,f.canonical_id);const panel=variantsPanel(page);
|
||||
const compare=panel.getByRole('button',{name:'Compare negatives',exact:true});
|
||||
const section=page.locator('.library-history .timeline-panel + section.surface');
|
||||
await expect(section).toHaveCount(0);
|
||||
await compare.click();await expect(compare).toHaveAttribute('aria-pressed','true');
|
||||
// The concept section (app.js a599-616) follows the timeline panel; the baseline is the oldest active lane (D25).
|
||||
await expect(section).toHaveCount(1);
|
||||
expect(await section.evaluate(node=>[...node.children].map(child=>child.tagName.toLowerCase()+(child.className?'.'+child.className:'')))).toEqual(['h2','p','div.compare-grid','p.small','p.small']);
|
||||
await expect(section.locator(':scope > h2')).toHaveText('Compare negative variants');
|
||||
await expect(section.locator(':scope > p').first()).toHaveText('Baseline compared with No negative. Choose another red dot to change the comparison.');
|
||||
const items=section.locator(':scope > .compare-grid > .compare-item');await expect(items).toHaveCount(2);
|
||||
await expect(items.nth(0).locator('.prompt-section.negative .prompt-text')).toHaveText('blurred edges, watermark');
|
||||
await expect(items.nth(1).locator('.prompt-section.negative .prompt-text')).toHaveText('No negative prompt (explicitly empty)');
|
||||
await expect(items.nth(0).locator('.prompt-section.positive')).toHaveCount(0);
|
||||
// D46: the two blocks are named apart, so no region or action name repeats on the page.
|
||||
await expect(section.getByRole('region',{name:'Baseline negative prompt',exact:true})).toBeVisible();
|
||||
await expect(section.getByRole('region',{name:'Selected negative prompt',exact:true})).toBeVisible();
|
||||
await expect(section.locator(':scope > p.small').first()).toHaveText('Added terms');
|
||||
await expect(section.locator(':scope > p.small').nth(1)).toHaveText('No added terms.');
|
||||
await expect(section.locator('.diff-added')).toHaveCount(0);
|
||||
// Another dot updates the same section in place.
|
||||
const node=await section.elementHandle();
|
||||
await panel.getByRole('button',{name:'Select negative Clean geometry',exact:true}).click();
|
||||
await expect(section.locator(':scope > p').first()).toHaveText('Baseline compared with Clean geometry. Choose another red dot to change the comparison.');
|
||||
await expect(items.nth(1).locator('.prompt-text')).toHaveText('flat light');
|
||||
await expect(section.locator(':scope > span.diff-added')).toHaveText(['+ flat light']);
|
||||
await expect(section.locator(':scope > p.small')).toHaveCount(1);
|
||||
expect(await node.evaluate(element=>element.isConnected)).toBe(true);
|
||||
await expect(section.locator('.diff-added')).toHaveCSS('display','inline-block');
|
||||
await expect(section.locator('.compare-item').first()).toHaveCSS('padding','15px');
|
||||
await page.setViewportSize({width:1600,height:1100});
|
||||
let boxes=await items.evaluateAll(nodes=>nodes.map(node=>node.getBoundingClientRect().toJSON()));
|
||||
expect(boxes[1].x).toBeGreaterThan(boxes[0].x+boxes[0].width-1);expect(Math.abs(boxes[1].y-boxes[0].y)).toBeLessThan(1);
|
||||
await page.setViewportSize({width:390,height:844});
|
||||
boxes=await items.evaluateAll(nodes=>nodes.map(node=>node.getBoundingClientRect().toJSON()));
|
||||
expect(Math.abs(boxes[1].x-boxes[0].x)).toBeLessThan(1);expect(boxes[1].y).toBeGreaterThanOrEqual(boxes[0].y+boxes[0].height);
|
||||
expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
|
||||
await panel.getByRole('button',{name:'Select negative Quality baseline',exact:true}).click();
|
||||
await expect(section.locator(':scope > p').first()).toHaveText('Baseline compared with Quality baseline. Choose another red dot to change the comparison.');
|
||||
await expect(section.locator(':scope > p.small').nth(1)).toHaveText('No added terms.');
|
||||
await compare.click();await expect(compare).toHaveAttribute('aria-pressed','false');await expect(section).toHaveCount(0);
|
||||
});
|
||||
test('compare waits for the page holding the oldest active negative',async({page})=>{
|
||||
const f=page.fixture;
|
||||
// A bounded first page that does not reach the oldest saved pair: the baseline is not guessed from the newest lanes (D46).
|
||||
await page.route(new RegExp(`/prompt_manager/library/${f.canonical_id}\\?`),async route=>{
|
||||
const url=new URL(route.request().url());if(url.searchParams.has('variant_cursor') || url.searchParams.get('archive')) return route.continue();
|
||||
const response=await route.fetch(),body=await response.json();
|
||||
await route.fulfill({response,json:{...body,variants:body.variants.slice(0,2),variants_has_more:true,variants_next_cursor:'held'}});
|
||||
});
|
||||
await open(page,f.canonical_id);const panel=variantsPanel(page);
|
||||
await panel.getByRole('button',{name:'Compare negatives',exact:true}).click();
|
||||
const section=page.locator('.library-history .timeline-panel + section.surface');
|
||||
await expect(section.locator(':scope > h2')).toHaveText('Compare negative variants');
|
||||
await expect(section.locator(':scope > p.small')).toHaveText('Load more negative variants to reach the baseline.');
|
||||
await expect(section.locator('.compare-grid')).toHaveCount(0);
|
||||
await page.unroute(new RegExp(`/prompt_manager/library/${f.canonical_id}\\?`));
|
||||
});
|
||||
test('run dot falls back to Repeat run when the lookup fails or Recorded run cannot open',async({page})=>{
|
||||
const f=page.fixture;
|
||||
await page.route(/\/history\/pairs\/\d+\/newest/,route=>route.fulfill({status:503,json:{success:false}}));
|
||||
await open(page,f.canonical_id);const panel=variantsPanel(page);
|
||||
const baseline=panel.getByRole('button',{name:'Show repeat run for Quality baseline',exact:true});
|
||||
await baseline.click();const repeat=modalDialog(page,'Repeat run');await expect(repeat).toBeVisible();
|
||||
await expect(page.locator('#workspace-toast')).toHaveText('Recorded runs could not be read. Showing the newest linked image.');
|
||||
await expect(dialog(page)).toHaveCount(0);
|
||||
await page.keyboard.press('Escape');await expect(repeat).toHaveCount(0);await expect(baseline).toBeFocused();
|
||||
await page.unroute(/\/history\/pairs\/\d+\/newest/);
|
||||
// The lookup names a run, but the recorded history never loaded, so Recorded run cannot open: Repeat run, no toast.
|
||||
await page.route(/\/library\/\d+\/history\?/,route=>route.fulfill({status:503,json:{success:false}}));
|
||||
await page.reload();await expect(recorded(page).getByRole('button',{name:'Retry recorded history',exact:true})).toBeVisible();
|
||||
await page.locator('#workspace-toast').evaluate(node=>{node.textContent='';});
|
||||
const newest=page.waitForResponse(response=>/\/history\/pairs\/\d+\/newest/.test(response.url()));
|
||||
await variantsPanel(page).getByRole('button',{name:'Show repeat run for Quality baseline',exact:true}).click();
|
||||
expect((await(await newest).json()).run.run_id).toBe(f.canonical_run);
|
||||
await expect(modalDialog(page,'Repeat run')).toBeVisible();await expect(dialog(page)).toHaveCount(0);
|
||||
await expect(page.locator('#workspace-toast')).toHaveText('');
|
||||
});
|
||||
test('an archived pair\'s Repeat run reads the archived gallery',async({page,request})=>{
|
||||
const f=page.fixture;
|
||||
await expect(await request.patch(`/prompt_manager/pairs/${f.clean_pair}`,{data:{archived:true}})).toBeOK();
|
||||
await open(page,f.canonical_id);const panel=variantsPanel(page);
|
||||
await panel.getByRole('button',{name:'Show archived (1)',exact:true}).click();
|
||||
const reads=[];page.on('request',request=>{const url=new URL(request.url());if(url.pathname===`/prompt_manager/library/${f.canonical_id}/images`)reads.push(url.searchParams);});
|
||||
await panel.getByRole('button',{name:'Show repeat run for Clean geometry',exact:true}).click();
|
||||
const repeat=modalDialog(page,'Repeat run');await expect(repeat.locator('.spaced-15 article[data-image-key]')).toHaveCount(1);
|
||||
expect(reads.at(-1).get('pair_id')).toBe(String(f.clean_pair));expect(reads.at(-1).get('archive')).toBe('archived');
|
||||
// Each open replaces the previous dialog and its gallery.
|
||||
await page.keyboard.press('Escape');await panel.getByRole('button',{name:'All images for this positive',exact:true}).click();
|
||||
await expect(modalDialog(page,'All images for this positive')).toBeVisible();await expect(page.locator('dialog[open]')).toHaveCount(1);
|
||||
});
|
||||
test('Compare negatives is disabled without an active variant',async({page,request})=>{
|
||||
const f=page.fixture,detail=await(await request.get(`/prompt_manager/library/${f.canonical_id}`)).json();
|
||||
for(const variant of detail.variants) await expect(await request.patch(`/prompt_manager/pairs/${variant.id}`,{data:{archived:true}})).toBeOK();
|
||||
await open(page,f.canonical_id);const compare=variantsPanel(page).getByRole('button',{name:'Compare negatives',exact:true});
|
||||
await expect(compare).toBeDisabled();await expect(compare).toHaveAttribute('aria-pressed','false');
|
||||
await expect(page.locator('.library-history .timeline-panel + section.surface')).toHaveCount(0);
|
||||
});
|
||||
test('dot names never repeat for trimmed titles or titles that read like a fallback',async({page,request})=>{
|
||||
const f=page.fixture,detail=await(await request.get(`/prompt_manager/library/${f.canonical_id}`)).json();
|
||||
const byTitle=Object.fromEntries(detail.variants.map(variant=>[variant.title,variant.id]));
|
||||
await expect(await request.patch(`/prompt_manager/pairs/${byTitle['No negative']}`,{data:{title:'variant 2'}})).toBeOK();
|
||||
await expect(await request.patch(`/prompt_manager/pairs/${byTitle['Quality baseline']}`,{data:{title:' Clean geometry '}})).toBeOK();
|
||||
await open(page,f.canonical_id);
|
||||
const names=await variantsPanel(page).locator('.variant-dot').evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')));
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
expect(names).toEqual(['Select negative variant 1','Select negative variant 2','Select negative variant 3']);
|
||||
});
|
||||
// #249 F4: the concept's shared gallery (app.js a616-621, style.css 1546-1548) of PR C media cards (C3 activation).
|
||||
test('shared gallery uses concept cards, image opens the viewer, empty copy',async({page,request})=>{
|
||||
const f=page.fixture;await open(page,f.canonical_id);
|
||||
const section=gallery(page),heading=section.locator(':scope > .panel-heading');
|
||||
expect(await section.evaluate(node=>`${node.tagName.toLowerCase()}.${node.className}`)).toBe('section.history-gallery');
|
||||
await expect(heading.locator(':scope > h2')).toHaveText('Shared gallery');
|
||||
// The page opens on the newest lane, a saved variant with no runs: the concept's empty copy.
|
||||
await expect(heading.locator(':scope > span.small')).toHaveText('No negative · same positive prompt');
|
||||
await expect(section.locator('.empty')).toHaveText('Saved variant · no runs yet.');await expect(section.locator('.empty')).toBeVisible();
|
||||
await expect(section.locator('article[data-image-key]')).toHaveCount(0);
|
||||
// D47 (maintainer ruling 2026-10-21): the concept's "Shared gallery" heading is the gallery's only heading, so the
|
||||
// collection's "Linked images" heading and its "N loaded · N linked images" count are gone; the layout row and the
|
||||
// selected-image side panel stay.
|
||||
await expect(section.locator('.section-caption')).toHaveCount(0);
|
||||
await expect(section.getByRole('heading',{name:'Linked images',exact:true})).toHaveCount(0);
|
||||
await expect(section.getByText(/ loaded · /)).toHaveCount(0);
|
||||
// Outside the side panel (whose own heading names the selected image), the gallery has one section heading.
|
||||
expect(await section.evaluate(node=>[...node.querySelectorAll('h1, h2')].filter(item=>!item.closest('aside')).map(item=>item.textContent))).toEqual(['Shared gallery']);
|
||||
await expect(section.getByRole('group',{name:'Image layout',exact:true})).toBeVisible();
|
||||
await expect(section.getByRole('combobox',{name:'Thumbnail size',exact:true})).toBeVisible();
|
||||
await expect(section.getByRole('complementary',{name:'Selected image details',exact:true})).toBeVisible();
|
||||
// As in the concept, the gallery follows the variants panel; recorded executions (#209) stay below it.
|
||||
const order=await page.locator('.library-history').evaluate(node=>[...node.querySelectorAll('section[aria-label]')].map(item=>item.getAttribute('aria-label')).filter(name=>['Saved negative variants','Shared gallery','Recorded executions'].includes(name)));
|
||||
expect(order).toEqual(['Saved negative variants','Shared gallery','Recorded executions']);
|
||||
const dot=variantsPanel(page).getByRole('button',{name:'Select negative Quality baseline',exact:true});await dot.click();await expect(dot).toHaveAttribute('aria-pressed','true');
|
||||
await expect(heading.locator(':scope > span.small')).toHaveText('Quality baseline · same positive prompt');
|
||||
const cards=section.locator('article.media-card[data-image-key]');await expect(cards).toHaveCount(2);
|
||||
await expect(section.locator('.empty')).toBeHidden();
|
||||
const details=await(await request.get(`/prompt_manager/library/${f.canonical_id}`)).json();
|
||||
const pair=details.variants.find(variant=>variant.title==='Quality baseline').id;
|
||||
const listing=await(await request.get(`/prompt_manager/library/${f.canonical_id}/images?pair_id=${pair}`)).json();
|
||||
const first=cards.first(),filename=(await first.locator('.image-open').getAttribute('aria-label')).replace(/^Open /,'');
|
||||
const image=listing.images.find(item=>item.filename===filename);expect(typeof image.favorite).toBe('boolean');
|
||||
// The concept card: art (open button + overlays), then the info row; the star where the read returns favorite and
|
||||
// no checkbox (the selection bar stays Images-only, #259).
|
||||
expect(await first.evaluate(node=>[...node.children].map(child=>child.className))).toEqual(['media-art','media-info']);
|
||||
expect(await first.locator(':scope > .media-art').evaluate(node=>[...node.children].map(child=>`${child.tagName.toLowerCase()}.${child.className}`))).toEqual(['button.image-open','div.image-overlays']);
|
||||
await expect(first.locator('.image-overlays input[type="checkbox"]')).toHaveCount(0);
|
||||
const star=first.locator('.image-overlays > button.star-button');
|
||||
await expect(star).toHaveAccessibleName(`Favorite ${filename}`);await expect(star).toHaveAttribute('aria-pressed',String(image.favorite));
|
||||
await expect(star).toHaveText(image.favorite?'★':'☆');
|
||||
await expect(first.locator('.media-info h3')).toHaveCount(1);
|
||||
await expect(first.locator('.image-card-actions > button.icon-btn:first-child')).toHaveAccessibleName(`Inspect ${filename}`);
|
||||
// C3 activation: the image opens the viewer and Escape returns focus to it.
|
||||
const openButton=first.locator('.image-open');await openButton.click();
|
||||
const viewer=page.locator('dialog#viewer-dialog');await expect(viewer).toBeVisible();
|
||||
await page.keyboard.press('Escape');await expect(viewer).toBeHidden();await expect(openButton).toBeFocused();
|
||||
await expect(section.locator(':scope > .panel-heading')).toHaveCSS('justify-content','space-between');
|
||||
await expect(section).toHaveCSS('margin-top','24px');
|
||||
// D47 (maintainer ruling 2026-10-21) covers the history page's dialogs too: the All images dialog is named by its
|
||||
// own title, with no "Linked images" heading or "N loaded · N linked images" count, as the concept's (a1282-1290).
|
||||
await variantsPanel(page).getByRole('button',{name:'All images for this positive',exact:true}).click();
|
||||
const all=page.getByRole('dialog',{name:'All images for this positive',exact:true});
|
||||
await expect(all.locator('article[data-image-key]').first()).toBeVisible();
|
||||
await expect(all.locator('.section-caption')).toHaveCount(0);
|
||||
await expect(all.getByRole('heading',{name:'Linked images',exact:true})).toHaveCount(0);
|
||||
await expect(all.getByText(/ loaded · /)).toHaveCount(0);
|
||||
expect(await all.evaluate(node=>[...node.querySelectorAll('h1, h2')].filter(item=>!item.closest('aside')).map(item=>item.textContent))).toEqual(['All images for this positive']);
|
||||
await page.keyboard.press('Escape');await expect(all).toHaveCount(0);
|
||||
// At 390px the heading stays inside the gallery and the page never scrolls sideways.
|
||||
await page.setViewportSize({width:390,height:844});
|
||||
await expect.poll(()=>page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
|
||||
const [box,text]=await Promise.all([section.boundingBox(),heading.locator(':scope > span.small').boundingBox()]);
|
||||
expect(text.x+text.width).toBeLessThanOrEqual(box.x+box.width+0.5);
|
||||
});
|
||||
|
||||
+1486
-127
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,687 @@
|
||||
// The native "Inspect a file" page (#249 local Inspect, PR L2 Task 6): one chosen or dropped local file previews
|
||||
// inline, and the heading's one action opens the shared viewer with the full inspector on a record read entirely in
|
||||
// this browser. The concept is docs/concepts/workspace/app.js renderMetadata (740–748) and its selection (1715–1729);
|
||||
// the production differences are D60–D66 in TESTING.md.
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
import { parityPng, localFixture, tinyWebm, sendablePng, corruptPng, disguised, undecodableMp4, POSITIVE_LINES } from './local-inspect-files.mjs';
|
||||
|
||||
const workspace = '/prompt_manager/workspace';
|
||||
const TOAST = 'Local preview only. No file uploaded; metadata is available above.';
|
||||
const ACTION = 'Open file metadata';
|
||||
const COPY = {
|
||||
intro: 'Read embedded metadata in this browser. The file is not uploaded or added to your library.',
|
||||
note: 'Your file stays in this browser. Its metadata opens with “Open file metadata”.',
|
||||
chooser: 'Choose an image or video for a local preview',
|
||||
multiple: 'Drop one file at a time.',
|
||||
directory: 'Folders cannot be inspected. Choose one image or video file.',
|
||||
unsupported: 'This file type cannot be previewed or inspected here. Choose an image or video file.',
|
||||
previewFailed: 'The preview could not be displayed. Metadata and the original remain available in the viewer.',
|
||||
};
|
||||
|
||||
test.use({ locale: 'en-US', timezoneId: 'UTC', reducedMotion: 'reduce' });
|
||||
|
||||
test.beforeEach(async ({ page, request, browserName }) => {
|
||||
await startCoverage(page, browserName);
|
||||
await expect(await request.post('/__test__/reset')).toBeOK();
|
||||
page.inspectErrors = [];
|
||||
page.on('pageerror', error => page.inspectErrors.push(error.message));
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page, browserName }) => {
|
||||
await page.unrouteAll({ behavior: 'ignoreErrors' });
|
||||
await saveCoverage(page, browserName);
|
||||
expect(page.inspectErrors).toEqual([]);
|
||||
});
|
||||
|
||||
const main = page => page.locator('#workspace-main');
|
||||
const action = page => main(page).locator('.heading-actions').getByRole('button', { name: ACTION, exact: true });
|
||||
const chooser = page => page.locator('#local-file');
|
||||
const preview = page => page.locator('#local-preview');
|
||||
const status = page => preview(page).locator('p.small');
|
||||
const viewer = page => page.locator('dialog#viewer-dialog');
|
||||
const rail = page => viewer(page).locator('#viewer-details');
|
||||
|
||||
async function openInspect(page) {
|
||||
await page.goto(`${workspace}#/inspect`);
|
||||
await expect(main(page).getByRole('heading', { level: 1, name: 'Inspect a file', exact: true })).toBeVisible();
|
||||
await expect(chooser(page)).toBeAttached();
|
||||
}
|
||||
|
||||
/** Selects through the real chooser, then waits for the page to classify the file. */
|
||||
async function choose(page, file) {
|
||||
await chooser(page).setInputFiles(file);
|
||||
}
|
||||
|
||||
async function openViewer(page) {
|
||||
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
|
||||
await action(page).click();
|
||||
await expect(viewer(page)).toBeVisible();
|
||||
await expect(rail(page).locator('.panel-heading')).toBeVisible();
|
||||
}
|
||||
|
||||
/** Dispatches dragover + drop on `selector` with Files built in the page (and optional string items). */
|
||||
function drop(page, selector, { files = [], strings = [] } = {}) {
|
||||
return page.evaluate(async ({ selector, files, strings }) => {
|
||||
const transfer = new DataTransfer();
|
||||
for (const file of files) transfer.items.add(new File([new Uint8Array(file.bytes)], file.name, { type: file.mimeType }));
|
||||
for (const [type, value] of strings) transfer.items.add(value, type);
|
||||
const target = document.querySelector(selector);
|
||||
const over = new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer: transfer });
|
||||
const dropped = new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: transfer });
|
||||
target.dispatchEvent(over);
|
||||
target.dispatchEvent(dropped);
|
||||
return { over: over.defaultPrevented, drop: dropped.defaultPrevented };
|
||||
}, { selector, files: files.map(file => ({ name: file.name, mimeType: file.mimeType, bytes: [...file.buffer] })), strings });
|
||||
}
|
||||
|
||||
/** The rail's comparable content: prompts, the six facts, the raw records and the two downloads (D66 aside). */
|
||||
const railContent = locator => locator.evaluate(node => {
|
||||
const raw = [...node.querySelectorAll('details')].find(item => item.querySelector('summary')?.textContent === 'Workflow & raw metadata');
|
||||
const facts = node.querySelector(':scope .meta-grid:not(details .meta-grid)');
|
||||
return {
|
||||
positive: node.querySelector('.prompt-section.positive p.prompt-text')?.textContent ?? null,
|
||||
negative: node.querySelector('.prompt-section.negative p.prompt-text')?.textContent ?? null,
|
||||
facts: facts ? [...facts.querySelectorAll(':scope > div')].map(item => [item.querySelector('dt').textContent, item.querySelector('dd').textContent]) : null,
|
||||
raw: raw ? [...raw.querySelectorAll('p.small, pre')].map(item => item.textContent) : null,
|
||||
downloads: raw ? [...raw.querySelectorAll('button')].map(item => [item.textContent, item.getAttribute('aria-disabled')]) : null,
|
||||
};
|
||||
});
|
||||
|
||||
/** Counts storage writes and object URLs created and revoked for Files (download blobs are not the session's). */
|
||||
async function instrument(page) {
|
||||
await page.addInitScript(() => {
|
||||
window.__inspect = { writes: [], created: [], revoked: [], files: new Set() };
|
||||
for (const name of ['setItem', 'removeItem', 'clear']) {
|
||||
const original = Storage.prototype[name];
|
||||
Storage.prototype[name] = function (...args) { window.__inspect.writes.push([name, args[0]]); return original.apply(this, args); };
|
||||
}
|
||||
const open = indexedDB.open.bind(indexedDB);
|
||||
indexedDB.open = (...args) => { window.__inspect.writes.push(['indexedDB.open', args[0]]); return open(...args); };
|
||||
const create = URL.createObjectURL, revoke = URL.revokeObjectURL;
|
||||
URL.createObjectURL = object => {
|
||||
const url = create(object);
|
||||
if (object instanceof File) { window.__inspect.created.push(url); window.__inspect.files.add(url); }
|
||||
return url;
|
||||
};
|
||||
URL.revokeObjectURL = url => { if (window.__inspect.files.has(url)) window.__inspect.revoked.push(url); return revoke(url); };
|
||||
});
|
||||
}
|
||||
const urls = page => page.evaluate(() => ({ created: [...window.__inspect.created], revoked: [...window.__inspect.revoked], writes: [...window.__inspect.writes] }));
|
||||
|
||||
test('chooser opens the shared full inspector without a second read', async ({ page, request }) => {
|
||||
// The Images viewer's rail for the same bytes, served from the output folder.
|
||||
await expect(await request.post('/__test__/add-local-inspect-file', { data: { name: 'f02.png' } })).toBeOK();
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await page.locator('article[data-image-key="0:local-inspect/f02.png"] .image-open').click();
|
||||
await expect(rail(page).locator('.prompt-section.positive p.prompt-text')).toBeVisible();
|
||||
const served = await railContent(rail(page));
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(viewer(page)).toHaveCount(0);
|
||||
|
||||
await page.locator('aside.sidebar nav').getByRole('link', { name: 'Inspect a file', exact: true }).click();
|
||||
await expect(main(page).getByRole('heading', { level: 1, name: 'Inspect a file', exact: true })).toBeVisible();
|
||||
await expect(main(page).locator('.heading p.muted')).toHaveText('A standalone file uses the same image viewer and prompt actions.');
|
||||
// The concept DOM: one surface, the drop area with its icon, label, input and note, then the preview slot.
|
||||
const surface = main(page).locator('section.surface');
|
||||
await expect(surface).toHaveCount(1);
|
||||
await expect(surface.locator('h2')).toHaveText('Inspect without importing');
|
||||
await expect(surface.locator(':scope > p')).toHaveText(COPY.intro);
|
||||
const area = surface.locator('div.file-drop.spaced-20');
|
||||
await expect(area.locator(':scope > svg')).toHaveCount(1);
|
||||
await expect(area.locator('label[for="local-file"]')).toHaveText(COPY.chooser);
|
||||
await expect(chooser(page)).toHaveAttribute('type', 'file');
|
||||
await expect(chooser(page)).toHaveAttribute('accept', 'image/png,image/jpeg,image/webp,video/*,.mp4,.mov,.m4v,.webm,.mkv,.avi,.wmv');
|
||||
await expect(chooser(page)).not.toHaveAttribute('multiple', /.*/);
|
||||
await expect(area.locator('span.small#local-file-note')).toHaveText(COPY.note);
|
||||
await expect(surface.locator(':scope > #local-preview')).toBeEmpty();
|
||||
// D60: the one heading action is described by the note until a file is chosen.
|
||||
await expect(main(page).locator('.heading-actions button')).toHaveCount(1);
|
||||
await expect(action(page)).toHaveClass(/\bca-primary\b/);
|
||||
await expect(action(page)).toHaveAttribute('aria-disabled', 'true');
|
||||
await expect(action(page)).toHaveAccessibleDescription(COPY.note);
|
||||
await action(page).dispatchEvent('click');
|
||||
await expect(viewer(page)).toHaveCount(0);
|
||||
|
||||
await choose(page, parityPng('f02.png'));
|
||||
const image = preview(page).locator('img.local-file-preview');
|
||||
await expect(image).toHaveAttribute('alt', 'Local file preview');
|
||||
await expect(image).toHaveJSProperty('complete', true);
|
||||
expect(await image.evaluate(node => node.naturalWidth)).toBe(64);
|
||||
expect(await image.getAttribute('src')).toMatch(/^blob:/);
|
||||
const box = await image.evaluate(node => ({ height: node.getBoundingClientRect().height, fit: getComputedStyle(node).objectFit, margin: getComputedStyle(node).marginTop }));
|
||||
expect(box.height).toBeLessThanOrEqual(400);
|
||||
expect(box).toMatchObject({ fit: 'contain', margin: '20px' });
|
||||
await expect(page.locator('#workspace-toast')).toHaveText(TOAST);
|
||||
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
|
||||
await expect(action(page)).not.toHaveAttribute('aria-describedby', /.*/);
|
||||
|
||||
await openViewer(page);
|
||||
await expect(viewer(page).locator('#viewer-title')).toHaveText('f02.png');
|
||||
const position = viewer(page).locator('.media-position');
|
||||
await expect(position).toHaveText('1 of 1 loaded: f02.png');
|
||||
for (const name of ['Previous media', 'Next media', 'Play slideshow']) {
|
||||
await expect(viewer(page).getByRole('button', { name, exact: true })).toHaveAttribute('aria-disabled', 'true');
|
||||
}
|
||||
await expect(viewer(page).locator('.viewer-canvas img')).toBeVisible();
|
||||
for (const name of ['Fit image', 'Rotate image 90 degrees', 'Flip image horizontally']) {
|
||||
await expect(viewer(page).getByRole('button', { name, exact: true })).toHaveAttribute('aria-disabled', 'false');
|
||||
}
|
||||
const canvas = viewer(page).locator('.viewer-canvas img');
|
||||
await viewer(page).getByRole('button', { name: 'Rotate image 90 degrees', exact: true }).click();
|
||||
await expect(canvas).toHaveAttribute('style', /rotate\(90deg\)/);
|
||||
await viewer(page).getByRole('button', { name: 'Flip image horizontally', exact: true }).click();
|
||||
await expect(canvas).toHaveAttribute('style', /scaleX\(-1\)/);
|
||||
await viewer(page).getByRole('button', { name: 'Fit image', exact: true }).click();
|
||||
await expect(canvas).not.toHaveAttribute('style', /rotate\(90deg\)/);
|
||||
|
||||
// The same rail as Images for the same bytes, except the D66 provenance.
|
||||
const local = await railContent(rail(page));
|
||||
expect(local).toEqual(served);
|
||||
expect(local.positive).toBe('a red fox in snow, detailed fur');
|
||||
expect(local.negative).toBe('blurry, lowres');
|
||||
expect(local.facts.map(([label]) => label)).toEqual(['Seed', 'Steps', 'CFG', 'Sampler', 'Scheduler', 'Model']);
|
||||
expect(local.downloads).toEqual([['Download metadata JSON', null], ['Download workflow JSON', null]]);
|
||||
const provenance = rail(page).locator('details').filter({ has: page.locator('summary', { hasText: 'File & provenance' }) });
|
||||
await expect(provenance).toContainText('Selected local file · read in this browser');
|
||||
await expect(provenance).toContainText('Local file; no saved library association was checked.');
|
||||
await expect(provenance.locator('dt', { hasText: /^Path$/ })).toHaveCount(0);
|
||||
await expect(rail(page).getByRole('button', { name: 'Show in folder', exact: true })).toHaveCount(0);
|
||||
await expect(rail(page).getByRole('button', { name: /Prompt history/ })).toHaveCount(0);
|
||||
await expect(rail(page).locator('.chip-row button.chip')).toHaveCount(0);
|
||||
const original = viewer(page).getByRole('link', { name: 'Download original', exact: true });
|
||||
await expect(original).toHaveAttribute('href', /^blob:/);
|
||||
await expect(original).toHaveAttribute('download', 'f02.png');
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(viewer(page)).toHaveCount(0);
|
||||
await expect(action(page)).toBeFocused();
|
||||
// Opening the viewer again shows the same record without reading the File a second time.
|
||||
await page.evaluate(() => {
|
||||
window.__slices = 0;
|
||||
const slice = Blob.prototype.slice;
|
||||
Blob.prototype.slice = function (...args) { if (this.name === 'f02.png') window.__slices++; return slice.apply(this, args); };
|
||||
});
|
||||
await openViewer(page);
|
||||
expect(await railContent(rail(page))).toEqual(served);
|
||||
expect(await page.evaluate(() => window.__slices)).toBe(0);
|
||||
});
|
||||
|
||||
test('Import → Inspect a local image lands here', async ({ page }) => {
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await page.getByRole('button', { name: 'Import', exact: true }).click();
|
||||
const dialog = page.getByRole('dialog', { name: 'Import & inspect', exact: true });
|
||||
await dialog.getByRole('button', { name: 'Open inspector', exact: true }).click();
|
||||
await expect(page).toHaveURL(/#\/inspect$/);
|
||||
await expect(main(page).getByRole('heading', { level: 1, name: 'Inspect a file', exact: true })).toBeVisible();
|
||||
await expect(chooser(page)).toBeAttached();
|
||||
await expect(main(page).locator('.handoff')).toHaveCount(0);
|
||||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('drop accepts exactly one File and never navigates', async ({ page }) => {
|
||||
await openInspect(page);
|
||||
// A drop that misses the area is cancelled, so the browser never opens the file in place of the page.
|
||||
expect(await drop(page, 'body', { files: [parityPng('f02.png')] })).toEqual({ over: true, drop: true });
|
||||
await expect(preview(page)).toBeEmpty();
|
||||
expect(await drop(page, '.file-drop', { files: [parityPng('f02.png')] })).toEqual({ over: true, drop: true });
|
||||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
|
||||
await expect(page.locator('#workspace-toast')).toHaveText(TOAST);
|
||||
// The chooser names the dropped file, never the one it replaced and never "No file chosen".
|
||||
expect(await chooser(page).evaluate(node => [...node.files].map(file => file.name))).toEqual(['f02.png']);
|
||||
const first = await preview(page).locator('img').getAttribute('src');
|
||||
// Two files, a link and markup keep the selection and say why.
|
||||
await drop(page, '.file-drop', { files: [parityPng('f02.png'), parityPng('f03.png')] });
|
||||
await expect(status(page)).toHaveText(COPY.multiple);
|
||||
await expect(status(page)).toHaveAttribute('role', 'alert');
|
||||
await expect(preview(page).locator('img')).toHaveAttribute('src', first);
|
||||
for (const strings of [[['text/uri-list', 'https://example.invalid/a.png']], [['text/html', '<img src="https://example.invalid/a.png">']]]) {
|
||||
await drop(page, '.file-drop', { strings });
|
||||
await expect(status(page)).toHaveText(COPY.unsupported);
|
||||
await expect(preview(page).locator('img')).toHaveAttribute('src', first);
|
||||
}
|
||||
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
|
||||
await openViewer(page);
|
||||
await expect(viewer(page).locator('#viewer-title')).toHaveText('f02.png');
|
||||
await expect(page).toHaveURL(/#\/inspect$/);
|
||||
});
|
||||
|
||||
test('cancel and same-file reselect', async ({ page }) => {
|
||||
await instrument(page);
|
||||
await openInspect(page);
|
||||
await choose(page, parityPng('f02.png'));
|
||||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
|
||||
const first = await preview(page).locator('img').getAttribute('src');
|
||||
// The chooser still names the chosen file, as in the concept; a cancel (no files) keeps the page as it is.
|
||||
expect(await chooser(page).evaluate(node => node.files[0]?.name)).toBe('f02.png');
|
||||
await chooser(page).setInputFiles([]);
|
||||
await expect(preview(page).locator('img')).toHaveAttribute('src', first);
|
||||
await expect(status(page)).toHaveCount(0);
|
||||
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
|
||||
// Opening the picker and cancelling it keeps naming the live selection (modern engines fire `cancel`, not `change`).
|
||||
await chooser(page).dispatchEvent('click');
|
||||
await chooser(page).dispatchEvent('cancel');
|
||||
expect(await chooser(page).evaluate(node => [...node.files].map(file => file.name))).toEqual(['f02.png']);
|
||||
await expect(preview(page).locator('img')).toHaveAttribute('src', first);
|
||||
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
|
||||
// The same file again is a new selection with its own URL; the old one is released. Opening the picker clears
|
||||
// the chooser, so the browser reports the same file as a change.
|
||||
// (Read in the same task: a picker the click opens is cancelled by the harness, which names the file again.)
|
||||
expect(await chooser(page).evaluate(node => { node.dispatchEvent(new MouseEvent('click')); return node.value; })).toBe('');
|
||||
await choose(page, parityPng('f02.png'));
|
||||
await expect(preview(page).locator('img')).not.toHaveAttribute('src', first);
|
||||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
|
||||
const state = await urls(page);
|
||||
expect(state.created).toHaveLength(2);
|
||||
expect(state.revoked).toEqual([first]);
|
||||
await openViewer(page);
|
||||
await expect(viewer(page).locator('.media-position')).toHaveText('1 of 1 loaded: f02.png');
|
||||
});
|
||||
|
||||
test('preview does not imply metadata support', async ({ page }) => {
|
||||
await instrument(page);
|
||||
await openInspect(page);
|
||||
// JPEG and WebP preview and report no supported metadata, exactly as Images reports them.
|
||||
for (const [file, format] of [[localFixture('tiny.jpg', 'image/jpeg'), 'JPEG'], [localFixture('tiny.webp', 'image/webp'), 'WEBP']]) {
|
||||
await choose(page, file);
|
||||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
|
||||
await openViewer(page);
|
||||
await expect(rail(page)).toContainText('No supported embedded metadata was recorded in this file.');
|
||||
await expect(rail(page).locator('details').first()).toContainText(format);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(viewer(page)).toHaveCount(0);
|
||||
}
|
||||
// A PNG whose text chunk fails its CRC: unreadable metadata, the original still downloads.
|
||||
await choose(page, corruptPng());
|
||||
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
|
||||
await openViewer(page);
|
||||
await expect(rail(page)).toContainText('Embedded metadata could not be read.');
|
||||
const [download] = await Promise.all([page.waitForEvent('download'), viewer(page).getByRole('link', { name: 'Download original', exact: true }).click()]);
|
||||
expect(download.suggestedFilename()).toBe('corrupt.png');
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(viewer(page)).toHaveCount(0);
|
||||
// Text, markup, SVG and a playlist under media names are unsupported and never get a URL.
|
||||
const before = (await urls(page)).created.length;
|
||||
for (const file of disguised()) {
|
||||
await choose(page, file);
|
||||
await expect(status(page)).toHaveText(COPY.unsupported);
|
||||
await expect(status(page)).toHaveAttribute('role', 'alert');
|
||||
await expect(preview(page).locator('img, video')).toHaveCount(0);
|
||||
await expect(action(page)).toHaveAttribute('aria-disabled', 'true');
|
||||
}
|
||||
expect((await urls(page)).created).toHaveLength(before);
|
||||
});
|
||||
|
||||
test('local video plays inline and in the viewer with real tags', async ({ page, browserName }) => {
|
||||
await openInspect(page);
|
||||
await choose(page, localFixture('core-tags.webm', 'video/webm'));
|
||||
const video = preview(page).locator('video.local-file-preview');
|
||||
await expect(video).toBeVisible();
|
||||
expect(await video.evaluate(node => ({ controls: node.controls, playsInline: node.playsInline, preload: node.preload, muted: node.muted, autoplay: node.autoplay, paused: node.paused })))
|
||||
.toEqual({ controls: true, playsInline: true, preload: 'metadata', muted: true, autoplay: false, paused: true });
|
||||
// WebKit may not decode VP8: whichever state it reports, metadata is still read.
|
||||
await expect.poll(() => video.evaluate(node => node.readyState >= 1 || !!node.error)).toBe(true);
|
||||
await openViewer(page);
|
||||
await expect(rail(page).locator('.prompt-section.positive p.prompt-text')).toHaveText('a red fox in snow, detailed fur');
|
||||
await expect(rail(page).locator('.meta-grid').first().locator('dd').first()).toHaveText('7');
|
||||
await expect(viewer(page).locator('#viewer-stage video')).toHaveCount(1);
|
||||
await page.keyboard.press('Escape');
|
||||
await choose(page, tinyWebm());
|
||||
await expect(preview(page).locator('video.local-file-preview')).toBeVisible();
|
||||
await openViewer(page);
|
||||
await expect(rail(page)).toContainText('No supported embedded metadata was recorded in this file.');
|
||||
if (browserName !== 'webkit') await expect.poll(() => viewer(page).locator('#viewer-stage video').evaluate(node => node.readyState >= 1)).toBe(true);
|
||||
});
|
||||
|
||||
test('the toast and a rejected drop never contradict the status line', async ({ page }) => {
|
||||
// A file whose bytes are gone after choosing: the status says its metadata cannot be read, so no toast says it is
|
||||
// available.
|
||||
await page.addInitScript(() => {
|
||||
const slice = Blob.prototype.slice;
|
||||
Blob.prototype.slice = function (...args) {
|
||||
if (this.name === 'gone.png') return { arrayBuffer: () => Promise.reject(new DOMException('The file is gone', 'NotReadableError')) };
|
||||
return slice.apply(this, args);
|
||||
};
|
||||
});
|
||||
await openInspect(page);
|
||||
await choose(page, parityPng('f02.png', 'gone.png'));
|
||||
await expect(status(page)).toHaveText('Embedded metadata could not be read.');
|
||||
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
|
||||
await expect(page.locator('#workspace-toast')).toHaveText('');
|
||||
// A rejected drop is its own alert beside the selection's status, which it never replaces, and it ends with the
|
||||
// concept's toast interval.
|
||||
await choose(page, undecodableMp4());
|
||||
const failed = status(page).filter({ hasText: COPY.previewFailed });
|
||||
await expect(failed).toHaveCount(1);
|
||||
await drop(page, '.file-drop', { files: [parityPng('f02.png'), parityPng('f03.png')] });
|
||||
const rejected = status(page).filter({ hasText: COPY.multiple });
|
||||
await expect(rejected).toHaveAttribute('role', 'alert');
|
||||
await expect(failed).toHaveCount(1);
|
||||
await expect(rejected).toHaveCount(0, { timeout: 15000 });
|
||||
await expect(status(page)).toHaveText([COPY.previewFailed]);
|
||||
// The next selection attempt ends a rejection at once.
|
||||
await drop(page, '.file-drop', { files: [parityPng('f02.png'), parityPng('f03.png')] });
|
||||
await expect(rejected).toHaveCount(1);
|
||||
await chooser(page).dispatchEvent('click');
|
||||
await chooser(page).dispatchEvent('cancel');
|
||||
await expect(status(page)).toHaveText([COPY.previewFailed]);
|
||||
});
|
||||
|
||||
test('undecodable video keeps container metadata', async ({ page }) => {
|
||||
await openInspect(page);
|
||||
// A real MP4 whose sample entry names a codec no engine decodes; its container tags are intact.
|
||||
await choose(page, undecodableMp4());
|
||||
await expect(status(page)).toHaveText(COPY.previewFailed);
|
||||
await expect(status(page)).toHaveAttribute('role', 'alert');
|
||||
await expect(preview(page).locator('video')).toHaveCount(0);
|
||||
await openViewer(page);
|
||||
await expect(rail(page).locator('.prompt-section.positive p.prompt-text')).toHaveText('a red fox in snow, detailed fur');
|
||||
await expect(rail(page).locator('.meta-grid').first().locator('dd').first()).toHaveText('7');
|
||||
});
|
||||
|
||||
/** Records, for each revoked URL, how many elements (inline or in the viewer) still held it, and counts resets. */
|
||||
const trackReleases = page => page.evaluate(() => {
|
||||
window.__released = [];
|
||||
const revoke = URL.revokeObjectURL;
|
||||
URL.revokeObjectURL = url => {
|
||||
const live = [...document.querySelectorAll('img, video')].filter(node => node.getAttribute('src') === url);
|
||||
window.__released.push({ url, live: live.length });
|
||||
return revoke(url);
|
||||
};
|
||||
const load = HTMLMediaElement.prototype.load;
|
||||
window.__loads = 0;
|
||||
HTMLMediaElement.prototype.load = function () { if (!this.hasAttribute('src')) window.__loads++; return load.call(this); };
|
||||
});
|
||||
|
||||
test('URLs and media end with their owning session', async ({ page }) => {
|
||||
await instrument(page);
|
||||
await openInspect(page);
|
||||
// Every release pauses the media and drops its src before the URL is revoked.
|
||||
await trackReleases(page);
|
||||
await choose(page, localFixture('core-tags.webm', 'video/webm'));
|
||||
await expect(preview(page).locator('video')).toBeVisible();
|
||||
// A settled video (metadata read, or failed) is reset with load() when it is released.
|
||||
await expect.poll(() => preview(page).locator('video').evaluate(node => node.readyState >= 1 || !!node.error)).toBe(true);
|
||||
// Replacement.
|
||||
await choose(page, parityPng('f02.png'));
|
||||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
|
||||
let state = await urls(page);
|
||||
expect(state.revoked).toEqual([state.created[0]]);
|
||||
expect(await page.evaluate(() => window.__loads)).toBeGreaterThanOrEqual(1);
|
||||
// Route change.
|
||||
await page.locator('aside.sidebar nav').getByRole('link', { name: 'Overview', exact: true }).click();
|
||||
await expect(main(page).getByRole('heading', { level: 1, name: 'Overview', exact: true })).toBeVisible();
|
||||
state = await urls(page);
|
||||
expect(state.revoked).toEqual(state.created);
|
||||
// Five mount/unmount cycles with a selection and an opened viewer leave no live URL and no dialog.
|
||||
for (let cycle = 0; cycle < 5; cycle++) {
|
||||
await page.locator('aside.sidebar nav').getByRole('link', { name: 'Inspect a file', exact: true }).click();
|
||||
await choose(page, parityPng('f02.png', `cycle-${cycle}.png`));
|
||||
await openViewer(page);
|
||||
// The modal viewer makes the sidebar inert, so the route changes underneath it.
|
||||
await page.evaluate(() => { location.hash = '#/overview'; });
|
||||
await expect(main(page).getByRole('heading', { level: 1, name: 'Overview', exact: true })).toBeVisible();
|
||||
}
|
||||
state = await urls(page);
|
||||
expect(state.created).toHaveLength(7);
|
||||
expect([...state.revoked].sort()).toEqual([...state.created].sort());
|
||||
expect(new Set(state.revoked).size).toBe(state.revoked.length);
|
||||
expect((await page.evaluate(() => window.__released)).every(entry => entry.live === 0)).toBe(true);
|
||||
await expect(page.locator('dialog')).toHaveCount(0);
|
||||
// Page leave releases the selection once.
|
||||
await page.locator('aside.sidebar nav').getByRole('link', { name: 'Inspect a file', exact: true }).click();
|
||||
await choose(page, parityPng('f02.png', 'leave.png'));
|
||||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
|
||||
await page.evaluate(() => window.dispatchEvent(new PageTransitionEvent('pagehide', { persisted: false })));
|
||||
state = await urls(page);
|
||||
expect(state.created).toHaveLength(8);
|
||||
expect(state.revoked.filter(url => url === state.created[7])).toHaveLength(1);
|
||||
// Page leave with the viewer open, on a page loaded at Inspect (so its listeners precede the shell's): the viewer
|
||||
// closes and releases its media before the URL is revoked, and a page restored from the back-forward cache shows
|
||||
// an empty chooser beside the empty selection.
|
||||
await page.reload();
|
||||
await expect(chooser(page)).toBeAttached();
|
||||
await trackReleases(page);
|
||||
await choose(page, parityPng('f02.png', 'leave-open.png'));
|
||||
await openViewer(page);
|
||||
await expect(viewer(page).getByRole('button', { name: 'Zoom in', exact: true })).toHaveAttribute('aria-disabled', 'false');
|
||||
await page.evaluate(() => window.dispatchEvent(new PageTransitionEvent('pagehide', { persisted: true })));
|
||||
await expect(page.locator('dialog')).toHaveCount(0);
|
||||
state = await urls(page);
|
||||
expect(state.created).toHaveLength(1);
|
||||
expect(state.revoked).toEqual(state.created);
|
||||
expect(await page.evaluate(() => window.__released)).toEqual([{ url: state.created[0], live: 0 }]);
|
||||
await page.evaluate(() => window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true })));
|
||||
await expect(chooser(page)).toBeAttached();
|
||||
expect(await chooser(page).evaluate(node => ({ value: node.value, files: node.files.length }))).toEqual({ value: '', files: 0 });
|
||||
await expect(preview(page).locator('img, video')).toHaveCount(0);
|
||||
await expect(action(page)).toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
|
||||
test('replacing a video that just started loading never blocks the page', async ({ context, browserName }) => {
|
||||
test.setTimeout(120000);
|
||||
// WebKit (GStreamer) blocked the page when a video whose pipeline was still starting was reset with load(): the
|
||||
// next chooser selection, or the steps after it, never completed. Each fresh page replaces its first video as
|
||||
// soon as it shows.
|
||||
for (let attempt = 0; attempt < 15; attempt++) {
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
if (attempt === 0) await startCoverage(page, browserName);
|
||||
await openInspect(page);
|
||||
await choose(page, localFixture('core-tags.webm', 'video/webm'));
|
||||
await expect(preview(page).locator('video.local-file-preview')).toBeVisible();
|
||||
await chooser(page).setInputFiles(parityPng('f02.png'), { timeout: 8000 });
|
||||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
|
||||
await expect(preview(page).locator('video')).toHaveCount(0);
|
||||
if (attempt === 0) await saveCoverage(page, browserName);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('a video replaced while still starting is reset once it settles', async ({ context, browserName }) => {
|
||||
// A WebKit (GStreamer) video released exactly at its `loadstart` sometimes blocks the page, with or without any
|
||||
// reset and also with the code before the deferred reset (2 of 24 runs); tracked in TESTING.md (D62). WebKit's
|
||||
// no-hang guard is `replacing a video that just started loading never blocks the page`.
|
||||
test.skip(browserName === 'webkit', 'WebKit release at loadstart is a tracked follow-up (D62)');
|
||||
test.setTimeout(120000);
|
||||
// The video is replaced from its own `loadstart` (readyState HAVE_NOTHING). It leaves the page with no src before
|
||||
// its URL is revoked, and is reset (load() with no src, which frees its decoder) only once it reports metadata or
|
||||
// an error, so the page never blocks. Fresh pages share one browser process.
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
if (attempt === 0) await startCoverage(page, browserName);
|
||||
await page.addInitScript(() => {
|
||||
const load = HTMLMediaElement.prototype.load;
|
||||
HTMLMediaElement.prototype.load = function () { if (!this.hasAttribute('src')) this.__resets = (this.__resets ?? 0) + 1; return load.call(this); };
|
||||
const revoke = URL.revokeObjectURL;
|
||||
URL.revokeObjectURL = url => {
|
||||
const starting = window.__starting;
|
||||
if (url === starting?.url) starting.revoked = { src: starting.node.hasAttribute('src'), connected: starting.node.isConnected, resets: starting.node.__resets ?? 0 };
|
||||
return revoke(url);
|
||||
};
|
||||
});
|
||||
await openInspect(page);
|
||||
await page.evaluate(() => {
|
||||
const input = document.querySelector('#local-file'), slot = document.querySelector('#local-preview');
|
||||
new MutationObserver((records, observer) => {
|
||||
const video = slot.querySelector('video');
|
||||
if (!video) return;
|
||||
observer.disconnect();
|
||||
video.addEventListener('loadstart', () => {
|
||||
window.__starting = { node: video, url: video.getAttribute('src'), readyState: video.readyState };
|
||||
input.files = window.__pngFiles; input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}, { once: true });
|
||||
}).observe(slot, { childList: true });
|
||||
});
|
||||
const png = parityPng('f02.png');
|
||||
await page.evaluate(bytes => {
|
||||
const transfer = new DataTransfer();
|
||||
transfer.items.add(new File([new Uint8Array(bytes)], 'f02.png', { type: 'image/png' }));
|
||||
window.__pngFiles = transfer.files;
|
||||
}, [...png.buffer]);
|
||||
await choose(page, localFixture('core-tags.webm', 'video/webm'));
|
||||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible({ timeout: 8000 });
|
||||
await expect(preview(page).locator('video')).toHaveCount(0);
|
||||
// Released while starting, and never reset synchronously: the URL went once the node had no src.
|
||||
expect(await page.evaluate(() => ({ readyState: window.__starting.readyState, revoked: window.__starting.revoked })))
|
||||
.toEqual({ readyState: 0, revoked: { src: false, connected: false, resets: 0 } });
|
||||
await expect.poll(() => page.evaluate(() => window.__starting.node.__resets ?? 0), { timeout: 15000 }).toBe(1);
|
||||
// The page still answers.
|
||||
await chooser(page).setInputFiles(parityPng('f02.png', 'after.png'), { timeout: 8000 });
|
||||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible({ timeout: 8000 });
|
||||
if (attempt === 0) await saveCoverage(page, browserName);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('closing the viewer on a video that just started never blocks the page', async ({ context, browserName }) => {
|
||||
test.setTimeout(180000);
|
||||
// The viewer's video teardown resets its element; on WebKit a reset of a still-starting pipeline blocked the page.
|
||||
for (let attempt = 0; attempt < 15; attempt++) {
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
if (attempt === 0) await startCoverage(page, browserName);
|
||||
await openInspect(page);
|
||||
await choose(page, localFixture('core-tags.webm', 'video/webm'));
|
||||
await openViewer(page);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(viewer(page)).toHaveCount(0, { timeout: 8000 });
|
||||
await chooser(page).setInputFiles(parityPng('f02.png'), { timeout: 8000 });
|
||||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible({ timeout: 8000 });
|
||||
if (attempt === 0) await saveCoverage(page, browserName);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('replacing a file mid-read never shows or sends the old record', async ({ page, context }) => {
|
||||
const host = await bridgeHost(context);
|
||||
try {
|
||||
await openInspect(page);
|
||||
// Hold every read of held.png after its classification prefix.
|
||||
await page.evaluate(() => {
|
||||
let release;
|
||||
window.__gate = new Promise(resolve => { release = resolve; });
|
||||
window.__releaseGate = release;
|
||||
window.__held = 0;
|
||||
const slice = Blob.prototype.slice;
|
||||
Blob.prototype.slice = function (...args) {
|
||||
const part = slice.apply(this, args);
|
||||
if (this.name !== 'held.png' || (args[0] ?? 0) === 0 && (args[1] ?? this.size) <= 32) return part;
|
||||
const read = part.arrayBuffer.bind(part);
|
||||
part.arrayBuffer = async () => { window.__held++; await window.__gate; return read(); };
|
||||
return part;
|
||||
};
|
||||
});
|
||||
await choose(page, parityPng('f03.png', 'held.png'));
|
||||
await expect.poll(() => page.evaluate(() => window.__held)).toBeGreaterThan(0);
|
||||
await choose(page, sendablePng('fresh.png'));
|
||||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
|
||||
await page.evaluate(() => window.__releaseGate());
|
||||
await openViewer(page);
|
||||
await expect(viewer(page).locator('#viewer-title')).toHaveText('fresh.png');
|
||||
await expect(rail(page).locator('.prompt-section.positive p.prompt-text')).toHaveText(POSITIVE_LINES);
|
||||
await sendPair(page);
|
||||
expect(await host.evaluate(() => window.inspectApplied)).toEqual([{ positive: POSITIVE_LINES, negative: '' }]);
|
||||
} finally {
|
||||
await host.evaluate(() => window.inspectHost.dispose());
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
/** A second tab acting as a ComfyUI host with one Prompt Manager pair destination. */
|
||||
async function bridgeHost(context) {
|
||||
const host = await context.newPage();
|
||||
await host.goto(`${workspace}#/overview`);
|
||||
await host.evaluate(async () => {
|
||||
const { createBridgeHost } = await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');
|
||||
const target = { id: 'pair-1', label: 'Inspect destination', type: 'PromptManagerPair', roles: ['positive', 'negative'] };
|
||||
window.inspectApplied = [];
|
||||
window.inspectPreviews = [];
|
||||
window.inspectHost = createBridgeHost({ origin: location.origin, namespace: '/', label: 'Inspect ComfyUI', adapter: {
|
||||
listTargets: () => [target],
|
||||
preview: (_id, roles) => {
|
||||
window.inspectPreviews.push(roles);
|
||||
return { 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.inspectApplied.push(roles); return { targetId: target.id, values: roles }; },
|
||||
} });
|
||||
});
|
||||
return host;
|
||||
}
|
||||
|
||||
async function sendPair(page) {
|
||||
await rail(page).getByRole('button', { name: /^Send prompt pair to ComfyUI: / }).click();
|
||||
const sender = page.getByRole('dialog', { name: 'Send to ComfyUI', exact: true });
|
||||
await sender.getByRole('combobox', { name: 'ComfyUI window', exact: true }).selectOption({ label: 'Inspect ComfyUI' });
|
||||
await sender.getByRole('combobox', { name: 'Destination node', exact: true }).selectOption({ label: 'Inspect destination' });
|
||||
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.');
|
||||
await sender.getByRole('button', { name: 'Close', exact: true }).click();
|
||||
}
|
||||
|
||||
test('local roles send exactly', async ({ page, context }) => {
|
||||
const host = await bridgeHost(context);
|
||||
try {
|
||||
await openInspect(page);
|
||||
await choose(page, sendablePng());
|
||||
await openViewer(page);
|
||||
await expect(rail(page).locator('.prompt-section.positive p.prompt-text')).toHaveText(POSITIVE_LINES);
|
||||
await expect(rail(page).locator('.prompt-section.negative p.prompt-text')).toHaveText('No negative prompt (explicitly empty)');
|
||||
await sendPair(page);
|
||||
const applied = await host.evaluate(() => window.inspectApplied);
|
||||
expect(applied).toEqual([{ positive: POSITIVE_LINES, negative: '' }]);
|
||||
const payload = JSON.stringify([applied, await host.evaluate(() => window.inspectPreviews)]);
|
||||
expect(payload).not.toContain('blob:');
|
||||
expect(payload).not.toContain('send-me');
|
||||
// The viewer is still open, focus came back to the send action.
|
||||
await expect(viewer(page)).toBeVisible();
|
||||
} finally {
|
||||
await host.evaluate(() => window.inspectHost.dispose());
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('local inspection makes no content request or storage write', async ({ page }) => {
|
||||
await instrument(page);
|
||||
await openInspect(page);
|
||||
// The shell's own reads (counts, Activity) settle before the baseline.
|
||||
await expect(page.locator('aside.sidebar nav [data-route="images"] .nav-count')).toBeAttached();
|
||||
const baseline = (await urls(page)).writes.length;
|
||||
const requests = [];
|
||||
page.on('request', request => requests.push({ url: request.url(), method: request.method(), body: request.postData() }));
|
||||
page.on('websocket', socket => requests.push({ url: socket.url(), method: 'WS', body: null }));
|
||||
for (const file of [sendablePng('private-name.png'), localFixture('core-tags.webm', 'video/webm', 'private-clip.webm')]) {
|
||||
await choose(page, file);
|
||||
await openViewer(page);
|
||||
await rail(page).locator('summary', { hasText: 'Workflow & raw metadata' }).click();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(viewer(page)).toHaveCount(0);
|
||||
}
|
||||
const network = requests.filter(entry => !entry.url.startsWith('blob:') && !entry.url.startsWith('data:'));
|
||||
expect(network.filter(entry => /private|lighthouse|fox/.test(`${entry.url} ${entry.body ?? ''}`))).toEqual([]);
|
||||
expect(network.filter(entry => entry.method !== 'GET')).toEqual([]);
|
||||
expect(network.filter(entry => !/\/prompt_manager\/(activity|images\/output|library|vocabulary)/.test(entry.url))).toEqual([]);
|
||||
expect((await urls(page)).writes.slice(baseline)).toEqual([]);
|
||||
});
|
||||
|
||||
test('touch layout at 390 has no horizontal overflow at 200% zoom', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openInspect(page);
|
||||
await choose(page, parityPng('f02.png'));
|
||||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
|
||||
for (const zoom of ['1', '2']) {
|
||||
await page.evaluate(value => { document.documentElement.style.zoom = value; }, zoom);
|
||||
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
const area = await page.locator('.file-drop').evaluate(node => node.getBoundingClientRect().right <= document.documentElement.clientWidth + 0.5);
|
||||
expect(area).toBe(true);
|
||||
}
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
import {inventoryOf,mapConcept,diffInventory} from './library-editor-canonical-comparison.mjs';
|
||||
|
||||
@@ -438,7 +438,7 @@ const variantsPanel=page=>page.getByRole('region',{name:'Saved negative variants
|
||||
const savedTexts=page=>historyRegion(page).getByRole('region',{name:'Saved prompt texts',exact:true});
|
||||
const toast=page=>page.locator('#workspace-toast');
|
||||
const visibleNames=locator=>locator.evaluateAll(nodes=>nodes.map(node=>node.textContent.trim()));
|
||||
const pressedVariant=page=>variantsPanel(page).locator('.library-variant-dot[aria-pressed="true"]');
|
||||
const pressedVariant=page=>variantsPanel(page).locator('.variant-dot[aria-pressed="true"]');
|
||||
async function openHistory(page,id) {
|
||||
await page.goto(`${workspace}#/library/${id}`);await expect(variantsPanel(page)).toBeVisible();
|
||||
}
|
||||
@@ -450,14 +450,14 @@ async function leaveAndReturn(page,id) {
|
||||
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
|
||||
await page.evaluate(id=>{location.hash=`#/library/${id}`;},id);await expect(variantsPanel(page)).toBeVisible();await expect(pressedVariant(page)).toHaveCount(1);
|
||||
}
|
||||
const laneLabel=(page,text)=>variantsPanel(page).locator('.library-variant-lane').filter({hasText:text});
|
||||
const laneLabel=(page,text)=>variantsPanel(page).locator('.variant-lane').filter({hasText:text});
|
||||
|
||||
test('library heading offers Export then + New prompt, and New opens the saved prompt history',async({page,request})=>{
|
||||
await page.goto(`${workspace}#/library`);await expect(page.locator('article[data-prompt-id]').first()).toBeVisible();
|
||||
const actions=headingActions(page).locator('button');
|
||||
expect(await visibleNames(actions)).toEqual(['Export','+ New prompt']);
|
||||
// One linked image is counted in the singular.
|
||||
const f=page.fixture;await expect(page.locator(`article[data-prompt-id="${f.positive_id}"]`).getByText('1 linked image',{exact:true})).toHaveCount(1);
|
||||
// One image is counted in the singular.
|
||||
const f=page.fixture;await expect(page.locator(`article[data-prompt-id="${f.positive_id}"]`).getByText('1 image',{exact:true})).toHaveCount(1);
|
||||
await expect(actions.nth(0)).toHaveAccessibleName('Export full Library JSON');await expect(actions.nth(0)).not.toHaveClass(/ca-primary/);
|
||||
await expect(actions.nth(1)).toHaveClass(/\bca-primary\b/);
|
||||
for(const index of [0,1])expect((await actions.nth(index).boundingBox()).height).toBeGreaterThanOrEqual(32);
|
||||
@@ -472,14 +472,15 @@ test('library heading offers Export then + New prompt, and New opens the saved p
|
||||
// Exactly one saved negative is counted in the singular, in history and on the shelf card.
|
||||
await expect(variantsPanel(page).getByText('1 of 1 saved negative variant',{exact:true})).toHaveCount(1);
|
||||
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
|
||||
await expect(page.locator(`article[data-prompt-id="${created.id}"]`).getByText('1 saved negative variant',{exact:true})).toHaveCount(1);
|
||||
await expect(page.locator(`article[data-prompt-id="${created.id}"]`).getByText('− 1 negative variant',{exact:true})).toHaveCount(1);
|
||||
expect(page.writes.map(write=>`${write.method} ${write.url}`)).toEqual(['POST /prompt_manager/library']);
|
||||
});
|
||||
|
||||
test('new from Images cancel preserves browse',async({page})=>{
|
||||
await page.goto(`${workspace}#/images`);await expect(page.locator('#workspace-main h1')).toHaveText('Images');
|
||||
const actions=headingActions(page).locator('button');expect(await visibleNames(actions)).toEqual(['New prompt']);
|
||||
const trigger=actions.first();await expect(trigger).toHaveClass(/\bca-primary\b/);await expect(trigger.locator('svg')).toHaveCount(1);
|
||||
// #249 C2: the concept's Images heading puts Import before New prompt.
|
||||
const actions=headingActions(page).locator('button');expect(await visibleNames(actions)).toEqual(['Import','New prompt']);
|
||||
const trigger=actions.last();await expect(trigger).toHaveClass(/\bca-primary\b/);await expect(trigger.locator('svg')).toHaveCount(1);
|
||||
await trigger.click();let dialog=editorDialog(page);await expect(dialog.getByRole('heading',{name:'New prompt',exact:true})).toBeVisible();await ready(dialog);
|
||||
await field(dialog,'Title').fill('Discarded');await button(dialog,'Cancel').click();await expect(dialog).toHaveCount(0);
|
||||
await expect(trigger).toBeFocused();await expect(page).toHaveURL(/#\/images$/);await expect(page.locator('.library-shelf, .library-history')).toHaveCount(0);
|
||||
@@ -496,7 +497,7 @@ test('history heading follows the concept and sends the selected saved pair',asy
|
||||
await expect(page.getByRole('link',{name:'Back to library',exact:true})).toHaveCount(0);
|
||||
const send=actions.nth(2);await expect(send).toHaveClass(/\bsend\b/);await expect(send.locator('svg')).toHaveCount(1);
|
||||
await expect(send).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${f.metadata.title}`);
|
||||
expect(await visibleNames(variantsPanel(page).locator('.row-actions button'))).toEqual(['+ Add negative','Show archived (1)']);
|
||||
expect(await visibleNames(variantsPanel(page).locator('.row-actions button'))).toEqual(['Compare negatives','+ Add negative','Show archived (1)']);
|
||||
await expect(variantsPanel(page).getByRole('button',{name:'Show archived (1)',exact:true})).toHaveAttribute('aria-pressed','false');
|
||||
// The concept opens a prompt history on its first negative variant (app.js navigate: state.variant = 0), so Send starts enabled.
|
||||
await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');await expect(send).toBeEnabled();
|
||||
@@ -508,15 +509,17 @@ test('history heading follows the concept and sends the selected saved pair',asy
|
||||
expect(page.writes).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('returning to a history opens on the first negative, forgetting a clicked variant or All images',async({page})=>{
|
||||
test('returning to a history opens on the first negative, forgetting a clicked variant; All images is a dialog',async({page})=>{
|
||||
const f=page.fixture;await openHistory(page,f.positive_id);
|
||||
await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');
|
||||
await selectPair(page,'Select negative variant 2');
|
||||
await leaveAndReturn(page,f.positive_id);await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');
|
||||
// #249 D26: All images opens the concept dialog and leaves the selected negative as it was.
|
||||
const all=variantsPanel(page).getByRole('button',{name:'All images for this positive',exact:true});
|
||||
await all.click();await expect(all).toHaveAttribute('aria-pressed','true');await expect(pressedVariant(page)).toHaveCount(0);
|
||||
await all.click();const listing=page.getByRole('dialog',{name:'All images for this positive',exact:true});await expect(listing).toBeVisible();
|
||||
await page.keyboard.press('Escape');await expect(listing).toHaveCount(0);await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');
|
||||
await leaveAndReturn(page,f.positive_id);
|
||||
await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');await expect(all).toHaveAttribute('aria-pressed','false');
|
||||
await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');
|
||||
await expect(headingActions(page).locator('button.send')).toBeEnabled();expect(page.writes).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -530,8 +533,8 @@ test('add negative re-renders history with the saved or reused pair selected',as
|
||||
await expect(laneLabel(page,'Negative variant 1 ·')).toHaveCount(1);await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');
|
||||
await add.click();dialog=editorDialog(page);await ready(dialog);await field(dialog,'Negative prompt').fill(` ${f.selected_negative.toUpperCase()} `);await button(dialog,'Save').click();
|
||||
await expect(dialog).toHaveCount(0);await expect(toast(page)).toHaveText('This prompt pair already exists. Showing the saved version.');
|
||||
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.selected_negative);
|
||||
await expect(laneLabel(page,/· 1 linked image$/)).toHaveCount(1);
|
||||
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText(f.selected_negative);
|
||||
await expect(laneLabel(page,/· 2 runs$/)).toHaveCount(1);
|
||||
await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 3');
|
||||
// The saved selection applies to that save only; returning opens on the first negative, as in the concept.
|
||||
await leaveAndReturn(page,f.positive_id);await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');
|
||||
@@ -549,9 +552,9 @@ test('reused pair outside first forty remains selected without loading every pag
|
||||
await openHistory(page,f.positive_id);await expect(variantsPanel(page)).toContainText('40 of 44 saved negative variants');page.writes.length=0;
|
||||
await variantsPanel(page).getByRole('button',{name:'+ Add negative',exact:true}).click();
|
||||
const dialog=editorDialog(page);await ready(dialog);await field(dialog,'Negative prompt').fill(f.first_negative);await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);
|
||||
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.first_negative);
|
||||
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText(f.first_negative);
|
||||
await expect(pressedVariant(page)).toHaveCount(1);await expect(variantsPanel(page)).toContainText('40 of 44 saved negative variants');
|
||||
await expect(variantsPanel(page).locator('.library-variant-lane')).toHaveCount(41);
|
||||
await expect(variantsPanel(page).locator('.variant-lane')).toHaveCount(41);
|
||||
expect(variantCursorRequests).toHaveLength(0);expect(page.writes).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -564,7 +567,7 @@ test('edit metadata stays on the page and re-reads the selected pair',async({pag
|
||||
await field(dialog,'Title').fill('Edited in place');await field(dialog,'Category').fill('Studio work');await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Edited in place');await expect(page.locator('#workspace-main h1')).toBeFocused();
|
||||
await expect(toast(page)).toHaveText('Saved.');
|
||||
await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));await expect(historyRegion(page).locator('.library-surface .library-chip').first()).toHaveText('Studio work');
|
||||
await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));await expect(historyRegion(page).locator('article.surface > span.chip').first()).toHaveText('Studio work');
|
||||
await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 2');
|
||||
await expect.poll(()=>pairReads.length).toBeGreaterThan(before);
|
||||
await expect(headingActions(page).locator('button.send')).toHaveAccessibleName('Send prompt pair to ComfyUI: Edited in place');
|
||||
@@ -638,6 +641,29 @@ test('restored storage before an Edit or Add negative save offers only the Libra
|
||||
expect((await detail(request,f.positive_id)).variants.some(variant=>variant.negative_text==='typed before the restore')).toBe(false);
|
||||
});
|
||||
|
||||
// #249 F2: archived lanes (#235) are concept lanes marked archived, with the D45 reduced emphasis.
|
||||
test('show archived and restore still work',async({page,request})=>{
|
||||
const f=page.fixture;await openHistory(page,f.positive_id);
|
||||
const timeline=variantsPanel(page).locator(':scope > .timeline');
|
||||
expect(await visibleNames(variantsPanel(page).locator('.panel-heading .row-actions button'))).toEqual(['Compare negatives','+ Add negative','Show archived (1)']);
|
||||
await expect(timeline.locator('.library-variant-archived')).toHaveCount(0);
|
||||
const toggle=variantsPanel(page).getByRole('button',{name:'Show archived (1)',exact:true});await toggle.click();await expect(toggle).toHaveAttribute('aria-pressed','true');
|
||||
const lane=timeline.locator(':scope > .variant-lane.library-variant-archived');await expect(lane).toHaveCount(1);
|
||||
await expect(lane.locator(':scope > span')).toContainText(/ · \d+ runs? · Archived/);
|
||||
const dot=lane.locator(':scope > button.variant-dot');await expect(dot).toHaveText('−');
|
||||
expect(Number(await dot.evaluate(node=>getComputedStyle(node).opacity))).toBeLessThan(1);
|
||||
expect(Number(await lane.evaluate(node=>getComputedStyle(node,'::before').opacity))).toBeLessThan(1);
|
||||
await expect(lane.locator(':scope > span')).toHaveCSS('opacity','1');
|
||||
await dot.click();await expect(dot).toHaveAttribute('aria-pressed','true');
|
||||
const writes=page.writes.length;
|
||||
await lane.getByRole('button',{name:/^Restore /}).click();
|
||||
await expect(timeline.locator('.library-variant-archived')).toHaveCount(0);
|
||||
await expect(variantsPanel(page).getByRole('button',{name:'Show archived (0)',exact:true})).toBeVisible();
|
||||
expect(page.writes.slice(writes)).toEqual([{method:'PATCH',url:`/prompt_manager/pairs/${f.archived_pair_id}`,body:{archived:false}}]);
|
||||
expect((await pairRead(request,f.archived_pair_id)).archived).toBe(false);
|
||||
await expect(timeline.locator(':scope > .variant-lane > .variant-dot[aria-pressed="true"]')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('Restore stays disabled until the restore settles and sends one PATCH',async({page,request})=>{
|
||||
const f=page.fixture;await openHistory(page,f.positive_id);
|
||||
let release;const held=new Promise(resolve=>{release=resolve;});
|
||||
@@ -667,26 +693,30 @@ test('archive hides a pair, Show archived finds it and Restore keeps it selected
|
||||
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();const dialog=editorDialog(page);await ready(dialog);
|
||||
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0);
|
||||
await expect(toast(page)).toHaveText('Archived. Find it under Show archived in Negative variants.');await expect(page.locator('#workspace-main h1')).toBeFocused();
|
||||
await expect(variantsPanel(page).locator('.library-variant-lane')).toHaveCount(2);await expect(pressedVariant(page)).toHaveCount(1);
|
||||
await expect(variantsPanel(page).locator('.variant-lane')).toHaveCount(2);await expect(pressedVariant(page)).toHaveCount(1);
|
||||
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true})).not.toContainText(f.selected_negative);
|
||||
expect(await evidence()).toEqual(before);
|
||||
const toggle=variantsPanel(page).getByRole('button',{name:'Show archived (2)',exact:true});await expect(toggle).toHaveAttribute('aria-pressed','false');
|
||||
await toggle.click();await expect(toggle).toHaveAttribute('aria-pressed','true');
|
||||
const archived=variantsPanel(page).locator('.library-variant-archived');await expect(archived).toHaveCount(2);
|
||||
const lane=archived.filter({hasText:'· 1 linked image · Archived'});await expect(lane).toHaveCount(1);
|
||||
const opacity=await lane.locator('.library-variant-dot').evaluate(node=>getComputedStyle(node).opacity);expect(Number(opacity)).toBeLessThan(1);
|
||||
const lane=archived.filter({hasText:'· 2 runs · Archived'});await expect(lane).toHaveCount(1);
|
||||
const opacity=await lane.locator('.variant-dot').evaluate(node=>getComputedStyle(node).opacity);expect(Number(opacity)).toBeLessThan(1);
|
||||
const galleryReads=[];page.on('request',item=>{if(item.url().includes('/images?'))galleryReads.push(new URL(item.url()).searchParams);});
|
||||
await lane.locator('.library-variant-dot').click();await expect(lane.locator('.library-variant-dot')).toHaveAttribute('aria-pressed','true');
|
||||
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.selected_negative);
|
||||
await lane.locator('.variant-dot').click();await expect(lane.locator('.variant-dot')).toHaveAttribute('aria-pressed','true');
|
||||
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText(f.selected_negative);
|
||||
await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).locator('article[data-image-key]')).toHaveCount(1);
|
||||
await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).getByText('1 loaded · 1 linked image',{exact:true})).toBeVisible();
|
||||
// #249 D8 ruling (a): the Library-embedded gallery has no select checkbox and no selection bar.
|
||||
await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).locator('.image-overlays input[type="checkbox"]')).toHaveCount(0);
|
||||
await expect(page.locator('#bulkbar, .bulkbar')).toHaveCount(0);
|
||||
// D47: the archived pair's gallery is named by its "Shared gallery" heading, with no separate loaded count.
|
||||
await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).locator('.panel-heading .small')).toHaveText(/ · Archived · same positive prompt$/);
|
||||
expect(galleryReads.at(-1).get('archive')).toBe('archived');expect(galleryReads.at(-1).get('pair_id')).toBe(String(f.pair_id));
|
||||
await expect(headingActions(page).getByRole('button',{name:/^Send prompt pair to ComfyUI/})).toBeEnabled();
|
||||
const writes=page.writes.length;
|
||||
await lane.getByRole('button',{name:/^Restore/}).click();
|
||||
await expect(variantsPanel(page).getByRole('button',{name:'Show archived (1)',exact:true})).toBeVisible();
|
||||
await expect(variantsPanel(page).locator('.library-variant-archived')).toHaveCount(1);
|
||||
await expect(pressedVariant(page)).toHaveCount(1);await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.selected_negative);
|
||||
await expect(pressedVariant(page)).toHaveCount(1);await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText(f.selected_negative);
|
||||
expect(page.writes.slice(writes)).toEqual([{method:'PATCH',url:`/prompt_manager/pairs/${f.pair_id}`,body:{archived:false}}]);
|
||||
expect((await pairRead(request,f.pair_id)).archived).toBe(false);expect(await evidence()).toEqual(before);
|
||||
await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).locator('article[data-image-key]')).toHaveCount(1);
|
||||
@@ -694,14 +724,14 @@ test('archive hides a pair, Show archived finds it and Restore keeps it selected
|
||||
});
|
||||
|
||||
test('filter hidden save resets shelf cursor and measured heights retaining anchor',async({page})=>{
|
||||
await page.goto(`${workspace}#/library`);await page.getByRole('searchbox',{name:'Search prompts',exact:true}).fill('Standalone');
|
||||
await page.goto(`${workspace}#/library`);await page.getByRole('searchbox',{name:'Search workspace',exact:true}).fill('Standalone');
|
||||
await expect(page.locator('article[data-prompt-id]')).toHaveCount(1);
|
||||
const shelfReads=[];page.on('request',item=>{const url=new URL(item.url());if(url.pathname==='/prompt_manager/library'&&item.method()==='GET')shelfReads.push(url.searchParams);});
|
||||
await page.getByRole('button',{name:'+ New prompt',exact:true}).click();const dialog=editorDialog(page);await ready(dialog);
|
||||
await field(dialog,'Title').fill('Hidden by filter');await field(dialog,'Positive prompt').fill('Lighthouse in heavy rain');await button(dialog,'Save').click();
|
||||
await expect(page).toHaveURL(/#\/library\/\d+$/);await expect(page.locator('#workspace-main h1')).toHaveText('Hidden by filter');
|
||||
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
|
||||
await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('Standalone');
|
||||
await expect(page.getByRole('searchbox',{name:'Search workspace',exact:true})).toHaveValue('Standalone');
|
||||
await expect(page.locator('article[data-prompt-id]')).toHaveCount(1);
|
||||
await expect(page.getByText('The saved prompt is hidden by the current filters.',{exact:false})).toBeVisible();
|
||||
expect(shelfReads.length).toBeGreaterThan(0);expect(shelfReads.every(params=>!params.has('cursor'))).toBe(true);
|
||||
@@ -724,7 +754,7 @@ test('export downloads the full Library JSON without a toast and reports a failu
|
||||
expect(downloads).toBe(0);await expect(toast(page)).toBeHidden();expect(page.writes).toHaveLength(0);
|
||||
});
|
||||
|
||||
const uniqueDotNames=async page=>{const names=await variantsPanel(page).locator('.library-variant-dot').evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')));expect(new Set(names).size).toBe(names.length);};
|
||||
const uniqueDotNames=async page=>{const names=await variantsPanel(page).locator('.variant-dot').evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')));expect(new Set(names).size).toBe(names.length);};
|
||||
|
||||
test('archived reuse is reported, shown under Show archived and selected read-only from Add negative and New',async({page})=>{
|
||||
const f=page.fixture;await openHistory(page,f.positive_id);
|
||||
@@ -735,8 +765,8 @@ test('archived reuse is reported, shown under Show archived and selected read-on
|
||||
await expect(toast(page)).toHaveText(message);
|
||||
await expect(variantsPanel(page).getByRole('button',{name:'Show archived (1)',exact:true})).toHaveAttribute('aria-pressed','true');
|
||||
const lane=variantsPanel(page).locator('.library-variant-archived');await expect(lane).toHaveCount(1);
|
||||
await expect(lane.locator('.library-variant-dot')).toHaveAttribute('aria-pressed','true');
|
||||
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.archived_negative);
|
||||
await expect(lane.locator('.variant-dot')).toHaveAttribute('aria-pressed','true');
|
||||
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText(f.archived_negative);
|
||||
await expect.poll(()=>galleryReads.at(-1)?.get('archive')).toBe('archived');expect(galleryReads.at(-1).get('pair_id')).toBe(String(f.archived_pair_id));
|
||||
await uniqueDotNames(page);
|
||||
await page.goto(`${workspace}#/library`);await page.getByRole('button',{name:'+ New prompt',exact:true}).click();dialog=editorDialog(page);await ready(dialog);
|
||||
@@ -744,7 +774,7 @@ test('archived reuse is reported, shown under Show archived and selected read-on
|
||||
await field(dialog,'Category').fill(f.metadata.category);await field(dialog,'Tags, separated by commas').fill(f.metadata.tags.join(', '));await field(dialog,'Rating').selectOption(String(f.metadata.rating));await field(dialog,'Notes').fill(f.metadata.notes);
|
||||
await button(dialog,'Save').click();await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));
|
||||
await expect(toast(page)).toHaveText(message);
|
||||
await expect(variantsPanel(page).locator('.library-variant-archived .library-variant-dot')).toHaveAttribute('aria-pressed','true');
|
||||
await expect(variantsPanel(page).locator('.library-variant-archived .variant-dot')).toHaveAttribute('aria-pressed','true');
|
||||
expect(page.writes.every(write=>write.method==='POST')).toBe(true);
|
||||
});
|
||||
|
||||
@@ -805,7 +835,7 @@ test('turning Show archived on again after a hidden archive shows the newly arch
|
||||
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0);
|
||||
await expect(toggle()).toHaveText('Show archived (2)');await toggle().click();
|
||||
await expect(variantsPanel(page).locator('.library-variant-archived')).toHaveCount(2);
|
||||
await expect(laneLabel(page,'· 1 linked image · Archived')).toHaveCount(1);await uniqueDotNames(page);
|
||||
await expect(laneLabel(page,'· 2 runs · Archived')).toHaveCount(1);await uniqueDotNames(page);
|
||||
});
|
||||
|
||||
test('the comparison reports a wrong role color, a wrong field color and a removed Save control',async({page})=>{
|
||||
@@ -869,7 +899,7 @@ test('long and error states retain footer focus and reflow',async({page})=>{
|
||||
|
||||
// Task A2: the editor dialogs keep matching the concept on the concept base stylesheet, with library-editor.css retired.
|
||||
const CONCEPT_BASE=['js/shared/toolkit/controls.css','lib/viewerjs/viewer.min.css','js/workspace/concept.css',
|
||||
'js/workspace/images.css','js/workspace/library.css','js/workspace/inspector.css','js/workspace/workspace.css'];
|
||||
'js/workspace/workspace.css'];
|
||||
for(const [width,height,theme] of [[1600,1100,'dark'],[390,844,'light']])test(`concept base keeps the tags screen and editor dialogs equal to the concept ${width} ${theme}`,async({page})=>{
|
||||
await page.setViewportSize({width,height});const f=page.fixture;const references={};
|
||||
for(const mode of Object.keys(conceptModes)) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
|
||||
const route='/prompt_manager/workspace#/library';
|
||||
const cards=page=>page.locator('article[data-prompt-id]');
|
||||
@@ -7,13 +7,34 @@ 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]');
|
||||
const allImages=page=>page.getByRole('dialog',{name:'All images for this positive',exact:true});
|
||||
|
||||
const refine=page=>page.getByRole('dialog',{name:'Refine this view',exact:true});
|
||||
/** The Library's Tags filter lives in "Refine this view" (#249 D14). */
|
||||
async function applyTags(page,tags) {
|
||||
// D44: Filters reads "Filters · 1" while a Tags filter is active.
|
||||
await page.locator('#workspace-main search.toolbar').getByRole('button',{name:/^Filters(?: · 1)?$/}).click();
|
||||
await refine(page).getByRole('textbox',{name:'Tags',exact:true}).fill(tags);
|
||||
await refine(page).getByRole('button',{name:'Apply filters',exact:true}).click();await expect(refine(page)).toHaveCount(0);
|
||||
}
|
||||
/** The first shelf page, then `pages` more, each waited for by its own response (the caption counts the filtered set). */
|
||||
async function loadShelfPages(page,more,pages) {
|
||||
await expect(page.locator('.section-caption')).toContainText('203 positive prompts');await expect(more).toHaveAttribute('aria-disabled','false');
|
||||
for(let index=0;index<pages;index++) {
|
||||
const response=page.waitForResponse(item=>{const url=new URL(item.url());return url.pathname==='/prompt_manager/library'&&url.searchParams.has('cursor');});
|
||||
await more.evaluate(node=>node.click());await response;await expect(more).toHaveAttribute('aria-disabled','false');
|
||||
}
|
||||
}
|
||||
/** Resolves in the rendering frame after every frame callback the page had already queued (callbacks run in request order). */
|
||||
const nextFrame=page=>page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>resolve())));
|
||||
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 cards(page).filter({has:page.getByText(text,{exact:true})}).getByRole('button',{name:'Explore history',exact:true}).click();
|
||||
await expect(history(page)).toBeVisible();
|
||||
}
|
||||
// The fixture's pairs are titled, so each dot is named by its title (#249 D45); these contracts address lanes by position.
|
||||
const variantDot=(page,index)=>history(page).locator('.timeline > .variant-lane').nth(index).locator(':scope > .variant-dot');
|
||||
async function selectVariant(page,index) {
|
||||
const choice=history(page).getByRole('button',{name:`Select negative variant ${index+1}`,exact:true});
|
||||
const choice=variantDot(page,index);
|
||||
await choice.click();await expect(choice).toHaveAttribute('aria-pressed','true');
|
||||
}
|
||||
|
||||
@@ -23,7 +44,8 @@ test.beforeEach(async({page,request,browserName})=>{
|
||||
});
|
||||
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})=>{
|
||||
test('library groups reusable negatives under one positive without exposing negative-only components as parents',async({page,request})=>{
|
||||
const rows=await(await request.get('/prompt_manager/library')).json();
|
||||
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);
|
||||
@@ -31,7 +53,8 @@ test('library groups reusable negatives under one positive without exposing nega
|
||||
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);
|
||||
// D23: "N runs" is the parent's own run_count, never a recorded-execution count.
|
||||
for(const item of rows.items) await expect(page.locator(`article[data-prompt-id="${item.id}"] .card-meta > span`).nth(1)).toHaveText(`${item.run_count} run${item.run_count===1?'':'s'}`);
|
||||
});
|
||||
|
||||
test('history displays the selected full negative beneath its positive and filters one shared physical gallery',async({page,request})=>{
|
||||
@@ -39,31 +62,34 @@ test('history displays the selected full negative beneath its positive and filte
|
||||
const data=await(await request.get(`/prompt_manager/library/${cat.id}`)).json();
|
||||
// The concept opens on the first negative variant and filters the gallery to it (app.js navigate: state.variant = 0).
|
||||
await page.goto(route);await openHistory(page);
|
||||
await expect(history(page).getByRole('button',{name:'Select negative variant 1',exact:true})).toHaveAttribute('aria-pressed','true');
|
||||
await expect(variantDot(page,0)).toHaveAttribute('aria-pressed','true');
|
||||
await expect(media(page)).toHaveCount(['blurry, watermark','low quality'].includes(data.variants[0].negative_text)?1:0);
|
||||
await expect(history(page).getByRole('button',{name:'All images for this positive',exact:true})).toHaveAttribute('aria-pressed','false');
|
||||
// #249 D26: All images for this positive opens a dialog; it is not a gallery filter.
|
||||
await expect(history(page).getByRole('button',{name:'All images for this positive',exact:true})).not.toHaveAttribute('aria-pressed',/.*/);
|
||||
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(neg.locator('p.prompt-text')).toHaveText(negative||'No negative prompt (explicitly empty)');
|
||||
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']));
|
||||
await history(page).getByRole('button',{name:'All images for this positive',exact:true}).click();
|
||||
const listing=allImages(page).locator('article[data-image-key]');await expect(listing).toHaveCount(3);
|
||||
expect(await listing.evaluateAll(nodes=>nodes.map(node=>node.dataset.imageKey))).toEqual(expect.arrayContaining(['0:shared/same.png','1:shared/same.png','0:legacy-unassigned.png']));
|
||||
await page.keyboard.press('Escape');await expect(allImages(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
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 page.goto(route);await page.getByRole('searchbox',{name:'Search workspace',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);
|
||||
await page.getByRole('searchbox',{name:'Search workspace',exact:true}).fill('');
|
||||
await page.getByRole('combobox',{name:'Filter by category',exact:true}).selectOption('Portraits');
|
||||
await applyTags(page,'studio');await expect(cards(page)).toHaveCount(1);
|
||||
await applyTags(page,'outdoors');await expect(cards(page)).toHaveCount(0);
|
||||
await page.getByRole('combobox',{name:'Filter by 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})=>{
|
||||
@@ -79,14 +105,39 @@ test('library infinite loading keeps unique parents and keyboard-accessible hist
|
||||
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);
|
||||
// The last page's render queued the window update for the next frame; count after that frame, not before it.
|
||||
await expect(more).toBeHidden();await nextFrame(page);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});
|
||||
const explore=page.locator(`article[data-prompt-id="${firstId}"]`).getByRole('button',{name:'Explore history',exact:true});
|
||||
await explore.focus();await expect(explore).toBeFocused();await page.keyboard.press('Enter');await expect(history(page)).toBeVisible();
|
||||
});
|
||||
|
||||
// D40/D15: with no Refresh, Retry also re-reads a category list that failed with the shelf.
|
||||
test('Retry re-reads a failed category list with the shelf',async({page})=>{
|
||||
let fail=true;const reads=[];
|
||||
await page.route(url=>['/prompt_manager/library','/prompt_manager/library/options'].includes(url.pathname),async request=>{
|
||||
reads.push(new URL(request.request().url()).pathname);
|
||||
if(fail)return request.fulfill({status:503,json:{success:false,error:'Unavailable'}});
|
||||
await request.continue().catch(()=>{});
|
||||
});
|
||||
await page.goto(route);
|
||||
const retry=page.getByRole('button',{name:'Retry prompt library',exact:true});await expect(retry).toBeVisible();
|
||||
const categoriesFailed=page.getByRole('alert').filter({hasText:'Categories could not be loaded. Open the Prompt library again to retry.'});
|
||||
await expect(categoriesFailed).toBeVisible();
|
||||
fail=false;reads.length=0;await retry.click();await expect(cards(page)).toHaveCount(3);
|
||||
await expect(categoriesFailed).toBeHidden();
|
||||
await expect(page.getByRole('combobox',{name:'Filter by category',exact:true}).locator('option',{hasText:'Portraits'})).toHaveCount(1);
|
||||
expect(reads).toContain('/prompt_manager/library/options');
|
||||
// Apply with the same Tags, and a search that lands on the values already shown, reload nothing.
|
||||
reads.length=0;
|
||||
await page.locator('#workspace-main search.toolbar').getByRole('button',{name:'Filters',exact:true}).click();
|
||||
await refine(page).getByRole('button',{name:'Apply filters',exact:true}).click();await expect(refine(page)).toHaveCount(0);
|
||||
const search=page.getByRole('searchbox',{name:'Search workspace',exact:true});await search.fill(' ');
|
||||
await page.waitForTimeout(400);expect(reads).toEqual([]);await expect(cards(page)).toHaveCount(3);
|
||||
});
|
||||
|
||||
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=>{
|
||||
@@ -97,11 +148,82 @@ test('failed library reads retry while late search responses cannot overwrite ne
|
||||
});
|
||||
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');
|
||||
const search=page.getByRole('searchbox',{name:'Search workspace',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})=>{
|
||||
test('library toolbar matches the concept and tags live in Refine this view',async({page})=>{
|
||||
await page.goto(route);await expect(cards(page)).toHaveCount(3);
|
||||
// app.js toolbar() a347–360: search.toolbar > label.search-field (input#search), select#category-filter, select#sort, Save view, Filters.
|
||||
const toolbar=page.locator('#workspace-main search.toolbar');await expect(toolbar).toHaveCount(1);
|
||||
expect(await toolbar.evaluate(node=>[...node.children].map(child=>child.tagName.toLowerCase()+(child.id?`#${child.id}`:'')+(child.className?`.${child.className.split(' ').filter(name=>!name.startsWith('ca-')).join('.')}`:'')))).toEqual(['label.search-field','select#category-filter','select#sort','button','button']);
|
||||
await expect(toolbar.locator('#search')).toHaveAttribute('placeholder','Search prompts, tags, filenames…');
|
||||
await expect(toolbar.locator('#sort option')).toHaveText(['Recently added','Name A–Z','Highest rated','Oldest first']);
|
||||
await expect(toolbar.getByRole('button')).toHaveText(['Save view','Filters']);
|
||||
await expect(page.getByRole('button',{name:'Refresh library',exact:true})).toHaveCount(0);
|
||||
await expect(page.getByRole('textbox',{name:'Tags',exact:true})).toHaveCount(0);
|
||||
await expect(page.locator('.library-shelf > search.toolbar:first-child')).toHaveCount(1);
|
||||
// Tags apply from the dialog only; Clear clears them and the toolbar category, as the concept's does.
|
||||
await page.getByRole('combobox',{name:'Filter by category',exact:true}).selectOption('Portraits');await applyTags(page,'studio');await expect(cards(page)).toHaveCount(1);
|
||||
// D44: the active Tags filter shows on the button; the category is the toolbar's own and is not counted.
|
||||
await expect(toolbar.getByRole('button')).toHaveText(['Save view','Filters · 1']);
|
||||
await toolbar.getByRole('button',{name:'Filters · 1',exact:true}).click();
|
||||
await expect(refine(page).locator('.field label')).toHaveText(['Tags']);await expect(refine(page).getByRole('textbox',{name:'Tags',exact:true})).toHaveValue('studio');
|
||||
await refine(page).getByRole('button',{name:'Clear filters',exact:true}).click();await expect(refine(page)).toHaveCount(0);
|
||||
await expect(toolbar.getByRole('button',{name:'Filters',exact:true})).toBeFocused();
|
||||
await expect(page.getByRole('combobox',{name:'Filter by category',exact:true})).toHaveValue('');await expect(cards(page)).toHaveCount(3);
|
||||
const sorted=page.waitForRequest(item=>new URL(item.url()).searchParams.get('sort')==='name');
|
||||
await page.getByRole('combobox',{name:'Sort results',exact:true}).selectOption({label:'Name A–Z'});await sorted;
|
||||
});
|
||||
|
||||
test('caption counts equal the filtered cards',async({page,request})=>{
|
||||
const totals=async query=>{const data=await(await request.get(`/prompt_manager/library?${query}`)).json();return [data.items.length,data.items.reduce((sum,item)=>sum+item.variant_count,0),data.variant_total];};
|
||||
await page.goto(route);await expect(cards(page)).toHaveCount(3);
|
||||
const caption=page.locator('#workspace-main .section-caption');
|
||||
for(const [query,act] of [['',null],['category=Portraits',()=>page.getByRole('combobox',{name:'Filter by category',exact:true}).selectOption('Portraits')],['category=Portraits&tag=outdoors',()=>applyTags(page,'outdoors')]]) {
|
||||
if(act)await act();
|
||||
const [count,variants,total]=await totals(query);expect(variants).toBe(total);
|
||||
await expect(cards(page)).toHaveCount(count);
|
||||
await expect(caption.locator('span')).toHaveText([`${count} positive prompt${count===1?'':'s'} · ${variants} negative variant${variants===1?'':'s'}`,'Images are grouped by positive prompt']);
|
||||
}
|
||||
});
|
||||
|
||||
// #249 final review M5: the count is a status region, written only when it changes, so Load more never re-announces it.
|
||||
test('the caption count is announced once per change and kept while the shelf reloads',async({page,request})=>{
|
||||
await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:200}});
|
||||
await page.goto(route);const more=page.getByRole('button',{name:'Load more prompts',exact:true});
|
||||
const count=page.locator('#workspace-main .section-caption > span').first();
|
||||
await expect(count).toHaveAttribute('role','status');await expect(count).toHaveText(/^203 positive prompts · \d+ negative variants?$/);
|
||||
await count.evaluate(node=>{window.__countWrites=0;new MutationObserver(records=>{window.__countWrites+=records.length;}).observe(node,{childList:true,characterData:true,subtree:true});});
|
||||
await loadShelfPages(page,more,1);
|
||||
expect(await page.evaluate(()=>window.__countWrites)).toBe(0);
|
||||
// A filter change keeps the last count shown while the next page loads, then writes the new one.
|
||||
let release;const held=new Promise(resolve=>{release=resolve;});
|
||||
await page.route(url=>url.pathname==='/prompt_manager/library'&&url.searchParams.get('category')==='Portraits',async route=>{await held;await route.continue();});
|
||||
await page.getByRole('combobox',{name:'Filter by category',exact:true}).selectOption('Portraits');
|
||||
await expect(page.locator('.library-status')).toHaveText('Loading prompt library…');
|
||||
await expect(page.locator('#workspace-main .section-caption')).toBeVisible();await expect(count).toHaveText(/^203 positive prompts/);
|
||||
release();await expect(count).not.toHaveText(/^203 /);
|
||||
expect(await page.evaluate(()=>window.__countWrites)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('a Library-embedded media card stars its file and the star survives a reload',async({page})=>{
|
||||
await page.goto(route);await openHistory(page);
|
||||
// The unassigned file is reached through the All images dialog (#249 D26).
|
||||
await history(page).getByRole('button',{name:'All images for this positive',exact:true}).click();await expect(allImages(page).locator('article[data-image-key]')).toHaveCount(3);
|
||||
const card=allImages(page).locator('article[data-image-key="0:legacy-unassigned.png"]');
|
||||
const star=card.getByRole('button',{name:'Favorite legacy-unassigned.png',exact:true});
|
||||
await expect(star).toHaveAttribute('aria-pressed','false');await expect(card.locator('input[type=checkbox]')).toHaveCount(0);
|
||||
await expect(page.locator('.bulkbar, #bulkbar')).toHaveCount(0);
|
||||
const saved=page.waitForResponse(item=>new URL(item.url()).pathname==='/prompt_manager/images/favorite'&&item.request().method()==='PUT');
|
||||
await star.click();expect((await saved).ok()).toBe(true);
|
||||
await expect(star).toHaveAttribute('aria-pressed','true');await expect(star).toHaveText('★');
|
||||
await expect(page.getByText('Added to favorites.',{exact:true})).toBeVisible();
|
||||
await page.reload();await history(page).getByRole('button',{name:'All images for this positive',exact:true}).click();
|
||||
await expect(allImages(page).locator('article[data-image-key="0:legacy-unassigned.png"]').getByRole('button',{name:'Favorite legacy-unassigned.png',exact:true})).toHaveAttribute('aria-pressed','true');
|
||||
});
|
||||
|
||||
test('a sort change 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();
|
||||
@@ -111,31 +233,45 @@ test('refresh atomically discards old row geometry and renders changed parent da
|
||||
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();
|
||||
// D15: no Refresh; any filter change restarts the stream the same way.
|
||||
await page.getByRole('combobox',{name:'Sort results',exact:true}).selectOption('oldest');
|
||||
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(changed.locator('.card-footer .ca-visually-hidden')).toHaveText('Rated 5 out of 5');await expect(changed.locator('.card-footer > span > [aria-hidden="true"]')).toHaveText('★★★★★');
|
||||
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 page.getByRole('combobox',{name:'Filter by category',exact:true}).selectOption('Animals');
|
||||
await page.getByRole('searchbox',{name:'Search workspace',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 page.getByRole('button',{name:'← Prompt library',exact:true}).click();
|
||||
await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('Cat portrait');await expect(cards(page)).toHaveCount(1);
|
||||
await expect(page.getByRole('searchbox',{name:'Search workspace',exact:true})).toHaveValue('Cat portrait');await expect(cards(page)).toHaveCount(1);
|
||||
await page.getByRole('navigation', { name: 'Tools and system', exact: true }).getByRole('link', { name: 'Overview', exact: true }).click();
|
||||
await expect(history(page)).toHaveCount(0);await expect(page.locator('dialog[open]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('leaving the shelf before its first page answers and returning reads page one without a page error',async({page})=>{
|
||||
// Hold the first shelf page so the route changes while the shelf is still loading (a slow CI runner's natural order).
|
||||
let reached;const firstRead=new Promise(resolve=>{reached=resolve;});
|
||||
await page.route(url=>url.pathname==='/prompt_manager/library'&&!url.searchParams.has('cursor'),route=>{reached();});
|
||||
await page.goto(route);await firstRead;await expect(page.locator('.library-status')).toHaveText('Loading prompt library…');
|
||||
await page.evaluate(()=>{window.location.hash='#/tags';});
|
||||
await expect(page.locator('#workspace-main > .heading h1')).toHaveText('Tags & categories');
|
||||
await page.unrouteAll({behavior:'ignoreErrors'});
|
||||
await page.evaluate(()=>{window.location.hash='#/library';});
|
||||
await expect(page.locator('#workspace-main > .heading h1')).toHaveText('Prompt library');
|
||||
await expect(cards(page)).toHaveCount(3);await expect(page.locator('.section-caption')).toContainText('3 positive prompts');
|
||||
expect(page.libraryErrors).toEqual([]);
|
||||
});
|
||||
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 page.getByRole('searchbox',{name:'Search workspace',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);
|
||||
@@ -143,17 +279,19 @@ test('empty shelf visits restore saved scroll without inventing a parent anchor'
|
||||
await page.getByRole('navigation', { name: 'Tools and system', 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(?:\s*[\d,]+)?$/}).evaluate(node=>node.click());
|
||||
await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('No matching saved positive');
|
||||
await expect(page.getByRole('searchbox',{name:'Search workspace',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');
|
||||
const search=page.getByRole('searchbox',{name:'Search workspace',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);
|
||||
await page.evaluate(()=>scrollTo(0,0));await page.getByRole('combobox',{name:'Sort results',exact:true}).selectOption('oldest');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('button',{name:'← Prompt library',exact:true}).click();
|
||||
await retainedParent(page,anchor);await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('Dog landscape');
|
||||
await cards(page).getByRole('button',{name:'Explore history',exact:true}).evaluate(node=>node.click());await expect(history(page)).toBeVisible();
|
||||
// A short phone window, so the one-card shelf can scroll far enough to put the parent back at its offset (nothing
|
||||
// follows the shelf's continuation since the legacy link moved to Overview, #249 E3).
|
||||
await page.setViewportSize({width:390,height:600});await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
|
||||
await retainedParent(page,anchor);await expect(page.getByRole('searchbox',{name:'Search workspace',exact:true})).toHaveValue('Dog landscape');
|
||||
});
|
||||
|
||||
test('history detail failures retry and returning to the list ignores late detail responses',async({page})=>{
|
||||
@@ -163,7 +301,7 @@ test('history detail failures retry and returning to the list ignores late detai
|
||||
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 page.goto(route);await cards(page).filter({has:page.getByText('Cat portrait',{exact:true})}).getByRole('button',{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('button',{name:'← Prompt library',exact:true}).click();release();
|
||||
@@ -187,7 +325,7 @@ test('history shares real image metadata, vendored Viewer and the acknowledged p
|
||||
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();
|
||||
const chosenNegative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text').textContent();
|
||||
await page.locator('#workspace-main > .heading').getByRole('button',{name:'Send prompt pair to ComfyUI: Cat portrait',exact:true}).click();
|
||||
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
|
||||
const hosts=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});
|
||||
@@ -197,11 +335,13 @@ test('history shares real image metadata, vendored Viewer and the acknowledged p
|
||||
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 media(page).first().getByRole('button',{name:'Inspect same.png',exact:true}).click();
|
||||
const inspector=page.getByRole('complementary',{name:'Selected image details',exact:true});await expect(inspector.getByRole('region',{name:'Positive prompt',exact:true})).toContainText('Cat portrait');
|
||||
await expect(inspector.locator('.meta-grid dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);
|
||||
// D20: provenance and raw records are the viewer's full details, opened from the card image (D19).
|
||||
await expect(inspector.locator('summary')).toHaveCount(0);
|
||||
await media(page).first().getByRole('button',{name:'Open same.png',exact:true}).click();await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible();
|
||||
await expect(page.locator('dialog[open] #viewer-details summary')).toHaveText(['File & provenance','Workflow & raw metadata']);
|
||||
await page.keyboard.press('Escape');await expect(page.locator('dialog[open]')).toHaveCount(0);
|
||||
await page.getByRole('navigation', { name: 'Tools and system', 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();}
|
||||
@@ -210,11 +350,11 @@ test('history shares real image metadata, vendored Viewer and the acknowledged p
|
||||
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();
|
||||
const variants=history(page).getByRole('button',{name:/^Select negative /});await expect(variants).toHaveCount(40);
|
||||
await selectVariant(page,0);const chosenNegative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text').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);
|
||||
await expect(variantDot(page,0)).toHaveAttribute('aria-pressed','true');
|
||||
await expect(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText(chosenNegative);
|
||||
});
|
||||
|
||||
async function observeLibrary(page, hold=false) {
|
||||
@@ -231,7 +371,7 @@ async function observeLibrary(page, hold=false) {
|
||||
record.native=new NativeResizeObserver(entries=>{
|
||||
const ready=[];
|
||||
for(const entry of entries) {
|
||||
if(entry.target.matches('.library-card')) {
|
||||
if(entry.target.matches('.prompt-card')) {
|
||||
if(!entry.target.isConnected)unmounted++;
|
||||
record.latest.set(entry.target,entry);
|
||||
if(holding) {record.entries.set(entry.target,entry);continue;}
|
||||
@@ -245,21 +385,25 @@ async function observeLibrary(page, hold=false) {
|
||||
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']});
|
||||
new MutationObserver(entries=>{mutations+=entries.filter(entry=>entry.target.matches('.prompt-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);}},
|
||||
// Resolves once two consecutive rendering frames find no Library frame queued. Each check runs inside a native
|
||||
// frame callback, so a frame the page queues later in that update (a scroll event, a ResizeObserver delivery) is
|
||||
// still pending there. A timer is not a frame: under CPU load a 60 ms wait could end before the scroll's frame.
|
||||
// A frame that never drains (a rAF loop, or settling while frames are held) fails after `limit` frames with the stats.
|
||||
settled(limit=600){return new Promise((resolve,reject)=>{let quiet=0,frames=0;const check=()=>{frames++;quiet=pending.size?0:quiet+1;
|
||||
if(quiet>=2)resolve();else if(frames>=limit)reject(new Error(`Library never settled within ${limit} frames: ${JSON.stringify(window.libraryObservation.stats())}`));else raf(check);};raf(check);});},
|
||||
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);
|
||||
await page.evaluate(()=>window.libraryObservation.settled());
|
||||
}
|
||||
async function firstVisibleParent(page) {
|
||||
return cards(page).evaluateAll(nodes=>{
|
||||
@@ -273,37 +417,40 @@ async function retainedParent(page, anchor) {
|
||||
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})=>{
|
||||
test('Library cards clamp long prompts and keep real content inside the card',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-main > .heading p.muted')).toContainText('One positive.');
|
||||
await expect(page.locator('#workspace-main p').filter({hasText:'One positive. Reusable negatives.'})).toHaveCount(1);
|
||||
for(const theme of ['dark','light']) {
|
||||
await page.getByRole('combobox',{name:'Theme',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('.prompt-section.positive .role-label').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('.prompt-card .card-art').first()).toHaveCSS('height',width===1600?'170px':'150px');
|
||||
await expect(page.locator('.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);
|
||||
// D23: two lines of the concept's 12px/1.75 prompt text, the rest clipped inside the card.
|
||||
expect(await prompt.evaluate(node=>({height:node.clientHeight,clipped:node.scrollHeight>node.clientHeight+1}))).toEqual({height:42,clipped:true});
|
||||
expect(await long.evaluate(node=>node.getBoundingClientRect().height)).toBeLessThan(560);
|
||||
for(const selector of ['.card-title','.card-content','.card-content > .tags','.card-footer']) {
|
||||
expect(await long.locator(selector).evaluate(node=>node.scrollHeight<=node.clientHeight+1&&node.scrollWidth<=node.clientWidth+1),selector).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();
|
||||
await long.getByRole('button',{name:'Explore history',exact:true}).scrollIntoViewIfNeeded();
|
||||
await expect(long.getByRole('button',{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);
|
||||
const footer=card.locator('.card-footer > span');
|
||||
await expect(footer.locator('[aria-hidden="true"]')).toHaveText(item.rating?'★'.repeat(item.rating):[]);
|
||||
await expect(footer.locator('.ca-visually-hidden')).toHaveText(item.rating?`Rated ${item.rating} out of 5`:[]);
|
||||
await expect(card.locator('.card-content > .tags .chip')).toHaveText(item.tags);
|
||||
await expect(card.locator('.card-meta > span').nth(1)).toHaveText(`${item.run_count} run${item.run_count===1?'':'s'}`);
|
||||
}
|
||||
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);}
|
||||
}
|
||||
@@ -317,23 +464,22 @@ test('row measurement preserves anchor after width invalidation and append',asyn
|
||||
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 loadShelfPages(page,more,3);
|
||||
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}));
|
||||
const pinned=cards(page).first();const pinnedId=await pinned.getAttribute('data-prompt-id');await pinned.locator('button').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();
|
||||
expect(await cards(page).count()).toBeLessThan(80);await expect(page.locator(`article[data-prompt-id="${pinnedId}"] button`).first()).toBeFocused();
|
||||
const after=await firstVisibleParent(page);release();await page.evaluate(async()=>{await Promise.all([...document.querySelectorAll('.prompt-card img')].map(image=>image.decode().catch(()=>{})));});await settleLibrary(page);await retainedParent(page,after);
|
||||
{const response=page.waitForResponse(item=>{const url=new URL(item.url());return url.pathname==='/prompt_manager/library'&&url.searchParams.has('cursor');});await more.evaluate(node=>node.click());await response;await expect(more).toHaveAttribute('aria-disabled','false');}await settleLibrary(page);await retainedParent(page,after);
|
||||
await expect(page.locator(`article[data-prompt-id="${pinnedId}"] button`).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();
|
||||
await expect(page.locator(`article[data-prompt-id="${pinnedId}"] button`).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();
|
||||
await page.locator(`article[data-prompt-id="${before.id}"]`).getByRole('button',{name:'Explore history',exact:true}).evaluate(node=>node.click());await expect(history(page)).toBeVisible();
|
||||
if(changedWidth)await page.setViewportSize({width:1600,height:1100});
|
||||
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();await settleLibrary(page);await retainedParent(page,before);
|
||||
expect(await cards(page).count()).toBeLessThan(80);
|
||||
@@ -347,11 +493,10 @@ test('queued row growth preserves newer native wheel movement and correction eve
|
||||
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 loadShelfPages(page,more,3);
|
||||
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}));
|
||||
const pinned=cards(page).first(),pinnedId=await pinned.getAttribute('data-prompt-id');await pinned.locator('button').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);
|
||||
@@ -362,7 +507,7 @@ test('queued row growth preserves newer native wheel movement and correction eve
|
||||
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 page.evaluate(()=>{window.libraryObservation.hold();for(const content of document.querySelectorAll('.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;
|
||||
@@ -378,10 +523,10 @@ test('queued row growth preserves newer native wheel movement and correction eve
|
||||
|
||||
// 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 page.evaluate(()=>{window.libraryObservation.hold();for(const content of document.querySelectorAll('.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();
|
||||
await expect(page.locator(`article[data-prompt-id="${pinnedId}"] button`).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);
|
||||
@@ -411,8 +556,7 @@ test('unchanged row observations settle without resize feedback',async({page,req
|
||||
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);
|
||||
await loadShelfPages(page,more,3);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);
|
||||
@@ -422,7 +566,7 @@ test('unchanged row observations settle without resize feedback',async({page,req
|
||||
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);
|
||||
await page.evaluate(()=>{window.libraryObservation.hold();for(const content of document.querySelectorAll('.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);
|
||||
@@ -436,12 +580,162 @@ test('saved choices retain curved staggered lanes and the full negative immediat
|
||||
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})));
|
||||
const values=await page.locator('.library-history .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']);
|
||||
expect(order).toEqual(['Saved negative variants','Shared gallery','Recorded executions']);
|
||||
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);
|
||||
expect(await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text').evaluate(node=>node.textContent)).toBe(selected);
|
||||
});
|
||||
|
||||
/** Rewrites each shelf item the server sends: run counts, last use and long texts are values the fixture never records. */
|
||||
async function patchShelf(page,patch) {
|
||||
await page.route(url=>url.pathname==='/prompt_manager/library',async route=>{
|
||||
const response=await route.fetch();const body=await response.json();
|
||||
body.items=body.items.map(item=>({...item,...patch(item)}));
|
||||
await route.fulfill({response,json:body});
|
||||
});
|
||||
}
|
||||
/** The footer line as seen: everything but its visually hidden rating sentence. */
|
||||
const shownText=node=>[...node.childNodes].filter(child=>!child.classList?.contains('ca-visually-hidden')).map(child=>child.textContent).join('');
|
||||
const longPrompt=[`${'A luminous alpine observatory with layered glass terraces '.repeat(6)}`,'second paragraph keeps the full text',`final line ${'detail '.repeat(30)}end of prompt`].join('\n');
|
||||
|
||||
test('card matches the concept and never wraps the category mid-word',async({page,request})=>{
|
||||
const before=await(await request.get('/prompt_manager/library')).json();
|
||||
const dog=before.items.find(item=>item.text==='Dog landscape'),cat=before.items.find(item=>item.text==='Cat portrait');
|
||||
const dual=before.items.find(item=>item.text==='Dual role portrait');
|
||||
await expect(await request.post('/prompt_manager/bulk/category',{data:{prompt_ids:[dog.id],category:'asian'}})).toBeOK();
|
||||
await expect(await request.post('/prompt_manager/bulk/category',{data:{prompt_ids:[dual.id],category:''}})).toBeOK();
|
||||
// A long saved title fills the title row, so only the chip's own min-content keeps "Uncategorized" and "asian" whole.
|
||||
const wide='A remarkably long saved positive prompt title that fills the whole card title row';
|
||||
await patchShelf(page,item=>item.id===cat.id?{run_count:4,last_used_at:new Date().toISOString(),rating:5}:item.id===dual.id||item.id===dog.id?{title:wide,run_count:1,last_used_at:null,rating:null}:{});
|
||||
for(const [width,height] of [[1600,1100],[390,844]]) {
|
||||
await page.setViewportSize({width,height});await page.goto(route);await expect(cards(page)).toHaveCount(3);
|
||||
const card=page.locator(`article[data-prompt-id="${cat.id}"]`);
|
||||
// app.js renderLibrary a586: article.prompt-card > .card-art, .card-content, .card-footer.
|
||||
await expect(card).toHaveClass('prompt-card');
|
||||
expect(await card.evaluate(node=>[...node.children].map(child=>child.className))).toEqual(['card-art','card-content','card-footer']);
|
||||
expect(await card.locator('.card-art').evaluate(node=>[...node.children].map(child=>child.tagName))).toEqual(['IMG','IMG']);
|
||||
expect(await card.locator('.card-content').evaluate(node=>[...node.children].map(child=>child.className))).toEqual(['card-title','prompt-inspector','card-meta','tags']);
|
||||
expect(await card.locator('.card-title').evaluate(node=>[...node.children].map(child=>`${child.tagName}.${child.className}`))).toEqual(['BUTTON.','SPAN.chip']);
|
||||
await expect(card.locator('.card-title > button')).toHaveText('Cat portrait');await expect(card.locator('.card-title > .chip')).toHaveText('Animals');
|
||||
await expect(card.locator('.card-title > button')).toHaveCSS('font-size','16px');await expect(card.locator('.card-title > button')).toHaveCSS('font-weight','600');
|
||||
const positive=card.locator('.card-content > .prompt-inspector > section.prompt-section.positive');await expect(positive).toHaveCount(1);
|
||||
await expect(card.locator('section.prompt-section.negative')).toHaveCount(0);
|
||||
await expect(positive.getByRole('button',{name:'Copy positive prompt: Cat portrait',exact:true})).toHaveCount(1);
|
||||
await expect(positive.getByRole('button',{name:'Send positive prompt to ComfyUI: Cat portrait',exact:true})).toHaveCount(1);
|
||||
await expect(card.locator('.card-meta > *')).toHaveText(['− 4 negative variants','4 runs','3 images']);
|
||||
await expect(card.locator('.card-meta > .chip.red')).toHaveCount(1);
|
||||
await expect(card.locator('.tags > .chip')).toHaveText(['cats','studio']);
|
||||
const footer=card.locator('.card-footer');
|
||||
expect(await footer.evaluate(node=>[...node.children].map(child=>`${child.tagName}.${child.className}`))).toEqual(['SPAN.','DIV.row-actions']);
|
||||
await expect(footer.locator('> span > [aria-hidden="true"]')).toHaveText('★★★★★');
|
||||
await expect(footer.locator('> span > .ca-visually-hidden')).toHaveText('Rated 5 out of 5');
|
||||
expect(await footer.locator('> span').evaluate(shownText)).toBe('★★★★★ · Used today');
|
||||
const actions=footer.locator('.row-actions > button');await expect(actions).toHaveCount(2);
|
||||
await expect(actions.first()).toHaveClass('send icon-btn');await expect(actions.first()).toHaveAccessibleName('Send positive prompt to ComfyUI: Cat portrait');
|
||||
await expect(actions.nth(1)).toHaveAccessibleName('Explore history');await expect(actions.nth(1).locator('svg')).toHaveCount(1);
|
||||
// Unrated and never used: the used label alone; no negatives still reads as a count.
|
||||
const unrated=page.locator(`article[data-prompt-id="${dual.id}"]`);
|
||||
expect(await unrated.locator('.card-footer > span').evaluate(shownText)).toBe('Not used yet');
|
||||
await expect(unrated.locator('.card-footer > span > [aria-hidden="true"]')).toHaveCount(0);
|
||||
await expect(unrated.locator('.card-meta > *')).toHaveText([`− ${dual.variant_count} negative variant${dual.variant_count===1?'':'s'}`,'1 run',`${dual.image_count} image${dual.image_count===1?'':'s'}`]);
|
||||
for(const [id,text] of [[dual.id,'Uncategorized'],[dog.id,'asian']]) {
|
||||
const chip=page.locator(`article[data-prompt-id="${id}"] .card-title > .chip`);await expect(chip).toHaveText(text);
|
||||
expect(await chip.evaluate(node=>{const range=document.createRange();range.selectNodeContents(node);return range.getClientRects().length;}),`${text} at ${width}`).toBe(1);
|
||||
expect(await page.locator(`article[data-prompt-id="${id}"] .card-title`).evaluate(node=>node.scrollWidth<=node.clientWidth+1)).toBe(true);
|
||||
}
|
||||
expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
test('long prompt is clamped to two lines while copy sends the full text',async({page,context,request})=>{
|
||||
const before=await(await request.get('/prompt_manager/library')).json();const cat=before.items.find(item=>item.text==='Cat portrait');
|
||||
await page.addInitScript(()=>{window.copied=[];Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>{window.copied.push(text);}}});});
|
||||
await patchShelf(page,item=>item.id===cat.id?{text:longPrompt,title:'Long observatory'}:{});
|
||||
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:'positive-1',label:'Card destination',type:'PromptManager',roles:['positive']};
|
||||
window.cardApplied=[];
|
||||
window.cardHost=createBridgeHost({origin:location.origin,namespace:'/',label:'Card 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}},snapshot:roles}),
|
||||
apply:roles=>{window.cardApplied.push(roles);return{targetId:target.id,values:roles};},
|
||||
}});
|
||||
});
|
||||
try {
|
||||
for(const [width,height] of [[1600,1100],[390,844]]) {
|
||||
await page.setViewportSize({width,height});await page.goto(route);
|
||||
const card=page.locator(`article[data-prompt-id="${cat.id}"]`),text=card.locator('.prompt-text');
|
||||
await expect(text).toHaveText(longPrompt);
|
||||
const box=await text.evaluate(node=>{const style=getComputedStyle(node);return {lines:Math.round(node.clientHeight/parseFloat(style.lineHeight)),clipped:node.scrollHeight>node.clientHeight+1,clamp:style.webkitLineClamp,overflow:style.overflowY};});
|
||||
expect(box).toEqual({lines:2,clipped:true,clamp:'2',overflow:'hidden'});
|
||||
}
|
||||
const card=page.locator(`article[data-prompt-id="${cat.id}"]`);
|
||||
await card.getByRole('button',{name:'Copy positive prompt: Long observatory',exact:true}).click();
|
||||
await expect.poll(()=>page.evaluate(()=>window.copied)).toEqual([longPrompt]);
|
||||
await card.locator('.card-footer .row-actions > button.send').click();
|
||||
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
|
||||
await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'Card ComfyUI'});
|
||||
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'Card destination'});
|
||||
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.cardApplied)).toEqual([{positive:longPrompt}]);
|
||||
await sender.getByRole('button',{name:'Close',exact:true}).click();
|
||||
} finally {await host.evaluate(()=>window.cardHost.dispose());await host.close();}
|
||||
});
|
||||
|
||||
test('title and Explore history are buttons that open history',async({page,request})=>{
|
||||
const rows=await(await request.get('/prompt_manager/library')).json();const cat=rows.items.find(item=>item.text==='Cat portrait');
|
||||
await page.goto(route);const card=page.locator(`article[data-prompt-id="${cat.id}"]`);
|
||||
await expect(card.locator('a')).toHaveCount(0);
|
||||
const title=card.locator('.card-title > button');await expect(title).toHaveAttribute('type','button');
|
||||
await title.click();await expect(page).toHaveURL(new RegExp(`#/library/${cat.id}$`));await expect(history(page)).toBeVisible();
|
||||
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();await expect(cards(page)).toHaveCount(3);
|
||||
const explore=page.locator(`article[data-prompt-id="${cat.id}"]`).getByRole('button',{name:'Explore history',exact:true});
|
||||
await explore.focus();await page.keyboard.press('Enter');await expect(page).toHaveURL(new RegExp(`#/library/${cat.id}$`));await expect(history(page)).toBeVisible();
|
||||
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
|
||||
// The card's navigation buttons keep the shelf's Home/End and arrow movement between cards.
|
||||
const first=cards(page).first();await first.locator('.card-title > button').focus();await page.keyboard.press('End');
|
||||
await expect(cards(page).last().locator('.card-title > button')).toBeFocused();
|
||||
await page.keyboard.press('ArrowLeft');await expect(cards(page).nth(1).locator('.card-title > button')).toBeFocused();
|
||||
});
|
||||
|
||||
test('settling fails with the observation stats when a Library frame stays queued',async({page,request})=>{
|
||||
await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:200}});
|
||||
await observeLibrary(page);await page.addInitScript(()=>{window.IntersectionObserver=undefined;});
|
||||
await page.goto(route);await expect(cards(page).first()).toBeVisible();await settleLibrary(page);
|
||||
await page.evaluate(()=>{window.libraryObservation.holdFrames();dispatchEvent(new Event('scroll'));});
|
||||
await expect.poll(()=>page.evaluate(()=>window.libraryObservation.stats().heldFrames)).toBeGreaterThan(0);
|
||||
const failure=await page.evaluate(()=>window.libraryObservation.settled(30).then(()=>'settled',error=>error.message));
|
||||
expect(failure).toMatch(/^Library never settled within 30 frames: \{.*"heldFrames":[1-9]/);
|
||||
await page.evaluate(()=>window.libraryObservation.releaseFrames());await settleLibrary(page);
|
||||
});
|
||||
|
||||
test('virtualized rows keep measured heights with the clamp',async({page,request,isMobile})=>{
|
||||
await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:200}});
|
||||
await observeLibrary(page);await page.addInitScript(()=>{window.IntersectionObserver=undefined;});
|
||||
await patchShelf(page,item=>({text:`${item.text}\n${longPrompt}`}));
|
||||
// Two columns on a desktop window; the touch project's phone gives the one-column shelf.
|
||||
{
|
||||
if(!isMobile)await page.setViewportSize({width:1600,height:1100});await page.goto(route);
|
||||
const more=page.getByRole('button',{name:'Load more prompts',exact:true});
|
||||
await loadShelfPages(page,more,3);
|
||||
await page.evaluate(()=>scrollTo(0,document.querySelector('.library-grid').getBoundingClientRect().top+scrollY+3000));await settleLibrary(page);
|
||||
const geometry=await page.evaluate(()=>[...document.querySelector('.library-grid').children].map(node=>{
|
||||
const box=node.getBoundingClientRect();
|
||||
return {row:node.classList.contains('library-row'),top:box.top,height:box.height,
|
||||
cards:[...node.querySelectorAll('.prompt-card')].map(card=>({height:card.getBoundingClientRect().height,text:card.querySelector('.prompt-text').clientHeight,clipped:card.querySelector('.prompt-text').scrollHeight>card.querySelector('.prompt-text').clientHeight+1}))};
|
||||
}));
|
||||
const rows=geometry.filter(item=>item.row);expect(rows.length).toBeGreaterThan(2);expect(rows.length).toBeLessThan(60);
|
||||
for(const row of rows) {
|
||||
expect(row.cards.length).toBeGreaterThan(0);
|
||||
for(const card of row.cards) {expect(card.text).toBe(42);expect(card.clipped).toBe(true);expect(Math.abs(card.height-row.height)).toBeLessThanOrEqual(1);}
|
||||
}
|
||||
// Consecutive mounted rows sit one concept gap apart: every height the layout used is the measured one.
|
||||
for(let index=1;index<geometry.length;index++) if(geometry[index].row&&geometry[index-1].row) expect(Math.abs(geometry[index].top-(geometry[index-1].top+geometry[index-1].height+18))).toBeLessThanOrEqual(1);
|
||||
const anchor=await firstVisibleParent(page);await page.evaluate(()=>window.libraryObservation.equal());await settleLibrary(page);await retainedParent(page,anchor);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,546 @@
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
|
||||
// Saved views (#249 D16, maintainer ruling 2026-10-21): the toolbar's Save view (concept app.js a359, a1330–1346),
|
||||
// the sidebar SAVED VIEWS rows (a331–335) and `#/saved/<id>` (a1158–1162), on Images and the Prompt library.
|
||||
const workspace = '/prompt_manager/workspace';
|
||||
const API = '/prompt_manager/saved-views';
|
||||
const main = page => page.locator('#workspace-main');
|
||||
const toolbar = page => main(page).locator('search.toolbar');
|
||||
const search = page => page.getByRole('searchbox', { name: 'Search workspace', exact: true });
|
||||
const sortBy = page => page.getByRole('combobox', { name: 'Sort results', exact: true });
|
||||
const filtersButton = page => toolbar(page).getByRole('button', { name: /^Filters(?: · \d+)?$/ });
|
||||
const imageCards = page => page.locator('article[data-image-key]');
|
||||
const promptCards = page => page.locator('article[data-prompt-id]');
|
||||
const savedNav = page => page.getByRole('navigation', { name: 'Saved views', exact: true });
|
||||
// The saved views' own rows, after the Favorites row (D51).
|
||||
const viewRows = page => savedNav(page).locator('a[data-route^="saved/"]');
|
||||
const favoritesRow = page => savedNav(page).locator('a[data-route="favorites"]');
|
||||
const favoritesCount = page => favoritesRow(page).locator('.nav-count');
|
||||
const editDialog = page => page.getByRole('dialog', { name: 'Edit saved view', exact: true });
|
||||
const deleteDialog = page => page.getByRole('dialog', { name: 'Delete saved view', exact: true });
|
||||
const heading = page => main(page).getByRole('heading', { level: 1 });
|
||||
const saveDialog = page => page.getByRole('dialog', { name: 'Save this view', exact: true });
|
||||
const refineDialog = page => page.getByRole('dialog', { name: 'Refine this view', exact: true });
|
||||
const toast = page => page.locator('#workspace-toast');
|
||||
const IMAGE_DEFAULTS = { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false };
|
||||
|
||||
async function refine(page, values) {
|
||||
await filtersButton(page).click();
|
||||
for (const [label, value] of Object.entries(values)) {
|
||||
const control = refineDialog(page).getByLabel(label, { exact: true });
|
||||
if (label === 'Tags') await control.fill(value);
|
||||
else await control.selectOption(value);
|
||||
}
|
||||
await refineDialog(page).getByRole('button', { name: 'Apply filters', exact: true }).click();
|
||||
await expect(refineDialog(page)).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function listed(request) {
|
||||
const response = await request.get(API);
|
||||
await expect(response).toBeOK();
|
||||
return (await response.json()).views;
|
||||
}
|
||||
|
||||
async function create(request, data) {
|
||||
const response = await request.post(API, { data });
|
||||
expect(response.status()).toBe(201);
|
||||
return (await response.json()).view;
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page, request, browserName }) => {
|
||||
await startCoverage(page, browserName);
|
||||
await expect(await request.post('/__test__/reset')).toBeOK();
|
||||
page.savedErrors = [];
|
||||
page.on('pageerror', error => page.savedErrors.push(error.message));
|
||||
// D51: deleting asks inside the dialog; no browser dialog (window.confirm) may ever open.
|
||||
page.on('dialog', dialog => { page.savedErrors.push(`browser ${dialog.type()}`); dialog.dismiss().catch(() => {}); });
|
||||
// No listing read may fail on a saved view's values (an unknown Source would be a 400).
|
||||
page.on('response', response => {
|
||||
if (response.status() >= 400 && new URL(response.url()).pathname === '/prompt_manager/images/output') {
|
||||
page.savedErrors.push(`${response.status()} ${response.url()}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page, browserName }) => {
|
||||
await saveCoverage(page, browserName);
|
||||
expect(page.savedErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test('Save view saves the Images toolbar and Refine filters, and its sidebar row reopens them', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(imageCards(page)).toHaveCount(2);
|
||||
// The concept's sidebar group: SAVED VIEWS without its PROPOSED badge (D49), empty until a view is saved.
|
||||
await expect(page.locator('aside.sidebar .sidebar-label')).toHaveText(['WORKSPACE', 'SAVED VIEWS']);
|
||||
await expect(viewRows(page)).toHaveCount(0);
|
||||
|
||||
await search(page).fill('dogs');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
await sortBy(page).selectOption('name');
|
||||
await refine(page, { Source: '1', Media: 'image' });
|
||||
await expect(filtersButton(page)).toHaveText('Filters · 2');
|
||||
const saveButton = toolbar(page).getByRole('button', { name: 'Save view', exact: true });
|
||||
await saveButton.click();
|
||||
// The concept's simpleInput: "View name", the search's default name, Cancel and the primary action.
|
||||
const dialog = saveDialog(page);
|
||||
await expect(dialog.getByRole('textbox', { name: 'View name', exact: true })).toHaveValue('Search: dogs');
|
||||
await expect(dialog.getByRole('textbox', { name: 'View name', exact: true })).toBeFocused();
|
||||
await expect(dialog.locator('.dialog-actions button')).toHaveText(['Cancel', 'Save']);
|
||||
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(saveButton).toBeFocused();
|
||||
expect(await listed(request)).toEqual([]);
|
||||
|
||||
await saveButton.click();
|
||||
await dialog.getByRole('textbox', { name: 'View name', exact: true }).fill('Dog shots');
|
||||
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('Saved view added to the sidebar.');
|
||||
const [view] = await listed(request);
|
||||
expect(view).toMatchObject({ page: 'images', name: 'Dog shots',
|
||||
state: { ...IMAGE_DEFAULTS, q: 'dogs', sort: 'name', source: '1', media: 'image' } });
|
||||
// The concept's links() row: the search icon, then the name, and no count.
|
||||
const row = savedNav(page).getByRole('link', { name: 'Dog shots', exact: true });
|
||||
await expect(row).toHaveAttribute('href', `#/saved/${view.id}`);
|
||||
expect(await row.evaluate(link => [...link.childNodes].map(node => node.nodeType === Node.TEXT_NODE ? `text:${node.textContent}`
|
||||
: `${node.localName}.${node.getAttribute('class')}`))).toEqual(['svg.icon', 'text:Dog shots']);
|
||||
await expect(row).not.toHaveAttribute('aria-current', 'page');
|
||||
|
||||
// Another page, then the saved row: its page opens with every saved value and the Filters count (D44).
|
||||
await page.getByRole('navigation', { name: 'Workspace', exact: true }).getByRole('link', { name: /^Prompt library/ }).click();
|
||||
await expect(promptCards(page).first()).toBeVisible();
|
||||
await row.click();
|
||||
await expect(page).toHaveURL(new RegExp(`#/saved/${view.id}$`));
|
||||
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Dog shots');
|
||||
await expect(main(page).getByRole('heading', { level: 1 })).toBeFocused();
|
||||
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Dog shots');
|
||||
await expect(page).toHaveTitle('Dog shots · Prompt Manager');
|
||||
await expect(row).toHaveAttribute('aria-current', 'page');
|
||||
await expect(page.locator('aside.sidebar nav a[aria-current]')).toHaveCount(1);
|
||||
await expect(search(page)).toHaveValue('dogs');
|
||||
await expect(sortBy(page)).toHaveValue('name');
|
||||
await expect(filtersButton(page)).toHaveText('Filters · 2');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
await expect(imageCards(page)).toHaveAttribute('data-image-key', '1:shared/same.png');
|
||||
await filtersButton(page).click();
|
||||
await expect(refineDialog(page).getByLabel('Source', { exact: true })).toHaveValue('1');
|
||||
await expect(refineDialog(page).getByLabel('Media', { exact: true })).toHaveValue('image');
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// A reload opens the same view from its link.
|
||||
await page.reload();
|
||||
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Dog shots');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
await expect(savedNav(page).getByRole('link', { name: 'Dog shots', exact: true })).toHaveAttribute('aria-current', 'page');
|
||||
});
|
||||
|
||||
test('an empty name is Saved view, and a Library view reopens the shelf filters', async ({ page, request }) => {
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(imageCards(page)).toHaveCount(2);
|
||||
await toolbar(page).getByRole('button', { name: 'Save view', exact: true }).click();
|
||||
const name = saveDialog(page).getByRole('textbox', { name: 'View name', exact: true });
|
||||
await expect(name).toHaveValue('My workspace view');
|
||||
await name.fill(' ');
|
||||
await saveDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(viewRows(page)).toHaveText(['Saved view']);
|
||||
expect((await listed(request))[0]).toMatchObject({ page: 'images', name: 'Saved view', state: IMAGE_DEFAULTS });
|
||||
|
||||
await page.goto(`${workspace}#/library`);
|
||||
await expect(promptCards(page)).toHaveCount(2);
|
||||
await search(page).fill('dog');
|
||||
await expect(promptCards(page)).toHaveCount(1);
|
||||
await refine(page, { Tags: 'dogs' });
|
||||
await expect(filtersButton(page)).toHaveText('Filters · 1');
|
||||
await toolbar(page).getByRole('button', { name: 'Save view', exact: true }).click();
|
||||
await expect(name).toHaveValue('Search: dog');
|
||||
await saveDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(viewRows(page)).toHaveText(['Saved view', 'Search: dog']);
|
||||
const library = (await listed(request))[1];
|
||||
expect(library).toMatchObject({ page: 'library', name: 'Search: dog', state: { q: 'dog', category: '', sort: 'newest', tags: ['dogs'] } });
|
||||
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(imageCards(page)).toHaveCount(2);
|
||||
await savedNav(page).getByRole('link', { name: 'Search: dog', exact: true }).click();
|
||||
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Search: dog');
|
||||
await expect(search(page)).toHaveValue('dog');
|
||||
await expect(filtersButton(page)).toHaveText('Filters · 1');
|
||||
await expect(promptCards(page)).toHaveCount(1);
|
||||
// Saved views open the page; one saved view to another re-renders with the other's state.
|
||||
await savedNav(page).getByRole('link', { name: 'Saved view', exact: true }).click();
|
||||
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Saved view');
|
||||
await expect(search(page)).toHaveValue('');
|
||||
await expect(imageCards(page)).toHaveCount(2);
|
||||
});
|
||||
|
||||
test('a saved value the page no longer accepts stays default while the rest applies', async ({ page, request }) => {
|
||||
const view = await create(request, { page: 'images', name: 'Gone source',
|
||||
state: { ...IMAGE_DEFAULTS, q: 'cat', sort: 'name-desc', source: '7', folder: 'missing/folder' } });
|
||||
await page.goto(`${workspace}#/saved/${view.id}`);
|
||||
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Gone source');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
await expect(imageCards(page)).toHaveAttribute('data-image-key', '0:shared/same.png');
|
||||
await expect(search(page)).toHaveValue('cat');
|
||||
await expect(sortBy(page)).toHaveValue('name-desc');
|
||||
// The missing Source and Folder are dropped, so no refinement is active.
|
||||
await expect(filtersButton(page)).toHaveText('Filters');
|
||||
await expect(main(page).locator('.filter-row > span.chip')).toHaveText('All output folders');
|
||||
|
||||
// A Source that does exist, with a folder it lists, applies.
|
||||
const kept = await create(request, { page: 'images', name: 'Input shared',
|
||||
state: { ...IMAGE_DEFAULTS, source: '1', folder: 'shared' } });
|
||||
await page.goto(`${workspace}#/saved/${kept.id}`);
|
||||
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Input shared');
|
||||
await expect(filtersButton(page)).toHaveText('Filters · 2');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
await expect(imageCards(page)).toHaveAttribute('data-image-key', '1:shared/same.png');
|
||||
});
|
||||
|
||||
test('a view stored under older rules still lists and opens with its usable values (#249 I1)', async ({ page, request }) => {
|
||||
// No favorites key, a retired key and a sort that is no longer offered, as an older build stored it.
|
||||
const raw = async data => {
|
||||
const response = await request.post('/__test__/saved-views/raw', { data });
|
||||
await expect(response).toBeOK();
|
||||
return (await response.json()).id;
|
||||
};
|
||||
const imagesId = await raw({ page: 'images', name: 'Old cats', state: JSON.stringify({ q: 'cat', sort: 'shuffle', layout: 'grid' }) });
|
||||
await raw({ page: 'library', name: '', state: JSON.stringify({ q: 'dog', sort: 'name-desc' }) });
|
||||
expect(await listed(request)).toEqual([
|
||||
{ id: imagesId, page: 'images', name: 'Old cats', state: { ...IMAGE_DEFAULTS, q: 'cat' } },
|
||||
expect.objectContaining({ page: 'library', name: 'Saved view', state: { q: 'dog', category: '', sort: 'newest', tags: [] } }),
|
||||
]);
|
||||
// A state that is no JSON object is never listed, and its link behaves like a missing view.
|
||||
const corruptId = await raw({ page: 'images', name: 'Corrupt', state: '{not json' });
|
||||
expect((await listed(request)).map(view => view.id)).not.toContain(corruptId);
|
||||
await page.goto(`${workspace}#/saved/${corruptId}`);
|
||||
await expect(page).toHaveURL(/#\/images$/);
|
||||
await expect(heading(page)).toHaveText('Images');
|
||||
await expect(viewRows(page)).toHaveText(['Old cats', 'Saved view']);
|
||||
|
||||
await page.goto(`${workspace}#/saved/${imagesId}`);
|
||||
await expect(heading(page)).toHaveText('Old cats');
|
||||
await expect(viewRows(page)).toHaveText(['Old cats', 'Saved view']);
|
||||
await expect(search(page)).toHaveValue('cat');
|
||||
await expect(sortBy(page)).toHaveValue('newest');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
await savedNav(page).getByRole('link', { name: 'Saved view', exact: true }).click();
|
||||
await expect(heading(page)).toHaveText('Saved view');
|
||||
await expect(search(page)).toHaveValue('dog');
|
||||
await expect(promptCards(page)).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('leaving a saved view returns Images and the Library to their own last state (#249 M2)', async ({ page, request }) => {
|
||||
const dogs = await create(request, { page: 'images', name: 'Dogs', state: { ...IMAGE_DEFAULTS, q: 'dogs' } });
|
||||
const cats = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [] } });
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await search(page).fill('cat');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
await viewRows(page).filter({ hasText: 'Dogs' }).click();
|
||||
await expect(heading(page)).toHaveText('Dogs');
|
||||
await expect(search(page)).toHaveValue('dogs');
|
||||
await page.locator('#primary-nav a[data-route="images"]').click();
|
||||
await expect(heading(page)).toHaveText('Images');
|
||||
await expect(search(page)).toHaveValue('cat');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
|
||||
await page.locator('#primary-nav a[data-route="library"]').click();
|
||||
await expect(promptCards(page)).toHaveCount(2);
|
||||
await search(page).fill('dog');
|
||||
await expect(promptCards(page)).toHaveCount(1);
|
||||
await page.goto(`${workspace}#/saved/${cats.id}`);
|
||||
await expect(heading(page)).toHaveText('Cats');
|
||||
await expect(search(page)).toHaveValue('cat');
|
||||
await page.locator('#primary-nav a[data-route="library"]').click();
|
||||
await expect(heading(page)).toHaveText('Prompt library');
|
||||
await expect(search(page)).toHaveValue('dog');
|
||||
await expect(promptCards(page)).toHaveCount(1);
|
||||
expect(dogs.id).not.toBe(cats.id);
|
||||
});
|
||||
|
||||
test('a toolbar change while sources load keeps the saved Source and Folder (#249 M3)', async ({ page, request }) => {
|
||||
const view = await create(request, { page: 'images', name: 'Input shared', state: { ...IMAGE_DEFAULTS, source: '1', folder: 'shared' } });
|
||||
let release;
|
||||
const held = new Promise(resolve => { release = resolve; });
|
||||
await page.route('**/prompt_manager/gallery/subfolders?**', async route => { await held; await route.fallback(); });
|
||||
await page.goto(`${workspace}#/saved/${view.id}`);
|
||||
await expect(heading(page)).toHaveText('Input shared');
|
||||
await sortBy(page).selectOption('name');
|
||||
await expect(imageCards(page)).toHaveCount(0);
|
||||
release();
|
||||
await expect(filtersButton(page)).toHaveText('Filters · 2');
|
||||
await expect(sortBy(page)).toHaveValue('name');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
await expect(imageCards(page)).toHaveAttribute('data-image-key', '1:shared/same.png');
|
||||
});
|
||||
|
||||
test('a saved view left while its list or rename is pending leaves the next page alone', async ({ page, request }) => {
|
||||
const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [] } });
|
||||
let releaseList = () => {};
|
||||
const holdList = async () => {
|
||||
const held = new Promise(resolve => { releaseList = resolve; });
|
||||
await page.unroute(`**${API}`);
|
||||
await page.route(`**${API}`, async route => { if (route.request().method() === 'GET') await held; await route.fallback(); });
|
||||
};
|
||||
// Leaving while the shell's first read is pending: the view never mounts over the page left to.
|
||||
await holdList();
|
||||
await page.goto(`${workspace}#/saved/${view.id}`);
|
||||
await page.evaluate(() => { window.location.hash = '#/tags'; });
|
||||
await expect(heading(page)).toHaveText('Tags & categories');
|
||||
releaseList();
|
||||
await expect(viewRows(page)).toHaveText(['Cats']);
|
||||
await expect(heading(page)).toHaveText('Tags & categories');
|
||||
// An unknown id re-reads the list once; leaving during that read keeps the page left to.
|
||||
await holdList();
|
||||
await page.evaluate(() => { window.location.hash = '#/saved/999'; });
|
||||
await page.evaluate(() => { window.location.hash = '#/library'; });
|
||||
await expect(heading(page)).toHaveText('Prompt library');
|
||||
releaseList();
|
||||
await expect(viewRows(page)).toHaveText(['Cats']);
|
||||
await expect(page).toHaveURL(/#\/library$/);
|
||||
await expect(heading(page)).toHaveText('Prompt library');
|
||||
await page.unroute(`**${API}`);
|
||||
|
||||
// A rename that lands after the view was left does not retitle the next page.
|
||||
let releaseRename;
|
||||
const renameHeld = new Promise(resolve => { releaseRename = resolve; });
|
||||
await page.route(`**${API}/${view.id}`, async route => { await renameHeld; await route.fallback(); });
|
||||
await page.goto(`${workspace}#/saved/${view.id}`);
|
||||
await expect(heading(page)).toHaveText('Cats');
|
||||
await main(page).locator('.heading-actions > button').first().click();
|
||||
await editDialog(page).getByRole('textbox', { name: 'View name', exact: true }).fill('Kittens');
|
||||
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await page.locator('#primary-nav a[data-route="images"]').click();
|
||||
await expect(heading(page)).toHaveText('Images');
|
||||
releaseRename();
|
||||
await expect(viewRows(page)).toHaveText(['Kittens']);
|
||||
await expect(heading(page)).toHaveText('Images');
|
||||
await expect(page).toHaveTitle('Images · Prompt Manager');
|
||||
});
|
||||
|
||||
test('an unknown saved view opens Images, and a failed listing offers Retry', async ({ page, request }) => {
|
||||
await page.goto(`${workspace}#/saved/999`);
|
||||
await expect(page).toHaveURL(/#\/images$/);
|
||||
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Images');
|
||||
|
||||
const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [] } });
|
||||
let failing = true;
|
||||
await page.route(`**${API}`, route => failing && route.request().method() === 'GET'
|
||||
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"down"}' })
|
||||
: route.fallback());
|
||||
await page.goto(`${workspace}#/saved/${view.id}`);
|
||||
await expect(main(page).getByRole('alert')).toHaveText('Saved views could not be loaded. Your saved data has not changed.');
|
||||
await expect(viewRows(page)).toHaveCount(0);
|
||||
failing = false;
|
||||
await main(page).getByRole('button', { name: 'Retry', exact: true }).click();
|
||||
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Cats');
|
||||
await expect(search(page)).toHaveValue('cat');
|
||||
await expect(promptCards(page)).toHaveCount(1);
|
||||
await expect(viewRows(page)).toHaveText(['Cats']);
|
||||
});
|
||||
|
||||
test('a failed save says so and adds nothing', async ({ page, request }) => {
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(imageCards(page)).toHaveCount(2);
|
||||
await page.route(`**${API}`, route => route.request().method() === 'POST'
|
||||
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"Saved view operation failed"}' })
|
||||
: route.fallback());
|
||||
await toolbar(page).getByRole('button', { name: 'Save view', exact: true }).click();
|
||||
await saveDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('The view could not be saved. Try again.');
|
||||
await expect(viewRows(page)).toHaveCount(0);
|
||||
expect(await listed(request)).toEqual([]);
|
||||
});
|
||||
|
||||
async function star(page, key) {
|
||||
await page.locator(`article[data-image-key="${key}"] .star-button`).click();
|
||||
}
|
||||
|
||||
test('Favorites is the first SAVED VIEWS row, with a real count that follows every star', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
let release;
|
||||
const held = new Promise(resolve => { release = resolve; });
|
||||
// Hidden until its read settles: never a placeholder "0".
|
||||
await page.route(url => url.pathname.endsWith('/prompt_manager/images/output') && url.searchParams.get('favorites') === '1'
|
||||
&& url.searchParams.get('limit') === '1', async route => { await held; await route.fallback(); }, { times: 1 });
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(imageCards(page)).toHaveCount(2);
|
||||
await expect(favoritesRow(page)).toHaveAttribute('href', '#/favorites');
|
||||
await expect(favoritesCount(page)).toHaveCount(0);
|
||||
release();
|
||||
// The concept's row (app.js a332): the star icon, "Favorites", then the count.
|
||||
await expect(favoritesCount(page)).toHaveText('0');
|
||||
expect(await favoritesRow(page).evaluate(link => [...link.childNodes].map(node => node.nodeType === Node.TEXT_NODE
|
||||
? `text:${node.textContent}` : `${node.localName}.${node.getAttribute('class')}`)))
|
||||
.toEqual(['svg.icon', 'text:Favorites', 'span.nav-count']);
|
||||
expect(await savedNav(page).locator('a').evaluateAll(links => links.map(link => link.dataset.route))).toEqual(['favorites']);
|
||||
|
||||
await star(page, '1:shared/same.png');
|
||||
await expect(toast(page)).toHaveText('Added to favorites.');
|
||||
await expect(favoritesCount(page)).toHaveText('1');
|
||||
|
||||
// #/favorites: Images filtered to favorites, named Favorites (concept renderImages a417).
|
||||
const listings = [];
|
||||
page.on('request', item => {
|
||||
const url = new URL(item.url());
|
||||
if (url.pathname.endsWith('/prompt_manager/images/output') && url.searchParams.get('limit') !== '1') listings.push(url.searchParams);
|
||||
});
|
||||
await favoritesRow(page).click();
|
||||
await expect(page).toHaveURL(/#\/favorites$/);
|
||||
await expect(heading(page)).toHaveText('Favorites');
|
||||
await expect(heading(page)).toBeFocused();
|
||||
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Favorites');
|
||||
await expect(page).toHaveTitle('Favorites · Prompt Manager');
|
||||
await expect(favoritesRow(page)).toHaveAttribute('aria-current', 'page');
|
||||
await expect(page.locator('aside.sidebar nav a[aria-current]')).toHaveCount(1);
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
await expect(imageCards(page)).toHaveAttribute('data-image-key', '1:shared/same.png');
|
||||
await expect(main(page).locator('#result-count')).toHaveText(/^1 of 1 image/);
|
||||
expect(listings.at(-1).get('favorites')).toBe('1');
|
||||
|
||||
// Unstarring on Favorites keeps the card (with ☆) so it can be undone in place; the count follows.
|
||||
await star(page, '1:shared/same.png');
|
||||
await expect(toast(page)).toHaveText('Removed from favorites.');
|
||||
await expect(imageCards(page).locator('.star-button')).toHaveAttribute('aria-pressed', 'false');
|
||||
await expect(favoritesCount(page)).toHaveText('0');
|
||||
await star(page, '1:shared/same.png');
|
||||
await expect(favoritesCount(page)).toHaveText('1');
|
||||
|
||||
// Favorites never replaces the Images return state: Images still lists everything.
|
||||
await page.getByRole('navigation', { name: 'Workspace', exact: true }).getByRole('link', { name: /^Images/ }).click();
|
||||
await expect(heading(page)).toHaveText('Images');
|
||||
await expect(imageCards(page)).toHaveCount(2);
|
||||
await page.goto(`${workspace}#/favorites`);
|
||||
await expect(heading(page)).toHaveText('Favorites');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('a failed Favorites count shows no badge', async ({ page }) => {
|
||||
await page.route(url => url.pathname.endsWith('/prompt_manager/images/output') && url.searchParams.get('favorites') === '1'
|
||||
&& url.searchParams.get('limit') === '1', route => route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } }));
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(page.locator('#primary-nav a[data-route="images"] .nav-count')).toHaveText('2');
|
||||
await expect(favoritesRow(page)).toBeVisible();
|
||||
await expect(favoritesCount(page)).toHaveCount(0);
|
||||
// The refused count read is this test's own; nothing else failed.
|
||||
page.savedErrors = page.savedErrors.filter(error => !/^503 .*favorites=1/.test(error));
|
||||
});
|
||||
|
||||
test('Save view on Favorites reopens the favorites', async ({ page, request }) => {
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(imageCards(page)).toHaveCount(2);
|
||||
await star(page, '0:shared/same.png');
|
||||
await expect(favoritesCount(page)).toHaveText('1');
|
||||
await page.goto(`${workspace}#/favorites`);
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
await toolbar(page).getByRole('button', { name: 'Save view', exact: true }).click();
|
||||
await saveDialog(page).getByRole('textbox', { name: 'View name', exact: true }).fill('Best');
|
||||
await saveDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('Saved view added to the sidebar.');
|
||||
const [view] = await listed(request);
|
||||
expect(view).toMatchObject({ page: 'images', name: 'Best', state: { ...IMAGE_DEFAULTS, favorites: true } });
|
||||
await expect(viewRows(page)).toHaveText(['Best']);
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(imageCards(page)).toHaveCount(2);
|
||||
await viewRows(page).click();
|
||||
await expect(heading(page)).toHaveText('Best');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
await expect(imageCards(page)).toHaveAttribute('data-image-key', '0:shared/same.png');
|
||||
});
|
||||
|
||||
test('Edit renames an open view, and deletes it only after confirming inside the dialog', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
const view = await create(request, { page: 'images', name: 'Dogs', state: { ...IMAGE_DEFAULTS, q: 'dogs' } });
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(imageCards(page)).toHaveCount(2);
|
||||
// Plain pages have no Edit.
|
||||
await expect(main(page).locator('.heading-actions').getByRole('button', { name: 'Edit', exact: true })).toHaveCount(0);
|
||||
await viewRows(page).click();
|
||||
await expect(heading(page)).toHaveText('Dogs');
|
||||
// D51: Edit leads the heading's actions of an open view.
|
||||
const edit = main(page).locator('.heading-actions > button').first();
|
||||
await expect(edit).toHaveText('Edit');
|
||||
await edit.click();
|
||||
const dialog = editDialog(page);
|
||||
const name = dialog.getByRole('textbox', { name: 'View name', exact: true });
|
||||
await expect(name).toHaveValue('Dogs');
|
||||
await expect(name).toBeFocused();
|
||||
await expect(dialog.locator('.dialog-actions button')).toHaveText(['Delete…', 'Cancel', 'Save']);
|
||||
await expect(dialog.getByRole('button', { name: 'Delete…', exact: true })).toHaveClass(/\bdanger\b/);
|
||||
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(edit).toBeFocused();
|
||||
|
||||
// An empty name keeps the dialog open on its field.
|
||||
await edit.click();
|
||||
await name.fill(' ');
|
||||
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(name).toBeFocused();
|
||||
await name.fill('Good dogs');
|
||||
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('Saved view renamed.');
|
||||
await expect(viewRows(page)).toHaveText(['Good dogs']);
|
||||
await expect(heading(page)).toHaveText('Good dogs');
|
||||
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Good dogs');
|
||||
await expect(page).toHaveTitle('Good dogs · Prompt Manager');
|
||||
await expect(edit).toBeFocused();
|
||||
expect(await listed(request)).toEqual([{ ...view, name: 'Good dogs' }]);
|
||||
// The rename kept the view's page state.
|
||||
await expect(search(page)).toHaveValue('dogs');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
|
||||
// Delete… asks first; its Cancel deletes nothing.
|
||||
await edit.click();
|
||||
await dialog.getByRole('button', { name: 'Delete…', exact: true }).click();
|
||||
const confirm = deleteDialog(page);
|
||||
await expect(confirm).toContainText('Delete “Good dogs”? Only the saved view is removed; its images and prompts stay.');
|
||||
await expect(confirm.locator('.dialog-actions button')).toHaveText(['Cancel', 'Delete']);
|
||||
await expect(confirm.getByRole('button', { name: 'Cancel', exact: true })).toBeFocused();
|
||||
await confirm.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(confirm).toHaveCount(0);
|
||||
expect(await listed(request)).toHaveLength(1);
|
||||
|
||||
await edit.click();
|
||||
await dialog.getByRole('button', { name: 'Delete…', exact: true }).click();
|
||||
await confirm.getByRole('button', { name: 'Delete', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('Saved view deleted.');
|
||||
await expect(page).toHaveURL(/#\/images$/);
|
||||
await expect(heading(page)).toHaveText('Images');
|
||||
await expect(viewRows(page)).toHaveCount(0);
|
||||
expect(await listed(request)).toEqual([]);
|
||||
// The dead link left no history entry.
|
||||
await page.goBack();
|
||||
await expect(page).not.toHaveURL(new RegExp(`#/saved/${view.id}$`));
|
||||
});
|
||||
|
||||
test('a deleted Library view returns to the Library, and failed edits say so', async ({ page, request }) => {
|
||||
const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [] } });
|
||||
await page.goto(`${workspace}#/saved/${view.id}`);
|
||||
await expect(heading(page)).toHaveText('Cats');
|
||||
const edit = main(page).locator('.heading-actions > button').first();
|
||||
await expect(edit).toHaveText('Edit');
|
||||
let failing = true;
|
||||
await page.route(`**${API}/${view.id}`, route => failing
|
||||
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"Saved view operation failed"}' })
|
||||
: route.fallback());
|
||||
await edit.click();
|
||||
await editDialog(page).getByRole('textbox', { name: 'View name', exact: true }).fill('Kittens');
|
||||
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('The view could not be renamed. Try again.');
|
||||
await expect(heading(page)).toHaveText('Cats');
|
||||
await expect(viewRows(page)).toHaveText(['Cats']);
|
||||
await edit.click();
|
||||
await editDialog(page).getByRole('button', { name: 'Delete…', exact: true }).click();
|
||||
await deleteDialog(page).getByRole('button', { name: 'Delete', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('The view could not be deleted. Try again.');
|
||||
await expect(page).toHaveURL(new RegExp(`#/saved/${view.id}$`));
|
||||
expect(await listed(request)).toHaveLength(1);
|
||||
|
||||
failing = false;
|
||||
await edit.click();
|
||||
await editDialog(page).getByRole('button', { name: 'Delete…', exact: true }).click();
|
||||
await deleteDialog(page).getByRole('button', { name: 'Delete', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('Saved view deleted.');
|
||||
await expect(page).toHaveURL(/#\/library$/);
|
||||
await expect(heading(page)).toHaveText('Prompt library');
|
||||
await expect(viewRows(page)).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
// The Tag image dialog's capture lifetime when the page or the dialog goes away mid-flight (#249 PR C final review I4,
|
||||
// I5), run on all four projects: leaving the page with the dialog open releases the capture (a `keepalive` DELETE sent
|
||||
// on `pagehide`, which also closes the dialog), and closing the dialog while its save is in flight keeps the capture
|
||||
// until the save settles, then still reports the save. The fixture is tests/browser/workspace_tagging_fixture.py.
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
|
||||
const images = '/prompt_manager/workspace#/images';
|
||||
const FIRST = 'tagging/dunes_00001.png';
|
||||
const TAGS_URL = '**/prompt_manager/image-annotations/tags';
|
||||
const dialog = page => page.getByRole('dialog', { name: 'Tag image', exact: true });
|
||||
const manual = page => dialog(page).getByRole('textbox', { name: 'Image tags · edit manually', exact: true });
|
||||
const tagButton = page => page.locator(`article[data-image-key="0:${FIRST}"]`).getByRole('button', { name: 'Tag or auto-tag dunes_00001.png', exact: true });
|
||||
const openCaptures = async request => (await (await request.get('/__test__/tagging/captures')).json()).captures;
|
||||
|
||||
test.beforeEach(async ({ page, request, browserName }) => {
|
||||
await startCoverage(page, browserName);
|
||||
await expect(await request.post('/__test__/tagging/reset', { data: {} })).toBeOK();
|
||||
page.problems = [];
|
||||
page.on('pageerror', error => page.problems.push(`pageerror: ${error.message}`));
|
||||
page.on('console', message => { if (message.type() === 'error') page.problems.push(`console: ${message.text()}`); });
|
||||
});
|
||||
test.afterEach(async ({ page, browserName }) => {
|
||||
await saveCoverage(page, browserName);
|
||||
expect(page.problems).toEqual([]);
|
||||
});
|
||||
|
||||
async function openTag(page) {
|
||||
await page.goto(images);
|
||||
const observed = page.waitForResponse(item => item.url().endsWith('/image-annotations/observe'));
|
||||
await tagButton(page).click();
|
||||
await expect(dialog(page)).toBeVisible();
|
||||
expect((await observed).status()).toBe(200);
|
||||
await expect(manual(page)).toBeEditable();
|
||||
}
|
||||
|
||||
test('leaving the page with the dialog open releases its capture without a console error (I4)', async ({ page, request }) => {
|
||||
await openTag(page);
|
||||
expect(await openCaptures(request)).toBe(1);
|
||||
// Another document: the old one's pagehide closes the dialog and sends the release with keepalive.
|
||||
await page.goto('/prompt_manager/workspace?left=1#/tags');
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Tags & categories');
|
||||
await expect.poll(() => openCaptures(request)).toBe(0);
|
||||
await expect(dialog(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('closing the dialog during a save releases the capture after the save and still reports it (I5)', async ({ page, request }) => {
|
||||
await openTag(page);
|
||||
await manual(page).fill('saved_after_close');
|
||||
const order = [];
|
||||
page.on('response', item => { if (item.url().endsWith('/image-annotations/tags')) order.push(`PUT ${item.status()}`); });
|
||||
page.on('request', item => { if (item.url().includes('/image-annotations/capture/') && item.method() === 'DELETE') order.push('DELETE'); });
|
||||
let release;
|
||||
const held = new Promise(resolve => { release = resolve; });
|
||||
const sent = new Promise(resolve => {
|
||||
page.route(TAGS_URL, async route => { resolve(); await held; await route.continue(); });
|
||||
});
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
await sent;
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(dialog(page)).toHaveCount(0);
|
||||
expect(await openCaptures(request)).toBe(1);
|
||||
expect(order).toEqual([]);
|
||||
release();
|
||||
await expect(page.locator('#workspace-toast')).toHaveText('1 tags saved for dunes_00001.png.');
|
||||
await expect.poll(() => order).toEqual(['PUT 200', 'DELETE']);
|
||||
await expect.poll(() => openCaptures(request)).toBe(0);
|
||||
// The page followed the late save: the selected file's details show the new image tag.
|
||||
await expect(page.getByRole('complementary', { name: 'Selected image details', exact: true }).getByText('saved_after_close', { exact: true })).toBeVisible();
|
||||
await page.unroute(TAGS_URL);
|
||||
});
|
||||
@@ -0,0 +1,749 @@
|
||||
// The Tag image dialog, the card Tag button and the Tags page hooks (#249 PR C, plan Task C2, Mock inventory M1, M2, M5)
|
||||
// against the real routes and the injected fake model (tests/browser/workspace_tagging_fixture.py). No model is ever
|
||||
// downloaded or loaded: the fake answers, and every test checks that no download request was sent.
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
import { saveImageTags } from './concept-inventory.mjs';
|
||||
import { taggingInventory } from './tagging-canonical-comparison.mjs';
|
||||
|
||||
const images = '/prompt_manager/workspace#/images';
|
||||
const MODULE = '/prompt_manager/js/workspace/image-tagging.mjs';
|
||||
const MODEL_LABELS = ['WD14 SwinV2 · ~400 MB', 'WD14 ViT · ~400 MB', 'GGUF JoyCaption (Recommended) · ~4.5 GB', 'HuggingFace JoyCaption · ~16 GB'];
|
||||
// The concept's on-screen opening sentence (its renderTagModelOptions replaces the markup's at once, D83).
|
||||
const MODEL_CHANGED = 'Generate suggestions with the selected model before approving.';
|
||||
const CHIP_HINT = 'Remove anything you do not want to add.';
|
||||
const ALL_REMOVED = 'All suggestions removed. Generate again or enter tags manually.';
|
||||
const card = (page, path) => page.locator(`article[data-image-key="0:${path}"]`);
|
||||
const tagButton = (page, path) => card(page, path).getByRole('button', { name: `Tag or auto-tag ${path.split('/').pop()}`, exact: true });
|
||||
const dialog = page => page.getByRole('dialog', { name: 'Tag image', exact: true });
|
||||
const manual = page => dialog(page).getByRole('textbox', { name: 'Image tags · edit manually', exact: true });
|
||||
const model = page => dialog(page).getByRole('combobox', { name: 'Tagging model', exact: true });
|
||||
const generate = page => dialog(page).getByRole('button', { name: 'Generate suggestions', exact: true });
|
||||
const chips = page => dialog(page).locator('#tag-suggestions .chip.tag-suggestion');
|
||||
const status = page => dialog(page).locator('#tag-generation-status');
|
||||
const toast = page => page.locator('#workspace-toast');
|
||||
const nav = (page, label) => page.locator('aside.sidebar nav').getByRole('link', { name: new RegExp(`^${label}(?:\\s*[\\d,]+)?$`) });
|
||||
const FIRST = 'tagging/dunes_00001.png', SECOND = 'tagging/dunes_00002.png', COPY = 'copies/dunes_00001.png';
|
||||
const VIDEO = 'tagging/clip.webm', UNLINKED = 'loose/unlinked.png';
|
||||
|
||||
let manifest;
|
||||
test.beforeEach(async ({ page, request, browserName }) => {
|
||||
await startCoverage(page, browserName);
|
||||
const response = await request.post('/__test__/tagging/reset', { data: {} });
|
||||
await expect(response).toBeOK();
|
||||
manifest = await response.json();
|
||||
page.taggingErrors = [];
|
||||
page.on('pageerror', error => page.taggingErrors.push(error.message));
|
||||
page.downloads = [];
|
||||
page.on('request', item => { if (/\/autotag\/download\//.test(item.url())) page.downloads.push(item.url()); });
|
||||
});
|
||||
test.afterEach(async ({ page, browserName }) => {
|
||||
await saveCoverage(page, browserName);
|
||||
expect(page.taggingErrors).toEqual([]);
|
||||
expect(page.downloads).toEqual([]);
|
||||
});
|
||||
|
||||
/** Images, then the Tag dialog of `path`, settled on its observation (the manual field holds the saved tags). */
|
||||
async function openTag(page, path = FIRST, { goto = true } = {}) {
|
||||
if (goto) await page.goto(images);
|
||||
const observed = page.waitForResponse(item => item.url().endsWith('/image-annotations/observe'));
|
||||
await tagButton(page, path).click();
|
||||
await expect(dialog(page)).toBeVisible();
|
||||
expect((await observed).status()).toBe(200);
|
||||
await expect(dialog(page).locator('img.tag-image-preview, video.tag-image-preview')).toHaveAttribute('src', /\/prompt_manager\/image-annotations\/capture\//);
|
||||
}
|
||||
async function generated(page) {
|
||||
const answer = page.waitForResponse(item => item.url().endsWith('/image-annotations/suggest'));
|
||||
await generate(page).click();
|
||||
return answer;
|
||||
}
|
||||
/** Read the saved tags of `path` through the real observe route, then release that capture. */
|
||||
async function savedTags(request, path) {
|
||||
const target = { root_index: 0, root_scope: await scopeOf(request), path };
|
||||
const observed = await (await request.post('/prompt_manager/image-annotations/observe', { data: { target } })).json();
|
||||
expect(observed.success).toBe(true);
|
||||
await request.delete(`/prompt_manager/image-annotations/capture/${observed.capture.token}`);
|
||||
return observed.tags;
|
||||
}
|
||||
async function scopeOf(request) {
|
||||
const listing = await (await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();
|
||||
return listing.images[0].root_scope;
|
||||
}
|
||||
|
||||
test('tag dialog control inventory and order match M2', async ({ page }) => {
|
||||
await openTag(page);
|
||||
await expect(page.locator('dialog#modal')).toHaveClass(/(^| )tagging-modal( |$)/);
|
||||
await expect(dialog(page).locator('#modal-title')).toHaveText('Tag image');
|
||||
const body = dialog(page).locator('#modal-body');
|
||||
expect(await body.evaluate(node => [...node.children].map(child => child.className))).toEqual(
|
||||
['tag-image-heading', 'field', 'tag-model-panel', 'tag-suggestion-panel', 'dialog-actions']);
|
||||
const heading = body.locator('.tag-image-heading');
|
||||
await expect(heading.locator('h3')).toHaveText(manifest.title);
|
||||
await expect(heading.locator('p.small')).toHaveText('dunes_00001.png');
|
||||
await expect(heading.locator('span.chip')).toHaveText('96 × 64');
|
||||
await expect(heading.locator('img.tag-image-preview')).toHaveAttribute('alt', manifest.title);
|
||||
await expect(body.locator('.tag-scope-note')).toHaveCount(0);
|
||||
await expect(manual(page)).toHaveAttribute('id', 'manual-image-tags');
|
||||
await expect(manual(page)).toHaveValue('');
|
||||
await expect(manual(page)).toHaveAccessibleDescription('Comma-separated. Generated suggestions are added only after you approve them.');
|
||||
const panel = body.locator('.tag-model-panel');
|
||||
await expect(panel.locator('h3')).toHaveText('Auto-tag this image');
|
||||
await expect(model(page).locator('option')).toHaveText(MODEL_LABELS);
|
||||
expect(await model(page).locator('option').evaluateAll(nodes => nodes.map(node => node.value))).toEqual(['wd14-swinv2', 'wd14-vit', 'gguf', 'hf']);
|
||||
await expect(model(page)).toHaveValue('wd14-swinv2');
|
||||
const options = panel.locator('#image-tag-model-options');
|
||||
const general = options.getByRole('spinbutton', { name: 'General confidence', exact: true });
|
||||
const character = options.getByRole('spinbutton', { name: 'Character confidence', exact: true });
|
||||
const limit = options.getByRole('spinbutton', { name: 'Suggestions', exact: true });
|
||||
await expect(options.locator('.two-col .field')).toHaveCount(2);
|
||||
await expect(general).toHaveValue('0.35');
|
||||
await expect(character).toHaveValue('0.85');
|
||||
await expect(limit).toHaveValue('10');
|
||||
for (const [input, attributes] of [[general, ['0', '1', '0.05']], [character, ['0', '1', '0.05']], [limit, ['1', '100', '1']]]) {
|
||||
expect(await input.evaluate(node => [node.min, node.max, node.step])).toEqual(attributes);
|
||||
}
|
||||
await expect(generate(page)).toHaveClass(/ca-primary/);
|
||||
await expect(generate(page).locator('svg.icon')).toHaveCount(1);
|
||||
const suggestions = body.locator('section.tag-suggestion-panel');
|
||||
await expect(suggestions).toHaveAttribute('aria-labelledby', 'tag-suggestions-title');
|
||||
await expect(suggestions.locator('h3#tag-suggestions-title')).toHaveText('Review suggestions');
|
||||
await expect(suggestions.locator('#suggestion-count')).toHaveText('Not generated');
|
||||
await expect(suggestions.locator('#tag-suggestions > p.small')).toHaveText(MODEL_CHANGED);
|
||||
await expect(status(page)).toHaveAttribute('role', 'status');
|
||||
await expect(status(page)).toHaveText('');
|
||||
await expect(body.locator('.dialog-actions button')).toHaveText(['Cancel', 'Save manual tags']);
|
||||
await expect(body.locator('.dialog-actions button').last()).toHaveClass(/ca-primary/);
|
||||
await model(page).selectOption('gguf');
|
||||
await expect(options.getByRole('textbox', { name: 'Tagging instructions', exact: true })).toHaveValue('Describe the image');
|
||||
await expect(options.getByRole('spinbutton', { name: 'Suggestions', exact: true })).toHaveValue('10');
|
||||
await expect(options.locator('.two-col')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('generate shows real fixture percentages for WD14 and Suggested for GGUF, at most ten chips', async ({ page }) => {
|
||||
await openTag(page);
|
||||
const answer = await generated(page);
|
||||
expect(answer.request().postDataJSON()).toMatchObject({ model_type: 'wd14-swinv2', options: { general_threshold: 0.35, character_threshold: 0.85, suggestion_limit: 10 } });
|
||||
await expect(chips(page)).toHaveCount(10);
|
||||
// D83: ruling T4's instruction leads the chips, only while chips exist.
|
||||
await expect(dialog(page).locator('#tag-suggestions > *')).toHaveCount(2);
|
||||
await expect(dialog(page).locator('#tag-suggestions > p.small')).toHaveText(CHIP_HINT);
|
||||
await expect(chips(page).first().locator(':scope > span').first()).toHaveText('sandstone');
|
||||
await expect(chips(page).locator('.tag-confidence').first()).toHaveText('98%');
|
||||
await expect(chips(page).nth(1).locator('.tag-confidence')).toHaveText('97%');
|
||||
await expect(chips(page).first().getByRole('button', { name: 'Remove sandstone', exact: true })).toBeVisible();
|
||||
await expect(dialog(page).locator('#suggestion-count')).toHaveText('10 suggestions');
|
||||
await expect(status(page)).toHaveText('Suggestions from WD14 SwinV2 · ~400 MB. Approve tags to save.');
|
||||
await expect(dialog(page).locator('.dialog-actions .ca-primary')).toHaveText('Approve selected tags');
|
||||
for (const id of ['gguf', 'hf']) {
|
||||
await model(page).selectOption(id);
|
||||
await expect(chips(page)).toHaveCount(0);
|
||||
await generated(page);
|
||||
await expect(chips(page)).toHaveCount(10);
|
||||
await expect(chips(page).locator('.tag-confidence')).toHaveText(Array(10).fill('Suggested'));
|
||||
await expect(status(page)).toHaveText(`Suggestions from ${MODEL_LABELS[id === 'gguf' ? 2 : 3]}. Approve tags to save.`);
|
||||
}
|
||||
await dialog(page).getByRole('spinbutton', { name: 'Suggestions', exact: true }).fill('3');
|
||||
const fewer = await generated(page);
|
||||
expect(fewer.request().postDataJSON().options).toEqual({ instructions: 'Describe the image', suggestion_limit: 3 });
|
||||
await expect(chips(page)).toHaveCount(3);
|
||||
await expect(dialog(page).locator('#suggestion-count')).toHaveText('3 suggestions');
|
||||
});
|
||||
|
||||
test('removing chips and editing manually then approving persists across reload', async ({ page, request }) => {
|
||||
await openTag(page);
|
||||
await manual(page).fill('Minimal, sky_blue');
|
||||
await dialog(page).getByRole('spinbutton', { name: 'Suggestions', exact: true }).fill('3');
|
||||
await generated(page);
|
||||
await expect(chips(page)).toHaveCount(3);
|
||||
const remove = dialog(page).getByRole('button', { name: 'Remove desert', exact: true });
|
||||
await remove.click();
|
||||
await expect(chips(page)).toHaveCount(2);
|
||||
// Focus moves to the next chip's ×.
|
||||
await expect(dialog(page).getByRole('button', { name: 'Remove arch', exact: true })).toBeFocused();
|
||||
const saved = page.waitForResponse(item => item.url().endsWith('/image-annotations/tags') && item.request().method() === 'PUT');
|
||||
await dialog(page).getByRole('button', { name: 'Approve selected tags', exact: true }).click();
|
||||
expect((await saved).request().postDataJSON().tags).toEqual(['Minimal', 'sky_blue', 'sandstone', 'arch']);
|
||||
await expect(dialog(page)).toHaveCount(0);
|
||||
await expect(toast(page)).toHaveText('4 tags saved for dunes_00001.png.');
|
||||
await expect(tagButton(page, FIRST)).toBeFocused();
|
||||
expect(await savedTags(request, FIRST)).toEqual(['Minimal', 'sky_blue', 'sandstone', 'arch']);
|
||||
await page.reload();
|
||||
await openTag(page, FIRST, { goto: false });
|
||||
await expect(manual(page)).toHaveValue('Minimal, sky_blue, sandstone, arch');
|
||||
// Removing every chip shows the all-removed copy; the manual set still saves.
|
||||
await dialog(page).getByRole('spinbutton', { name: 'Suggestions', exact: true }).fill('2');
|
||||
await generated(page);
|
||||
for (const name of ['sandstone', 'desert']) await dialog(page).getByRole('button', { name: `Remove ${name}`, exact: true }).click();
|
||||
await expect(dialog(page).locator('#tag-suggestions > p.small')).toHaveText(ALL_REMOVED);
|
||||
await expect(generate(page)).toBeFocused();
|
||||
await dialog(page).getByRole('button', { name: 'Approve selected tags', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('4 tags saved for dunes_00001.png.');
|
||||
});
|
||||
|
||||
test('cancel escape and close write nothing', async ({ page, request }) => {
|
||||
const writes = [];
|
||||
page.on('request', item => { if (item.url().endsWith('/image-annotations/tags')) writes.push(item.method()); });
|
||||
await page.goto(images);
|
||||
for (const dismiss of [
|
||||
() => dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click(),
|
||||
() => page.keyboard.press('Escape'),
|
||||
() => dialog(page).getByRole('button', { name: 'Close dialog', exact: true }).click(),
|
||||
]) {
|
||||
const released = page.waitForResponse(item => item.url().includes('/image-annotations/capture/') && item.request().method() === 'DELETE');
|
||||
await openTag(page, FIRST, { goto: false });
|
||||
await manual(page).fill('never_saved');
|
||||
await dismiss();
|
||||
await expect(dialog(page)).toHaveCount(0);
|
||||
expect((await released).ok()).toBe(true);
|
||||
await expect(tagButton(page, FIRST)).toBeFocused();
|
||||
await expect(page.locator('dialog#modal.tagging-modal')).toHaveCount(0);
|
||||
}
|
||||
expect(writes).toEqual([]);
|
||||
expect(await savedTags(request, FIRST)).toEqual([]);
|
||||
});
|
||||
|
||||
test('model change discards suggestions and relabels primary', async ({ page }) => {
|
||||
await openTag(page);
|
||||
await manual(page).fill('kept');
|
||||
await generated(page);
|
||||
await expect(dialog(page).locator('.dialog-actions .ca-primary')).toHaveText('Approve selected tags');
|
||||
await dialog(page).getByRole('spinbutton', { name: 'General confidence', exact: true }).fill('0.5');
|
||||
await model(page).selectOption('wd14-vit');
|
||||
await expect(chips(page)).toHaveCount(0);
|
||||
await expect(dialog(page).locator('#tag-suggestions > p.small')).toHaveText(MODEL_CHANGED);
|
||||
await expect(dialog(page).locator('#suggestion-count')).toHaveText('Not generated');
|
||||
await expect(status(page)).toHaveText('');
|
||||
await expect(dialog(page).locator('.dialog-actions .ca-primary')).toHaveText('Save manual tags');
|
||||
await expect(manual(page)).toHaveValue('kept');
|
||||
// Like the concept, the new model's fields start from the defaults.
|
||||
await expect(dialog(page).getByRole('spinbutton', { name: 'General confidence', exact: true })).toHaveValue('0.35');
|
||||
});
|
||||
|
||||
test('invalid threshold reports validity without request', async ({ page }) => {
|
||||
const sent = [];
|
||||
page.on('request', item => { if (item.url().endsWith('/image-annotations/suggest')) sent.push(item.url()); });
|
||||
await openTag(page);
|
||||
const general = dialog(page).getByRole('spinbutton', { name: 'General confidence', exact: true });
|
||||
await general.fill('1.5');
|
||||
await generate(page).click();
|
||||
await expect(general).toBeFocused();
|
||||
expect(await general.evaluate(node => node.validity.rangeOverflow)).toBe(true);
|
||||
await general.fill('');
|
||||
await generate(page).click();
|
||||
await expect.poll(() => general.evaluate(node => node.validationMessage)).toBe('Enter a confidence from 0 to 1.');
|
||||
await general.fill('0.35');
|
||||
expect(await general.evaluate(node => node.validity.valid)).toBe(true);
|
||||
await model(page).selectOption('gguf');
|
||||
const instructions = dialog(page).getByRole('textbox', { name: 'Tagging instructions', exact: true });
|
||||
await instructions.fill(' ');
|
||||
await generate(page).click();
|
||||
await expect.poll(() => instructions.evaluate(node => node.validationMessage)).toBe('Enter tagging instructions of at most 4000 characters.');
|
||||
await expect(status(page)).toHaveText('');
|
||||
expect(sent).toEqual([]);
|
||||
});
|
||||
|
||||
test('unavailable model shows inline status and never downloads', async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/tagging/models', { data: { unavailable: ['wd14-swinv2'] } })).toBeOK();
|
||||
await openTag(page);
|
||||
expect((await generated(page)).status()).toBe(409);
|
||||
await expect(status(page)).toHaveText('WD14 SwinV2 is not downloaded. Download it from the legacy Auto-tag page.');
|
||||
await expect(chips(page)).toHaveCount(0);
|
||||
await expect(dialog(page).locator('#suggestion-count')).toHaveText('Not generated');
|
||||
// D116: Retry leads the actions while the controller allows it, and generates again.
|
||||
await expect(dialog(page).locator('.dialog-actions button')).toHaveText(['Retry', 'Cancel', 'Save manual tags']);
|
||||
await expect(await request.post('/__test__/tagging/models', { data: { unavailable: [] } })).toBeOK();
|
||||
const again = page.waitForResponse(item => item.url().endsWith('/image-annotations/suggest'));
|
||||
await dialog(page).getByRole('button', { name: 'Retry', exact: true }).click();
|
||||
expect((await again).status()).toBe(200);
|
||||
await expect(chips(page)).toHaveCount(10);
|
||||
await expect(dialog(page).locator('.dialog-actions button')).toHaveText(['Cancel', 'Approve selected tags']);
|
||||
await expect(dialog(page).locator('.dialog-actions .ca-primary')).toBeFocused();
|
||||
});
|
||||
|
||||
test('video tag is manual only', async ({ page, request }) => {
|
||||
await openTag(page, VIDEO);
|
||||
const preview = dialog(page).locator('video.tag-image-preview');
|
||||
await expect(preview).toHaveAttribute('preload', 'metadata');
|
||||
expect(await preview.evaluate(node => node.muted)).toBe(true);
|
||||
await expect(dialog(page).locator('.tag-image-heading span.chip')).toHaveCount(0);
|
||||
await expect(generate(page)).toHaveAttribute('aria-disabled', 'true');
|
||||
await expect(status(page)).toHaveText('Auto-tagging is available for supported still images only.');
|
||||
let suggested = false;
|
||||
page.on('request', item => { if (item.url().endsWith('/image-annotations/suggest')) suggested = true; });
|
||||
// Pressing the aria-disabled Generate sends nothing.
|
||||
await generate(page).focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await manual(page).fill('clip_tag');
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('1 tags saved for clip.webm.');
|
||||
expect(suggested).toBe(false);
|
||||
expect(await savedTags(request, VIDEO)).toEqual(['clip_tag']);
|
||||
});
|
||||
|
||||
test('sticky footer reachable at 390x844 while content scrolls', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openTag(page);
|
||||
await model(page).selectOption('gguf');
|
||||
await generated(page);
|
||||
await expect(chips(page)).toHaveCount(10);
|
||||
const shell = page.locator('dialog#modal');
|
||||
const actions = dialog(page).locator('.dialog-actions');
|
||||
expect(await actions.evaluate(node => getComputedStyle(node).position)).toBe('sticky');
|
||||
expect(await shell.evaluate(node => node.scrollHeight > node.clientHeight)).toBe(true);
|
||||
for (const top of [0, 10_000]) {
|
||||
await shell.evaluate((node, value) => { node.scrollTop = value; }, top);
|
||||
const box = await actions.boundingBox(), frame = await shell.boundingBox();
|
||||
expect(box.y + box.height).toBeLessThanOrEqual(frame.y + frame.height + 1);
|
||||
expect(box.y).toBeGreaterThanOrEqual(frame.y);
|
||||
await expect(dialog(page).getByRole('button', { name: 'Approve selected tags', exact: true })).toBeInViewport();
|
||||
}
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
|
||||
});
|
||||
|
||||
test('focus starts on close and returns to the Tag trigger', async ({ page }) => {
|
||||
await openTag(page, SECOND);
|
||||
await expect(dialog(page).getByRole('button', { name: 'Close dialog', exact: true })).toBeFocused();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(dialog(page)).toHaveCount(0);
|
||||
await expect(tagButton(page, SECOND)).toBeFocused();
|
||||
});
|
||||
|
||||
test('every Images and Library gallery card has Info then Tag with mock labels', async ({ page }) => {
|
||||
const expectCards = async locator => {
|
||||
const labels = await locator.evaluateAll(nodes => nodes.map(node => {
|
||||
const buttons = [...node.querySelectorAll('.media-info > .image-card-actions > button')];
|
||||
return { file: node.querySelector('.image-open').getAttribute('aria-label').replace(/^Open /, ''),
|
||||
buttons: buttons.map(button => [button.className, button.getAttribute('aria-label'), button.querySelector('svg.icon') !== null]) };
|
||||
}));
|
||||
expect(labels.length).toBeGreaterThan(0);
|
||||
for (const { file, buttons } of labels) {
|
||||
expect(buttons).toEqual([['icon-btn', `Inspect ${file}`, true], ['icon-btn', `Tag or auto-tag ${file}`, true]]);
|
||||
}
|
||||
return labels.map(({ file }) => file);
|
||||
};
|
||||
await page.goto(images);
|
||||
await expect(page.locator('article[data-image-key]')).toHaveCount(manifest.files.length);
|
||||
expect(await expectCards(page.locator('article[data-image-key]'))).toEqual(manifest.files.map(file => file.filename));
|
||||
await page.goto(`/prompt_manager/workspace#/library/${manifest.positive_id}`);
|
||||
const gallery = page.locator('.library-history .history-gallery article[data-image-key]');
|
||||
await expect(gallery.first()).toBeVisible();
|
||||
await expectCards(gallery);
|
||||
const observed = page.waitForResponse(item => item.url().endsWith('/image-annotations/observe'));
|
||||
await gallery.first().getByRole('button', { name: /^Tag or auto-tag / }).click();
|
||||
await expect(dialog(page)).toBeVisible();
|
||||
expect((await observed).status()).toBe(200);
|
||||
await dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(gallery.first().getByRole('button', { name: /^Tag or auto-tag / })).toBeFocused();
|
||||
});
|
||||
|
||||
test('Tag opens the dialog without selecting the card', async ({ page }) => {
|
||||
const panel = page.getByRole('complementary', { name: 'Selected image details', exact: true });
|
||||
await page.goto(images);
|
||||
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
|
||||
const before = await panel.innerText();
|
||||
await openTag(page, SECOND, { goto: false });
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(card(page, SECOND)).not.toHaveClass(/\bselected\b/);
|
||||
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
|
||||
expect(await panel.innerText()).toBe(before);
|
||||
});
|
||||
|
||||
test('tagging one of two cards sharing a prompt leaves the other card, the prompt and the pair unchanged', async ({ page, request }) => {
|
||||
const before = await (await request.get('/__test__/tagging/snapshot')).json();
|
||||
expect(await savedTags(request, SECOND)).toEqual([]);
|
||||
expect(await savedTags(request, COPY)).toEqual([]);
|
||||
const pairBefore = await (await request.get(`/prompt_manager/library/${manifest.positive_id}`)).json();
|
||||
await openTag(page, FIRST);
|
||||
await manual(page).fill('only_this_file');
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('1 tags saved for dunes_00001.png.');
|
||||
expect(await savedTags(request, FIRST)).toEqual(['only_this_file']);
|
||||
expect(await savedTags(request, SECOND)).toEqual([]);
|
||||
expect(await savedTags(request, COPY)).toEqual([]);
|
||||
expect(await (await request.get('/__test__/tagging/snapshot')).json()).toEqual(before);
|
||||
const pairAfter = await (await request.get(`/prompt_manager/library/${manifest.positive_id}`)).json();
|
||||
expect(pairAfter.success).toBe(true);
|
||||
expect({ ...pairAfter, library_epoch: null }).toEqual({ ...pairBefore, library_epoch: null });
|
||||
});
|
||||
|
||||
test('Tags page Auto-tag images opens the dialog for the selected image and is disabled without one', async ({ page }) => {
|
||||
await page.goto('/prompt_manager/workspace#/tags');
|
||||
const autoTag = page.locator('#workspace-main .heading-actions').getByRole('button', { name: 'Auto-tag images', exact: true });
|
||||
await expect(page.locator('#workspace-main .heading-actions button')).toHaveText(['+ Create tag', 'Auto-tag images']);
|
||||
await expect(autoTag).toHaveClass(/ca-primary/);
|
||||
await expect(autoTag).toHaveAttribute('aria-disabled', 'true');
|
||||
// An aria-disabled control still takes a press; it only explains.
|
||||
await autoTag.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(toast(page)).toHaveText('Select an image in Images first.');
|
||||
await expect(dialog(page)).toHaveCount(0);
|
||||
await nav(page, 'Images').click();
|
||||
await card(page, SECOND).getByRole('button', { name: 'Inspect dunes_00002.png', exact: true }).click();
|
||||
await expect(card(page, SECOND)).toHaveClass(/selected/);
|
||||
await nav(page, 'Tags & categories').click();
|
||||
await expect(autoTag).not.toHaveAttribute('aria-disabled', 'true');
|
||||
const observed = page.waitForResponse(item => item.url().endsWith('/image-annotations/observe'));
|
||||
await autoTag.click();
|
||||
expect((await observed).request().postDataJSON().target.path).toBe(SECOND);
|
||||
await expect(dialog(page).locator('.tag-image-heading p.small')).toHaveText('dunes_00002.png');
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(autoTag).toBeFocused();
|
||||
});
|
||||
|
||||
test('Images column shows image_count and updates after save', async ({ page }) => {
|
||||
await page.goto(images);
|
||||
await card(page, FIRST).getByRole('button', { name: 'Inspect dunes_00001.png', exact: true }).click();
|
||||
await nav(page, 'Tags & categories').click();
|
||||
const row = name => page.locator('#workspace-main table tbody tr').filter({ has: page.locator('td:first-child').getByText(name, { exact: true }) });
|
||||
await expect(row('minimal').locator('td:nth-child(3)')).toHaveText('0');
|
||||
await expect(row('editorial').locator('td:nth-child(3)')).toHaveText('0');
|
||||
await page.locator('#workspace-main .heading-actions').getByRole('button', { name: 'Auto-tag images', exact: true }).click();
|
||||
await expect(manual(page)).toBeVisible();
|
||||
await expect(dialog(page).locator('img.tag-image-preview')).toHaveAttribute('src', /capture/);
|
||||
await manual(page).fill('minimal, editorial');
|
||||
const reread = page.waitForResponse(item => item.url().includes('/prompt_manager/vocabulary/tags'));
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
await reread;
|
||||
await expect(row('minimal').locator('td:nth-child(3)')).toHaveText('1');
|
||||
await expect(row('editorial').locator('td:nth-child(3)')).toHaveText('1');
|
||||
await expect(row('warm_light').locator('td:nth-child(3)')).toHaveText('0');
|
||||
});
|
||||
|
||||
test('virtualized cards release listeners on unmount', async ({ page, request }) => {
|
||||
const extra = 140;
|
||||
await expect(await request.post('/__test__/tagging/reset', { data: { extra_images: extra } })).toBeOK();
|
||||
const count = () => page.evaluate(async url => (await import(url)).imageTagsListenerCount(), MODULE);
|
||||
await page.goto(images);
|
||||
await expect(card(page, FIRST)).toBeVisible();
|
||||
const mounted = await count();
|
||||
// Leaving and returning to Images several times keeps one subscription per mounted page.
|
||||
for (let round = 0; round < 3; round += 1) {
|
||||
await nav(page, 'Tags & categories').click();
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Tags & categories');
|
||||
await nav(page, 'Images').click();
|
||||
await expect(card(page, FIRST)).toBeVisible();
|
||||
}
|
||||
expect(await count()).toBe(mounted);
|
||||
// Cards scrolled out are unmounted; the delegated Tag still works on a card rendered again.
|
||||
const continuation = page.locator('#gallery-continuation');
|
||||
await expect(async () => {
|
||||
await continuation.scrollIntoViewIfNeeded();
|
||||
await expect(continuation).toContainText('You’re all caught up', { timeout: 1000 });
|
||||
}).toPass();
|
||||
await continuation.scrollIntoViewIfNeeded();
|
||||
await expect(card(page, FIRST)).toHaveCount(0);
|
||||
expect(await count()).toBe(mounted);
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await expect(card(page, FIRST)).toBeVisible();
|
||||
await openTag(page, FIRST, { goto: false });
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(tagButton(page, FIRST)).toBeFocused();
|
||||
expect(await count()).toBe(mounted);
|
||||
});
|
||||
|
||||
// Task C3 (D88): the side panel and the viewer details draw the file's own current image tags first, then D21's linked
|
||||
// tags not already shown (case-insensitive). Panel image tags are Tags filter buttons; the viewer's chips are plain.
|
||||
const panel = page => page.getByRole('complementary', { name: 'Selected image details', exact: true });
|
||||
const tagRow = page => panel(page).locator('.detail-inner > .tags').last();
|
||||
const inspected = (page, path) => page.waitForResponse(item => new URL(item.url()).pathname === `/prompt_manager/images/inspect/${path}`);
|
||||
const infoButton = (page, path) => card(page, path).getByRole('button', { name: `Inspect ${path.split('/').pop()}`, exact: true });
|
||||
|
||||
test('inspector shows saved image tags after Save without reselecting', async ({ page }) => {
|
||||
await page.goto(images);
|
||||
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
|
||||
// D21/D39: the linked recipe's own tag, plain.
|
||||
await expect(tagRow(page).locator('.chip')).toHaveText(manifest.pair_tags);
|
||||
await expect(tagRow(page).locator('button.chip')).toHaveCount(0);
|
||||
await openTag(page, FIRST, { goto: false });
|
||||
await manual(page).fill('dunes_tag, Editorial');
|
||||
const reread = inspected(page, FIRST);
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
expect((await (await reread).json()).image_tags).toEqual({ state: 'current', tags: ['dunes_tag', 'Editorial'] });
|
||||
// The image tags lead as filter buttons; the recipe's "editorial" is already shown as "Editorial".
|
||||
await expect(tagRow(page).locator('.chip')).toHaveText(['dunes_tag', 'Editorial']);
|
||||
await expect(tagRow(page).locator('button.chip')).toHaveText(['dunes_tag', 'Editorial']);
|
||||
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
|
||||
await expect(panel(page).locator('.panel-heading h2')).toHaveText(manifest.title);
|
||||
});
|
||||
|
||||
test('clicking an image tag chip filters Images to the tagged unlinked file', async ({ page, request }) => {
|
||||
expect(await saveImageTags(request, UNLINKED, ['loose_tag'])).toEqual(['loose_tag']);
|
||||
await page.goto(images);
|
||||
const read = inspected(page, UNLINKED);
|
||||
await infoButton(page, UNLINKED).click();
|
||||
await read;
|
||||
await expect(panel(page).locator('.panel-heading h2')).toHaveText('unlinked.png');
|
||||
await expect(tagRow(page).locator('.chip')).toHaveText(['loose_tag']);
|
||||
const listing = page.waitForRequest(item => item.url().includes('/images/output?') && new URL(item.url()).searchParams.get('tags') === 'loose_tag');
|
||||
await tagRow(page).getByRole('button', { name: 'loose_tag', exact: true }).click();
|
||||
await listing;
|
||||
await expect(page.locator('.gallery-body article[data-image-key]')).toHaveCount(1);
|
||||
await expect(card(page, UNLINKED)).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Filters · 1', exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('viewer details show the same chips', async ({ page, request }) => {
|
||||
await saveImageTags(request, FIRST, ['dunes_tag', 'Editorial', 'sunlit']);
|
||||
await page.goto(images);
|
||||
await expect(tagRow(page).locator('button.chip')).toHaveText(['dunes_tag', 'Editorial', 'sunlit']);
|
||||
await card(page, FIRST).locator('.image-open').click();
|
||||
const details = page.locator('#viewer-dialog[open] #viewer-details');
|
||||
await expect(details.locator('.panel-heading h2')).toHaveText(manifest.title);
|
||||
const row = details.locator('.tags').last();
|
||||
await expect(row.locator('.chip')).toHaveText(['dunes_tag', 'Editorial', 'sunlit']);
|
||||
await expect(row.locator('span.chip')).toHaveCount(3);
|
||||
await expect(row.locator('button')).toHaveCount(0);
|
||||
// A file without image tags: the viewer shows only D21's linked tags.
|
||||
await page.getByRole('button', { name: 'Next media', exact: true }).click();
|
||||
await expect(page.locator('#viewer-dialog[open] #viewer-title')).toHaveText(`${manifest.title} · dunes_00002.png`);
|
||||
await expect(details.locator('.tags').last().locator('.chip')).toHaveText(manifest.pair_tags);
|
||||
});
|
||||
|
||||
test('search finds an image by its image tag', async ({ page, request }) => {
|
||||
await saveImageTags(request, UNLINKED, ['Loose_Tag']);
|
||||
await page.goto(images);
|
||||
await expect(page.locator('.gallery-body article[data-image-key]')).toHaveCount(manifest.files.length);
|
||||
const listing = page.waitForRequest(item => item.url().includes('/images/output?') && new URL(item.url()).searchParams.get('q') === 'loose_t');
|
||||
await page.getByRole('searchbox', { name: 'Search workspace', exact: true }).fill('loose_t');
|
||||
await listing;
|
||||
await expect(page.locator('.gallery-body article[data-image-key]')).toHaveCount(1);
|
||||
await expect(card(page, UNLINKED)).toBeVisible();
|
||||
});
|
||||
|
||||
// Task C4 rulings: a save re-reads the selected file's details without a loading flash, and under a Tags filter or a
|
||||
// search the loaded listing is re-read in place (scroll and selection kept while the file still matches).
|
||||
const cards = page => page.locator('.gallery-body article[data-image-key]');
|
||||
const listingOf = (page, check) => page.waitForResponse(item => item.url().includes('/images/output?') && check(new URL(item.url()).searchParams));
|
||||
|
||||
test('the post-save re-read keeps the panel content until the new read lands', async ({ page }) => {
|
||||
await page.goto(images);
|
||||
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
|
||||
await expect(tagRow(page).locator('.chip')).toHaveText(manifest.pair_tags);
|
||||
await page.evaluate(() => {
|
||||
const node = document.querySelector('aside[aria-label="Selected image details"]');
|
||||
window.__loadingSeen = false;
|
||||
window.__loadingWatch = new MutationObserver(() => { if (node.textContent.includes('Loading file details')) window.__loadingSeen = true; });
|
||||
window.__loadingWatch.observe(node, { childList: true, subtree: true, characterData: true });
|
||||
});
|
||||
let release;
|
||||
const held = new Promise(resolve => { release = resolve; });
|
||||
const matcher = url => url.pathname === `/prompt_manager/images/inspect/${FIRST}`;
|
||||
await page.route(matcher, async route => { await held; await route.continue(); });
|
||||
await openTag(page, FIRST, { goto: false });
|
||||
await manual(page).fill('held_tag');
|
||||
const reread = page.waitForRequest(item => new URL(item.url()).pathname === `/prompt_manager/images/inspect/${FIRST}`);
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
await reread;
|
||||
await expect(toast(page)).toHaveText('1 tags saved for dunes_00001.png.');
|
||||
// While the re-read is held, the panel still shows the file's details.
|
||||
await expect(panel(page).locator('.panel-heading h2')).toHaveText(manifest.title);
|
||||
await expect(tagRow(page).locator('.chip')).toHaveText(manifest.pair_tags);
|
||||
expect(await page.evaluate(() => window.__loadingSeen)).toBe(false);
|
||||
const landed = inspected(page, FIRST);
|
||||
release();
|
||||
await landed;
|
||||
await expect(tagRow(page).locator('.chip')).toHaveText(['held_tag', ...manifest.pair_tags]);
|
||||
expect(await page.evaluate(() => { window.__loadingWatch.disconnect(); return window.__loadingSeen; })).toBe(false);
|
||||
await page.unroute(matcher);
|
||||
});
|
||||
|
||||
test('a save under a Tags filter re-reads the listing: the selection stays while it matches and follows when it leaves', async ({ page, request }) => {
|
||||
await saveImageTags(request, UNLINKED, ['shared']);
|
||||
await saveImageTags(request, SECOND, ['shared']);
|
||||
await page.goto(images);
|
||||
const read = inspected(page, SECOND);
|
||||
await infoButton(page, SECOND).click();
|
||||
await read;
|
||||
const filtered = listingOf(page, query => query.get('tags') === 'shared');
|
||||
await tagRow(page).getByRole('button', { name: 'shared', exact: true }).click();
|
||||
await filtered;
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
// The newest match (SECOND) is selected once the filter applies.
|
||||
await expect(card(page, SECOND)).toHaveClass(/\bselected\b/);
|
||||
// Both matches checked: the selection bar keeps only what the refreshed result still lists (I7).
|
||||
for (const path of [SECOND, UNLINKED]) await card(page, path).getByRole('checkbox', { name: `Select ${path.split('/').pop()}`, exact: true }).check();
|
||||
const bulkbar = page.locator('#bulkbar .bulkbar');
|
||||
await expect(bulkbar.locator('strong')).toHaveText('2 selected');
|
||||
// Tagging the other match keeps both cards and SECOND selected; the listing is re-read.
|
||||
await openTag(page, UNLINKED, { goto: false });
|
||||
await manual(page).fill('shared, extra');
|
||||
let reread = listingOf(page, query => query.get('tags') === 'shared' && !query.has('cursor'));
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
await reread;
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
await expect(card(page, SECOND)).toHaveClass(/\bselected\b/);
|
||||
await expect(tagButton(page, UNLINKED)).toBeFocused();
|
||||
// SECOND loses the filter tag: it leaves the result and the panel follows the remaining match.
|
||||
await openTag(page, SECOND, { goto: false });
|
||||
await manual(page).fill('');
|
||||
reread = listingOf(page, query => query.get('tags') === 'shared' && !query.has('cursor'));
|
||||
const next = inspected(page, UNLINKED);
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
await reread;
|
||||
await next;
|
||||
await expect(cards(page)).toHaveCount(1);
|
||||
await expect(card(page, SECOND)).toHaveCount(0);
|
||||
await expect(card(page, UNLINKED)).toHaveClass(/\bselected\b/);
|
||||
await expect(panel(page).locator('.panel-heading h2')).toHaveText('unlinked.png');
|
||||
await expect(page.getByText('1 of 1 image', { exact: false }).first()).toBeVisible();
|
||||
await expect(bulkbar.locator('strong')).toHaveText('1 selected');
|
||||
await expect(card(page, UNLINKED).getByRole('checkbox')).toBeChecked();
|
||||
// Export metadata acts on exactly the one file the bar counts.
|
||||
const exported = [];
|
||||
page.on('request', item => { if (item.url().includes('/prompt_manager/images/inspect/')) exported.push(new URL(item.url()).pathname); });
|
||||
const download = page.waitForEvent('download');
|
||||
await bulkbar.getByRole('button', { name: 'Export metadata', exact: true }).click();
|
||||
await download;
|
||||
expect(exported).toEqual([`/prompt_manager/images/inspect/${UNLINKED}`]);
|
||||
});
|
||||
|
||||
test('a save under a search keeps the scroll position and the loaded range', async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/tagging/reset', { data: { extra_images: 140 } })).toBeOK();
|
||||
await page.goto(images);
|
||||
const searched = listingOf(page, query => query.get('q') === 'bulk');
|
||||
await page.getByRole('searchbox', { name: 'Search workspace', exact: true }).fill('bulk');
|
||||
await searched;
|
||||
const continuation = page.locator('#gallery-continuation');
|
||||
// Load at least two pages (the search lists 40 a page), then rest a third of the way down.
|
||||
const loadedCount = async () => {
|
||||
const text = await continuation.textContent();
|
||||
return /caught up/.test(text) ? 140 : Number(/^(\d+) of 140/.exec(text)?.[1] ?? 0);
|
||||
};
|
||||
await expect.poll(async () => { await continuation.scrollIntoViewIfNeeded(); return loadedCount(); }).toBeGreaterThanOrEqual(80);
|
||||
await page.evaluate(() => window.scrollTo(0, Math.round(document.documentElement.scrollHeight / 3)));
|
||||
await expect(continuation).not.toBeInViewport();
|
||||
const loaded = await continuation.textContent();
|
||||
await expect.poll(() => continuation.textContent()).toBe(loaded);
|
||||
const y = await page.evaluate(() => window.scrollY);
|
||||
expect(y).toBeGreaterThan(0);
|
||||
const visible = await cards(page).evaluateAll(nodes => nodes.find(node => { const box = node.getBoundingClientRect(); return box.top > 80 && box.bottom < innerHeight; })?.dataset.imageKey);
|
||||
expect(visible).toBeTruthy();
|
||||
const path = visible.slice(2);
|
||||
await openTag(page, path, { goto: false });
|
||||
await manual(page).fill('kept_in_place');
|
||||
const reread = listingOf(page, query => query.get('q') === 'bulk' && !query.has('cursor'));
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
await reread;
|
||||
await expect(tagButton(page, path)).toBeFocused();
|
||||
await expect(continuation).toHaveText(loaded);
|
||||
expect(Math.abs(await page.evaluate(() => window.scrollY) - y)).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
// Final review round (I1, I2, I9, M5, M6).
|
||||
test('the option fields show the server defaults that Generate sends (I1)', async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/tagging/defaults', { data: { general: 0.5, character: 0.9, limit: 7 } })).toBeOK();
|
||||
await openTag(page);
|
||||
const field = name => dialog(page).getByRole('spinbutton', { name, exact: true });
|
||||
await expect(field('General confidence')).toHaveValue('0.5');
|
||||
await expect(field('Character confidence')).toHaveValue('0.9');
|
||||
await expect(field('Suggestions')).toHaveValue('7');
|
||||
const answer = await generated(page);
|
||||
expect(answer.request().postDataJSON().options).toEqual({ general_threshold: 0.5, character_threshold: 0.9, suggestion_limit: 7 });
|
||||
await expect(chips(page)).toHaveCount(7);
|
||||
// A model change re-renders the new model's fields from the server defaults, and keeps the count (D91).
|
||||
await field('Suggestions').fill('4');
|
||||
await model(page).selectOption('wd14-vit');
|
||||
await expect(field('General confidence')).toHaveValue('0.5');
|
||||
await expect(field('Suggestions')).toHaveValue('4');
|
||||
});
|
||||
|
||||
test('a tag conflict shows the other writer\'s tags before Save, and a second Save keeps them (I2)', async ({ page, request }) => {
|
||||
await openTag(page);
|
||||
await manual(page).fill('mine');
|
||||
// Another writer (a second tab, the legacy page) saves while the dialog is open.
|
||||
expect(await saveImageTags(request, FIRST, ['changed_elsewhere'])).toEqual(['changed_elsewhere']);
|
||||
const conflict = page.waitForResponse(item => item.url().endsWith('/image-annotations/tags') && item.request().method() === 'PUT');
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
expect((await conflict).status()).toBe(409);
|
||||
await expect(status(page)).toContainText('Saved now: changed_elsewhere.');
|
||||
await expect(manual(page)).toHaveValue('changed_elsewhere, mine');
|
||||
await expect(dialog(page)).toBeVisible();
|
||||
const second = page.waitForResponse(item => item.url().endsWith('/image-annotations/tags') && item.request().method() === 'PUT');
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
expect((await second).request().postDataJSON().tags).toEqual(['changed_elsewhere', 'mine']);
|
||||
await expect(toast(page)).toHaveText('2 tags saved for dunes_00001.png.');
|
||||
expect(await savedTags(request, FIRST)).toEqual(['changed_elsewhere', 'mine']);
|
||||
});
|
||||
|
||||
test('D116: Retry leads the actions after an unconfirmed save and resends the same tags', async ({ page, request }) => {
|
||||
await openTag(page);
|
||||
await manual(page).fill('retried');
|
||||
let lost = true;
|
||||
await page.route('**/prompt_manager/image-annotations/tags', route => (lost ? (lost = false, route.fulfill({ status: 504, body: 'Gateway Timeout' })) : route.continue()));
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
await expect(status(page)).toHaveText('The save could not be confirmed. Retry sends the same tags again.');
|
||||
const inventory = await page.locator('dialog#modal').evaluate(taggingInventory);
|
||||
expect(inventory.actions.buttons.map(button => [button.text, button.primary, button.disabled])).toEqual(
|
||||
[['Retry', false, false], ['Cancel', false, false], ['Save manual tags', true, true]]);
|
||||
expect(inventory.actions.buttons[0].minHeight).toBe(inventory.actions.buttons[1].minHeight);
|
||||
const resent = page.waitForResponse(item => item.url().endsWith('/image-annotations/tags') && item.request().method() === 'PUT');
|
||||
await dialog(page).getByRole('button', { name: 'Retry', exact: true }).click();
|
||||
expect((await resent).request().postDataJSON().tags).toEqual(['retried']);
|
||||
await expect(toast(page)).toHaveText('1 tags saved for dunes_00001.png.');
|
||||
expect(await savedTags(request, FIRST)).toEqual(['retried']);
|
||||
await page.unroute('**/prompt_manager/image-annotations/tags');
|
||||
});
|
||||
|
||||
test('D116: Retry after an expired capture observes again and keeps the typed draft (M5)', async ({ page, request }) => {
|
||||
await openTag(page);
|
||||
await manual(page).fill('kept_draft');
|
||||
// The store forgets every capture (as its idle sweep would), so Save answers capture-expired.
|
||||
await expect(await request.post('/__test__/tagging/suggestions', { data: { scored: null } })).toBeOK();
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
await expect(dialog(page).locator('.dialog-actions .ca-primary')).toHaveAttribute('aria-disabled', 'true');
|
||||
const observed = page.waitForResponse(item => item.url().endsWith('/image-annotations/observe'));
|
||||
await dialog(page).getByRole('button', { name: 'Retry', exact: true }).click();
|
||||
expect((await observed).status()).toBe(200);
|
||||
await expect(dialog(page).locator('.dialog-actions button')).toHaveText(['Cancel', 'Save manual tags']);
|
||||
await expect(manual(page)).toHaveValue('kept_draft');
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('1 tags saved for dunes_00001.png.');
|
||||
expect(await savedTags(request, FIRST)).toEqual(['kept_draft']);
|
||||
});
|
||||
|
||||
test('a tag save from Tags reloads a filtered Images visit instead of restoring it (M6)', async ({ page, request }) => {
|
||||
await saveImageTags(request, SECOND, ['shared']);
|
||||
await saveImageTags(request, UNLINKED, ['shared']);
|
||||
await page.goto(images);
|
||||
await infoButton(page, SECOND).click();
|
||||
const filtered = listingOf(page, query => query.get('tags') === 'shared');
|
||||
await tagRow(page).getByRole('button', { name: 'shared', exact: true }).click();
|
||||
await filtered;
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
await expect(card(page, SECOND)).toHaveClass(/\bselected\b/);
|
||||
// Tags › Auto-tag images tags the selected SECOND without the filter tag.
|
||||
await nav(page, 'Tags & categories').click();
|
||||
await page.locator('#workspace-main .heading-actions').getByRole('button', { name: 'Auto-tag images', exact: true }).click();
|
||||
await expect(manual(page)).toHaveValue('shared');
|
||||
await manual(page).fill('other');
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('1 tags saved for dunes_00002.png.');
|
||||
const reloaded = listingOf(page, query => query.get('tags') === 'shared' && !query.has('cursor'));
|
||||
await nav(page, 'Images').click();
|
||||
await reloaded;
|
||||
await expect(cards(page)).toHaveCount(1);
|
||||
await expect(card(page, UNLINKED)).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Filters · 1', exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('a failed listing re-read after a save says so and keeps the cards (M2)', async ({ page, request }) => {
|
||||
await saveImageTags(request, SECOND, ['shared']);
|
||||
await saveImageTags(request, UNLINKED, ['shared']);
|
||||
await page.goto(images);
|
||||
await infoButton(page, SECOND).click();
|
||||
const filtered = listingOf(page, query => query.get('tags') === 'shared');
|
||||
await tagRow(page).getByRole('button', { name: 'shared', exact: true }).click();
|
||||
await filtered;
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
await openTag(page, SECOND, { goto: false });
|
||||
await manual(page).fill('');
|
||||
const failing = url => url.pathname === '/prompt_manager/images/output' && url.searchParams.get('tags') === 'shared';
|
||||
await page.route(failing, route => route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"down"}' }));
|
||||
const reread = page.waitForResponse(item => failing(new URL(item.url())));
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
expect((await reread).status()).toBe(500);
|
||||
await expect(toast(page)).toHaveText('Images could not be refreshed after the tag save. Refresh image collection to retry.');
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
await expect(page.locator('#gallery-continuation [aria-disabled="true"]')).toHaveCount(0);
|
||||
await page.unroute(failing);
|
||||
});
|
||||
@@ -3,12 +3,12 @@ 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';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
|
||||
test.use({ locale: 'en-US', timezoneId: 'UTC', reducedMotion: 'reduce' });
|
||||
const workspace = '/prompt_manager/workspace';
|
||||
const primary = ['images', 'library', 'tags', 'inspect'];
|
||||
const tools = ['system', 'overview'];
|
||||
const tools = ['activity', 'system', 'overview'];
|
||||
|
||||
async function noOverflow(page) {
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
|
||||
@@ -29,7 +29,7 @@ async function attachEvidence(page, testInfo, name, fullPage = true) {
|
||||
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', '.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 geometry = await page.evaluate(() => Object.fromEntries(['.sidebar', '.topbar', '#workspace-main', '.heading', 'h1', '.image-workspace', '.image-grid', '.media-card', '.detail-panel', '.prompt-heading', '.library-grid', '.prompt-card', '.prompt-card .card-art', '.prompt-card .card-content'].map(selector => {
|
||||
const node = document.querySelector(selector);
|
||||
if (!node) return [selector, null];
|
||||
const box = node.getBoundingClientRect(), style = getComputedStyle(node);
|
||||
@@ -94,23 +94,24 @@ for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of ['
|
||||
await page.goto(`${workspace}#/library/${fixture.canonical_id}`);
|
||||
await page.getByRole('combobox', { name: 'Theme', 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 });
|
||||
const pairTitle = details.variants.find(variant => variant.id === fixture.canonical_pair).title;
|
||||
const choice = page.getByRole('button', { name: `Select negative ${pairTitle}`, 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']) await expect(roles.getByRole('button', { name, exact: true })).toBeVisible();
|
||||
const title = await page.locator('#workspace-main > .heading h1').textContent();
|
||||
for (const name of ['Copy positive prompt', 'Send positive prompt to ComfyUI', 'Copy negative prompt', 'Send negative prompt to ComfyUI']) await expect(roles.getByRole('button', { name: `${name}: ${title}`, exact: true })).toBeVisible();
|
||||
// As in the concept, the selected pair's send sits in the history heading, not among the saved texts.
|
||||
await expect(roles.getByRole('button', { name: 'Send prompt pair', exact: true })).toHaveCount(0);
|
||||
await expect(page.locator('#workspace-main > .heading button.send')).toBeEnabled();
|
||||
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 expect(page.locator('.library-history .history-gallery article[data-image-key]')).toHaveCount(2);
|
||||
await page.locator('.library-history .history-gallery .image-card-actions button:first-child').first().click();
|
||||
await expect(page.getByRole('complementary', { name: 'Selected image details', exact: true })).toContainText('canonical-alt.png');
|
||||
await page.locator('.library-history .history-gallery .media-card.selected .image-open').click();
|
||||
await expect(page.locator('dialog#viewer-dialog')).toBeVisible();await page.keyboard.press('Escape');
|
||||
for (const [name, selector] of [['saved', '.library-history article.surface'], ['branches', '.library-history .timeline-panel'], ['gallery', '.library-history .history-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"]`);
|
||||
@@ -122,16 +123,20 @@ for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of ['
|
||||
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();
|
||||
const viewer = page.locator('dialog#viewer-dialog');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();
|
||||
for (const name of ['Copy positive prompt', 'Send positive prompt to ComfyUI', 'Copy negative prompt', 'Send negative prompt to ComfyUI']) await expect(viewer.getByRole('button', { name: new RegExp(`^${name}: `) })).toBeVisible();
|
||||
await expect(viewer.getByRole('button', { name: /^Send current file prompt pair to ComfyUI: |^Send prompt pair to ComfyUI: / })).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 currentPositive.getByRole('button', { name: /^Copy positive prompt: / }).click();
|
||||
// The concept's copy outcome: the shared toast, or its "Copy prompt" dialog when the clipboard is refused.
|
||||
const copyFallback = page.getByRole('dialog', { name: 'Copy prompt', exact: true });
|
||||
await expect(page.locator('#workspace-toast').filter({ hasText: 'Prompt copied.' }).or(copyFallback)).toHaveCount(1);
|
||||
if (await copyFallback.isVisible()) { await page.keyboard.press('Escape'); await expect(copyFallback).toBeHidden(); }
|
||||
await exerciseCurrentPairSend(page, { positive: fixture.canonical_positive, negative: fixture.canonical_negative });
|
||||
await captureCanonicalState(page, testInfo, `production-viewer-${width}-${theme}`, 'dialog.image-preview');
|
||||
await captureCanonicalState(page, testInfo, `production-viewer-${width}-${theme}`, 'dialog#viewer-dialog');
|
||||
await page.keyboard.press('Escape');await expect(view).toBeFocused();await page.keyboard.press('Escape');await expect(run).toBeFocused();
|
||||
await compareCanonicalHistory(reference, actual, testInfo);
|
||||
|
||||
@@ -242,9 +247,10 @@ test('long content and zoom keep grouped navigation reachable', async ({ page, r
|
||||
await text.scrollIntoViewIfNeeded();
|
||||
expect((await text.textContent()).length).toBeGreaterThan(1500);
|
||||
await text.focus(); await expect(text).toBeFocused();
|
||||
// #249 D23: the card clamps the prompt to two lines; the card content itself never scrolls or overflows.
|
||||
expect(await text.evaluate(node => {
|
||||
const scroller = node.closest('.library-card-prompts');
|
||||
return scroller.scrollHeight <= scroller.clientHeight + 1 && node.scrollHeight <= node.clientHeight + 1;
|
||||
const content = node.closest('.card-content');
|
||||
return content.scrollHeight <= content.clientHeight + 1 && content.scrollWidth <= content.clientWidth + 1 && node.scrollHeight > node.clientHeight + 1;
|
||||
})).toBe(true);
|
||||
await noOverflow(page);
|
||||
await page.evaluate(() => { document.documentElement.style.zoom = ''; });
|
||||
@@ -290,18 +296,18 @@ async function containedImageContent(page) {
|
||||
const failures = [];
|
||||
for (const card of nodes) {
|
||||
const outer = card.getBoundingClientRect();
|
||||
for (const selector of ['.image-card-footer', 'strong', 'small', '.image-card-footer button']) {
|
||||
for (const selector of ['.media-info', '.media-info h3', '.media-info p', '.image-card-actions']) {
|
||||
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)) {
|
||||
(selector === '.media-info p' && 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'));
|
||||
const action = card.querySelector('.image-card-actions').getBoundingClientRect();
|
||||
const range = document.createRange(); range.selectNodeContents(card.querySelector('.media-info p'));
|
||||
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) {
|
||||
@@ -341,18 +347,31 @@ for (const [width, height] of [[390, 844], [1600, 1100]]) for (const layout of [
|
||||
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();
|
||||
const longCard = cards.filter({ has: page.locator('[aria-label^="Open a-very-long-geometric-composition-"]') });
|
||||
const filename = (await longCard.locator('.image-open').getAttribute('aria-label')).replace(/^Open /, '');
|
||||
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 });
|
||||
// D19: Info selects into the panel; the image itself opens the viewer.
|
||||
await longCard.locator('.image-card-actions button:first-child').focus(); await page.keyboard.press('Enter');
|
||||
await expect(page.locator('.detail-panel .panel-heading h2')).toHaveText(filename);
|
||||
// D19: the card image, not the panel, opens the viewer.
|
||||
const fullPreview = longCard.locator('.image-open');
|
||||
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 expect(page.locator('dialog#viewer-dialog')).toBeVisible();
|
||||
await expect(page.locator('#viewer-title')).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();
|
||||
// The concept's card order (app.js mediaCard): image, the D8 checkbox, the D7 star, then Inspect. List hides
|
||||
// the overlays as the concept does (`.list-grid .image-overlays`), so there the image is followed by Inspect.
|
||||
const box = longCard.getByRole('checkbox', { name: `Select ${filename}`, exact: true });
|
||||
const star = longCard.getByRole('button', { name: `Favorite ${filename}`, exact: true });
|
||||
const overlays = layout === 'List' ? [] : [box, star];
|
||||
if (layout === 'List') { await expect(box).toBeHidden(); await expect(star).toBeHidden(); }
|
||||
await longCard.locator('.image-open').focus();
|
||||
for (const control of overlays) { await page.keyboard.press('Tab'); await expect(control).toBeFocused(); }
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(longCard.getByRole('button', { name: `Inspect ${filename}`, exact: true })).toBeFocused();
|
||||
for (const control of [...overlays].reverse()) { await page.keyboard.press('Shift+Tab'); await expect(control).toBeFocused(); }
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
await expect(longCard.locator('.image-open')).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([]);
|
||||
@@ -374,7 +393,7 @@ test('zoomed virtual Images keeps the visible real media mounted during native s
|
||||
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 more.click(); await expect(page.locator('.section-caption')).toContainText(`${loaded} of 202 images`);
|
||||
}
|
||||
await expect.poll(() => cards.count()).toBeLessThan(120);
|
||||
await page.evaluate(stride => {
|
||||
@@ -401,33 +420,32 @@ test('supplemental extreme zoom grows real footers preserves the visible anchor
|
||||
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.locator('.image-card-actions button:first-child').click(); await selected.locator('.image-open').focus();
|
||||
const filename = (await selected.locator('.image-open').getAttribute('aria-label')).replace(/^Open /, '');
|
||||
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;
|
||||
} };
|
||||
const grid = document.querySelector('.image-grid'), footers = [...grid.querySelectorAll('.media-info')];
|
||||
// Production measures each footer's fractional height (`getBoundingClientRect().height / scale`, #249 C7), so the
|
||||
// probe records the visible anchor at the first measurement after the zoom, before the grid relays out.
|
||||
const nativeRect = Element.prototype.getBoundingClientRect;
|
||||
const restore = () => { for (const node of footers) delete node.getBoundingClientRect; };
|
||||
window.footerMeasurementProbe = { before: null, restore };
|
||||
for (const footer of footers) Object.defineProperty(footer, 'offsetHeight', { configurable: true, get() {
|
||||
const height = nativeHeight.call(this);
|
||||
for (const footer of footers) Object.defineProperty(footer, 'getBoundingClientRect', { configurable: true, writable: true, value() {
|
||||
const rect = nativeRect.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) {
|
||||
Math.abs(Number.parseFloat(this.closest('.media-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;
|
||||
return rect;
|
||||
} });
|
||||
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;
|
||||
// The approved initial reservation is 54px under the concept's 1.33 media and a 13px row gap.
|
||||
const stride = (box.width / 4 - 2) / 1.33 + 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);
|
||||
@@ -442,7 +460,7 @@ test('supplemental extreme zoom grows real footers preserves the visible anchor
|
||||
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);
|
||||
await expect(page.locator('.detail-panel .panel-heading h2')).toHaveText(filename);
|
||||
const mutations = await grid.evaluate(async node => {
|
||||
let changes = 0;
|
||||
const observer = new MutationObserver(records => { changes += records.length; });
|
||||
@@ -454,10 +472,10 @@ test('supplemental extreme zoom grows real footers preserves the visible anchor
|
||||
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 page.getByRole('searchbox', { name: 'Search workspace', 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 page.getByRole('searchbox', { name: 'Search workspace', 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')));
|
||||
@@ -479,7 +497,7 @@ test('supplemental wrapped real footer reservation survives append without movin
|
||||
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();
|
||||
await cards.first().locator('.image-card-actions button:first-child').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);
|
||||
@@ -491,18 +509,23 @@ test('supplemental wrapped real footer reservation survives append without movin
|
||||
});
|
||||
try {
|
||||
const more = page.getByRole('button', { name: 'Load more images', exact: true });
|
||||
await more.click(); await expect.poll(() => pending).toBe(true);
|
||||
// At 4x zoom the concept continuation (copy + Load more) fills the viewport, so keep the last cards in view and
|
||||
// press Load more from the keyboard without scrolling to it.
|
||||
await cards.last().scrollIntoViewIfNeeded();
|
||||
await more.evaluate(node => node.focus({ preventScroll: true })); await page.keyboard.press('Enter');
|
||||
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');
|
||||
release(); await expect(page.locator('.section-caption')).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();
|
||||
// The concept focuses the first appended image after a Load more button press.
|
||||
await expect(page.locator('article[aria-posinset="41"] .image-open')).toBeFocused();
|
||||
await expect.poll(() => containedImageContent(page)).toEqual([]);
|
||||
} finally { release(); }
|
||||
});
|
||||
@@ -517,9 +540,9 @@ test('Library visual evidence keeps natural cards and returns from real history
|
||||
await page.getByRole('combobox',{name:'Theme',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 page.getByRole('button',{name:'Explore history',exact:true}).first().click();
|
||||
await expect(page.getByRole('region',{name:'Prompt history',exact:true})).toBeVisible();
|
||||
await expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('One positive prompt. Reusable negative variants. One shared gallery.');
|
||||
await expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('Prompt history · one positive, one shared gallery');
|
||||
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
|
||||
await expect(page.locator('article[data-prompt-id]')).toHaveCount(6);
|
||||
await expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('One positive. Reusable negatives. Every experiment stays connected.');
|
||||
@@ -533,7 +556,7 @@ test('approved icons are decorative safe SVGs and reject unknown vocabulary', as
|
||||
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', 'star', 'tagImage', 'folder', 'history', 'review'];
|
||||
const names = ['images', 'library', 'tags', 'upload', 'overview', 'activity', 'settings', 'grid', 'list', 'masonry', 'search', 'info', 'copy', 'branch', 'star', 'tagImage', 'folder', 'history', 'review'];
|
||||
const otherDocument = document.implementation.createHTMLDocument();
|
||||
const rendered = names.map(name => {
|
||||
const icon = createWorkspaceIcon(name, otherDocument);
|
||||
@@ -552,7 +575,8 @@ test('approved icons are decorative safe SVGs and reject unknown vocabulary', as
|
||||
expect(icon).toMatchObject({ svg: true, className: 'icon', 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);
|
||||
}
|
||||
// D28 ruling: Overview's own outline home glyph, not the concept's `activity` pulse ('M3 12h4l3-8 4 16 3-8h4').
|
||||
// D28 ruling: Overview's own outline home glyph; the concept's `activity` pulse is the Activity row's.
|
||||
expect(icons.rendered.find(icon => icon.name === 'activity').path).toBe('M3 12h4l3-8 4 16 3-8h4');
|
||||
expect(icons.rendered.find(icon => icon.name === 'overview').path).toBe('M3 11 12 4l9 7M5 10v10h14V10M10 20v-6h4v6');
|
||||
expect(new Set(icons.rendered.map(icon => icon.path)).size).toBe(icons.rendered.length);
|
||||
});
|
||||
@@ -577,53 +601,57 @@ test('Images visual layouts retain real delayed media long filenames and loaded
|
||||
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');
|
||||
expect(await page.locator('.media-card img').first().evaluate(image=>image.complete)).toBe(false);
|
||||
await expect(page.locator('.media-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:'Theme',exact:true}).selectOption(theme);
|
||||
await page.locator('.image-card .image-open').first().click();
|
||||
await expect(page.locator('.image-details h2')).toHaveText('study-01.png');
|
||||
await page.locator('.media-card .image-card-actions button:first-child').first().click();
|
||||
await expect(page.locator('.detail-panel .panel-heading 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');
|
||||
if(layout==='List') await expect(page.locator('.media-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());
|
||||
const longCard=page.locator('article[data-image-key]').filter({has:page.locator('[aria-label^="Open a-very-long-geometric-composition-"]')});
|
||||
const longName=(await longCard.locator('.image-open').getAttribute('aria-label')).replace(/^Open /,'');
|
||||
expect(longName).toMatch(/layered-context-.*\.png/);
|
||||
await longCard.locator('.image-card-actions button:first-child').click();
|
||||
await expect(page.locator('.detail-panel .panel-heading h2')).toHaveText(longName);
|
||||
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);
|
||||
await expect(page.locator('.media-card,.detail-panel,.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 page.getByRole('button',{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');
|
||||
// History opens on its first negative variant (concept navigate: state.variant = 0); the layout contract uses the whole gallery.
|
||||
// History opens on its first negative variant (concept navigate: state.variant = 0); the fixture's images are linked
|
||||
// to the positive only, so the layout contract uses the All images dialog's embedded gallery (#249 D26).
|
||||
await page.getByRole('button',{name:'All images for this positive',exact:true}).click();
|
||||
await expect(page.locator('article[data-image-key]').first()).toBeVisible();
|
||||
const listing=page.getByRole('dialog',{name:'All images for this positive',exact:true});
|
||||
await expect(listing.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 listing.getByRole('button',{name:layout,exact:true}).click();await orderedMedia(page);
|
||||
await expect(page.locator('h1')).toHaveText(title);
|
||||
await expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('One positive prompt. Reusable negative variants. One shared gallery.');
|
||||
await expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('Prompt history · one positive, one shared gallery');
|
||||
}
|
||||
await attachEvidence(page,testInfo,`after-images-embedded-history-${width}`,false);
|
||||
await page.keyboard.press('Escape');await expect(listing).toHaveCount(0);
|
||||
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
|
||||
await expect(page.locator('.image-card,.image-details,dialog[open]')).toHaveCount(0);
|
||||
await expect(page.locator('.media-card,.detail-panel,dialog[open]')).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
import {pageInventoryOf,inventoryOf,diffInventory,mapVocabularyConcept,mapVocabularyDialog} from './vocabulary-canonical-comparison.mjs';
|
||||
|
||||
@@ -24,13 +24,18 @@ async function expectLibrary(page,{tags='',category='',count}) {
|
||||
await expect(page).toHaveURL(/#\/library$/);
|
||||
await expect(main(page).getByRole('heading',{level:1})).toHaveText('Prompt library');
|
||||
await expect(main(page).getByRole('heading',{level:1})).toBeFocused();
|
||||
await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('');
|
||||
await expect(page.getByRole('textbox',{name:'Tags',exact:true})).toHaveValue(tags);
|
||||
const select=page.getByRole('combobox',{name:'Category',exact:true});
|
||||
await expect(page.getByRole('searchbox',{name:'Search workspace',exact:true})).toHaveValue('');
|
||||
const select=page.getByRole('combobox',{name:'Filter by category',exact:true});
|
||||
await expect(select).toHaveValue(category);
|
||||
if(!category) await expect(select.locator('option:checked')).toHaveText('All categories');
|
||||
await expect(page.locator('.library-caption')).toHaveText(`${count} of ${count} positive prompts · Images are grouped by positive prompt`);
|
||||
await expect(page.locator('#workspace-main .section-caption > span').first()).toHaveText(new RegExp(`^${count} positive prompts? · \\d+ negative variants?$`));
|
||||
await expect(page.locator('article[data-prompt-id]')).toHaveCount(count);
|
||||
// Browse sets exactly this Tags filter, which lives in "Refine this view" (#249 D14); Filters counts it (D44).
|
||||
const filters=page.locator('#workspace-main search.toolbar').getByRole('button',{name:tags?'Filters · 1':'Filters',exact:true});
|
||||
await expect(filters).toBeVisible();await filters.click();
|
||||
const refine=page.getByRole('dialog',{name:'Refine this view',exact:true});
|
||||
await expect(refine.getByRole('textbox',{name:'Tags',exact:true})).toHaveValue(tags);
|
||||
await refine.getByRole('button',{name:'Close dialog',exact:true}).click();await expect(refine).toHaveCount(0);
|
||||
}
|
||||
|
||||
let manifest;
|
||||
@@ -45,9 +50,12 @@ test('tags route renders the concept composition without a legacy handoff',async
|
||||
await expect(main(page).getByRole('heading',{level:1})).toHaveText('Tags & categories');
|
||||
await expect(main(page).locator(':scope > .heading p.muted')).toHaveText('Organize once. Reuse everywhere.');
|
||||
const actions=main(page).locator(':scope > .heading > .heading-actions button');
|
||||
await expect(actions).toHaveText(['+ Create tag']);
|
||||
// The concept's heading actions (#249 C2 retired #245 D2): Auto-tag images is primary; with no image selected in
|
||||
// Images it is aria-disabled (D115).
|
||||
await expect(actions).toHaveText(['+ Create tag','Auto-tag images']);
|
||||
await expect(actions.first()).not.toHaveClass(/ca-primary/);
|
||||
await expect(main(page).getByRole('button',{name:'Auto-tag images'})).toHaveCount(0);
|
||||
await expect(actions.last()).toHaveClass(/ca-primary/);
|
||||
await expect(actions.last()).toHaveAttribute('aria-disabled','true');
|
||||
await expect(surfaces(page)).toHaveCount(2);
|
||||
await expect(surfaces(page).locator('h2')).toHaveText(['Tag vocabulary','Categories']);
|
||||
await expect(surfaces(page).locator(':scope > p:visible')).toHaveText(['Category/tag autocomplete uses this same vocabulary across nodes and dialogs.',
|
||||
@@ -60,9 +68,9 @@ test('tags route renders the concept composition without a legacy handoff',async
|
||||
expect(manifest.tags).toEqual(expected.map(([name])=>name));
|
||||
await expect(rows(page).locator('td:nth-child(1)')).toHaveText(expected.map(([name])=>name));
|
||||
await expect(rows(page).locator('td:nth-child(2)')).toHaveText(expected.map(([,count])=>String(count)));
|
||||
const images=await rows(page).locator('td:nth-child(3)').evaluateAll(cells=>cells.map(cell=>[cell.querySelector('[aria-hidden="true"]')?.textContent,cell.querySelector('.ca-visually-hidden')?.textContent]));
|
||||
expect(images).toEqual(expected.map(()=>['—','Not available yet']));
|
||||
await expect(rows(page).first().locator('td:nth-child(3)')).toHaveAccessibleName('Not available yet');
|
||||
// Images counts the files carrying each tag (#249 C2 retired #245 D3); this fixture tags no image.
|
||||
await expect(rows(page).locator('td:nth-child(3)')).toHaveText(expected.map(()=>'0'));
|
||||
await expect(rows(page).locator('td:nth-child(3) .ca-visually-hidden')).toHaveCount(0);
|
||||
const labels=await rows(page).evaluateAll(nodes=>nodes.map(row=>[...row.querySelectorAll('td:nth-child(4) .row-actions button')].map(button=>button.textContent)));
|
||||
expect(labels).toEqual(expected.map(()=>['Browse','Rename / merge']));
|
||||
const operations=surfaces(page).nth(1).locator('.operation');
|
||||
@@ -85,9 +93,31 @@ test('prompt counts equal Library Browse results',async({page})=>{
|
||||
}
|
||||
});
|
||||
|
||||
// #249 D44: a Browse-set Tags filter is visible on the shelf as "Filters · 1", and Clear filters returns the full shelf.
|
||||
test('a tag Browse shows Filters · 1 until its filter is cleared',async({page})=>{
|
||||
await page.goto('/prompt_manager/workspace#/library');
|
||||
const shelf=page.locator('article[data-prompt-id]');await expect(shelf.first()).toBeVisible();const total=await shelf.count();
|
||||
const toolbar=page.locator('#workspace-main search.toolbar');
|
||||
await expect(toolbar.getByRole('button')).toHaveText(['Save view','Filters']);
|
||||
await page.goto(route);
|
||||
const row=tagRow(page,'cinematic');await row.getByRole('button',{name:'Browse',exact:true}).click();
|
||||
await expectLibrary(page,{tags:'cinematic',count:2});
|
||||
await expect(toolbar.getByRole('button')).toHaveText(['Save view','Filters · 1']);
|
||||
await toolbar.getByRole('button',{name:'Filters · 1',exact:true}).click();
|
||||
const refine=page.getByRole('dialog',{name:'Refine this view',exact:true});
|
||||
await refine.getByRole('button',{name:'Clear filters',exact:true}).click();await expect(refine).toHaveCount(0);
|
||||
await expect(toolbar.getByRole('button',{name:'Filters',exact:true})).toBeFocused();
|
||||
await expect(shelf).toHaveCount(total);
|
||||
// The cleared filter stays cleared on the next in-app visit (the shelf remembers its filters).
|
||||
const nav=page.getByRole('navigation',{name:'Workspace',exact:true});
|
||||
await nav.getByRole('link',{name:/^Tags & categories(?:\s*[\d,]+)?$/}).click();await expect(main(page).getByRole('heading',{level:1})).toHaveText('Tags & categories');
|
||||
await nav.getByRole('link',{name:/^Prompt library(?:\s*[\d,]+)?$/}).click();
|
||||
await expect(shelf).toHaveCount(total);await expect(toolbar.getByRole('button')).toHaveText(['Save view','Filters']);
|
||||
});
|
||||
|
||||
test('category Browse sets only the category constraint',async({page})=>{
|
||||
await page.goto('/prompt_manager/workspace#/library');
|
||||
const search=page.getByRole('searchbox',{name:'Search prompts',exact:true});
|
||||
const search=page.getByRole('searchbox',{name:'Search workspace',exact:true});
|
||||
await search.fill('observatory');await expect(page.locator('article[data-prompt-id]')).toHaveCount(1);
|
||||
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name: /^Tags & categories(?:\s*[\d,]+)?$/}).click();
|
||||
const row=categoryRow(page,'Landscapes');await expect(row.locator('p')).toHaveText('3 positive prompts');
|
||||
@@ -401,8 +431,8 @@ test('delete tag confirms real impact and removes only the vocabulary name',asyn
|
||||
expect(after.prompts.map(([id,text,category])=>[id,text,category])).toEqual(before.prompts.map(([id,text,category])=>[id,text,category]));
|
||||
await page.goto('/prompt_manager/workspace#/library');
|
||||
await expect(page.locator('article[data-prompt-id]')).toHaveCount(6);
|
||||
await expect(page.locator('.library-chip').filter({hasText:/^cinematic$/})).toHaveCount(0);
|
||||
await expect(page.locator('.library-chip').filter({hasText:/^blue_hour$/})).toHaveCount(1);
|
||||
await expect(page.locator('article[data-prompt-id] .tags .chip').filter({hasText:/^cinematic$/})).toHaveCount(0);
|
||||
await expect(page.locator('article[data-prompt-id] .tags .chip').filter({hasText:/^blue_hour$/})).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('cancel and escape write nothing and restore focus',async({page,request})=>{
|
||||
@@ -486,7 +516,7 @@ test('lost response is outcome-unknown',async({page,request})=>{
|
||||
|
||||
// Concept nav links end in their real `.nav-count` once it loads.
|
||||
const nav=(page,name)=>page.locator('aside.sidebar nav').getByRole('link',{name:new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')}(?:\\s*[\\d,]+)?$`)});
|
||||
const chips=(page,name)=>main(page).locator('article[data-prompt-id] .library-chip').filter({hasText:new RegExp(`^${name}$`)});
|
||||
const chips=(page,name)=>main(page).locator('article[data-prompt-id] :is(.card-title, .tags) > .chip').filter({hasText:new RegExp(`^${name}$`)});
|
||||
/** Visit the Library so its shelf is cached, then open Rename on blue_hour and start a rename whose PATCH is held. */
|
||||
async function heldRename(page) {
|
||||
await page.goto('/prompt_manager/workspace#/library');await expect(chips(page,'blue_hour')).toHaveCount(1);
|
||||
@@ -798,7 +828,7 @@ test('mutation check: the comparison reports a changed surface padding and a cha
|
||||
// Task A2: the workspace loads the verbatim concept stylesheet as its base and one overlay last; the screen and its
|
||||
// dialogs must stay equal to the concept with the retired vocabulary and editor sheets gone.
|
||||
const CONCEPT_BASE=['js/shared/toolkit/controls.css','lib/viewerjs/viewer.min.css','js/workspace/concept.css',
|
||||
'js/workspace/images.css','js/workspace/library.css','js/workspace/inspector.css','js/workspace/workspace.css'];
|
||||
'js/workspace/workspace.css'];
|
||||
const loadedSheets=page=>page.evaluate(()=>[...document.styleSheets].filter(sheet=>sheet.href).map(sheet=>new URL(sheet.href).pathname.replace(/^\/prompt_manager\//,'')));
|
||||
for(const [width,height,theme] of [[1600,1100,'dark'],[390,844,'light']])test(`concept base keeps the tags screen and editor dialogs equal to the concept ${width} ${theme}`,async({page})=>{
|
||||
await page.setViewportSize({width,height});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
|
||||
const workspace = '/prompt_manager/workspace';
|
||||
const statistic = (page, label) => page.locator('#workspace-main dt')
|
||||
@@ -11,11 +11,18 @@ const sidebarLink = (page, label) => page.locator('aside.sidebar nav').getByRole
|
||||
const sidebarLinks = page => page.locator('aside.sidebar nav a');
|
||||
const routes = [
|
||||
['overview', 'Overview', null],
|
||||
['images', 'Images', '/prompt_manager/gallery'],
|
||||
['library', 'Prompt library', '/prompt_manager/admin'],
|
||||
// D29: Images no longer links the legacy gallery; Overview's tool cards still do.
|
||||
['images', 'Images', null],
|
||||
// #249 E3: the shelf no longer links the legacy Library; Overview's tool card still does.
|
||||
['library', 'Prompt library', null],
|
||||
['tags', 'Tags & categories', null],
|
||||
['inspect', 'Inspect a file', '/prompt_manager/gallery.html'],
|
||||
// #249 local Inspect: the native Inspect a file page; Overview's tool card still links the legacy metadata page.
|
||||
['inspect', 'Inspect a file', null],
|
||||
['system', 'System', '/prompt_manager/admin'],
|
||||
// #249 D51: SAVED VIEWS' Favorites, Images filtered to favorites.
|
||||
['favorites', 'Favorites', null],
|
||||
// #249 Activity-1: the concept's Activity page in the tools-and-system group.
|
||||
['activity', 'Activity', null],
|
||||
];
|
||||
|
||||
async function countThemeWrites(page) {
|
||||
@@ -42,6 +49,8 @@ test.beforeEach(async ({ page, request, browserName }) => {
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page, browserName }) => {
|
||||
// A route handler still running at teardown must not fail the test it outlived.
|
||||
await page.unrouteAll({ behavior: 'ignoreErrors' });
|
||||
await saveCoverage(page, browserName);
|
||||
expect(page.workspaceErrors).toEqual([]);
|
||||
});
|
||||
@@ -72,7 +81,8 @@ test('all shell destinations deep-link, reload and expose working existing appli
|
||||
const main = page.locator('#workspace-main');
|
||||
await expect(main.getByRole('heading', { level: 1, name: label, exact: true })).toBeVisible();
|
||||
await expect(sidebarLink(page, label)).toHaveAttribute('aria-current', 'page');
|
||||
if (route === 'tags') await expect(main.locator('.handoff')).toHaveCount(0);
|
||||
if (route === 'tags' || route === 'inspect') await expect(main.locator('.handoff')).toHaveCount(0);
|
||||
if (route === 'inspect') await expect(main.locator('#local-file')).toBeAttached();
|
||||
if (legacy) {
|
||||
await expect(main.locator(`a[href="${legacy}"]`)).toBeVisible();
|
||||
await expect(await request.get(legacy.split('#')[0])).toBeOK();
|
||||
@@ -80,6 +90,20 @@ test('all shell destinations deep-link, reload and expose working existing appli
|
||||
await page.reload();
|
||||
await expect(main.getByRole('heading', { level: 1, name: label, exact: true })).toBeVisible();
|
||||
}
|
||||
// D29 and #249 E3: the legacy gallery and Library are reached from Overview's tool cards, not from their pages.
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
const library = page.locator('#workspace-main').getByRole('link', { name: 'Open existing prompt library', exact: true });
|
||||
await expect(library).toHaveAttribute('href', '/prompt_manager/admin');
|
||||
await expect(await request.get('/prompt_manager/admin')).toBeOK();
|
||||
// The legacy metadata page stays reachable from Overview beside the native Inspect page.
|
||||
const metadata = page.locator('#workspace-main').getByRole('link', { name: 'Open existing metadata inspector', exact: true });
|
||||
await expect(metadata).toHaveAttribute('href', '/prompt_manager/gallery.html');
|
||||
await expect(await request.get('/prompt_manager/gallery.html')).toBeOK();
|
||||
const gallery = page.locator('#workspace-main').getByRole('link', { name: 'Open existing image gallery', exact: true });
|
||||
await expect(gallery).toHaveAttribute('href', '/prompt_manager/gallery');
|
||||
await expect(await request.get('/prompt_manager/gallery')).toBeOK();
|
||||
await gallery.click();
|
||||
await expect(page).toHaveURL(/\/prompt_manager\/gallery$/);
|
||||
});
|
||||
|
||||
test('keyboard navigation, browser history and unknown routes remain usable', async ({ page }) => {
|
||||
@@ -162,7 +186,8 @@ test('theme System follows ComfyUI and persists an explicit light or dark prefer
|
||||
await expect(theme).toHaveAttribute('data-ca-theme', 'light');
|
||||
});
|
||||
|
||||
// Task A4 (D4, D5): the concept topbar (index.html 45-63, style.css 225-277) without the prototype badge or Activity.
|
||||
// Task A4 (D4, D29): the concept topbar (index.html 45-63, style.css 225-277) without the prototype badge; Activity
|
||||
// (D52, D53) shows its button, and its pill only while a job runs.
|
||||
test('topbar matches the concept actions', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
await page.goto(`${workspace}#/images`);
|
||||
@@ -190,7 +215,7 @@ test('topbar matches the concept actions', async ({ page }) => {
|
||||
expect(shape).toEqual({
|
||||
children: ['div.breadcrumbs', 'div.top-actions'],
|
||||
crumbs: ['ComfyAssets', 'span:/', 'span#workspace-breadcrumb:Images'],
|
||||
actions: ['label.theme-label', 'a.canvas-link'],
|
||||
actions: ['a.activity-pill', 'button.', 'label.theme-label', 'a.canvas-link'],
|
||||
label: 'Theme', labelFor: 'theme',
|
||||
options: [['dark', 'Dark'], ['light', 'Light'], ['system', 'System']],
|
||||
arrow: [['↗', 'true']],
|
||||
@@ -204,11 +229,12 @@ test('topbar matches the concept actions', async ({ page }) => {
|
||||
await expect(link).toHaveAttribute('href', '/');
|
||||
expect(await link.evaluate(node => node.href === `${location.origin}/`)).toBe(true);
|
||||
await expect(page.getByRole('combobox', { name: 'Appearance' })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: /^Activity/ })).toHaveCount(0);
|
||||
await expect(page.locator('.concept-badge, #activity-open')).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: 'Activity', exact: true })).toHaveCount(1);
|
||||
await expect(page.locator('#activity-pill')).toBeHidden();
|
||||
await expect(page.locator('.concept-badge')).toHaveCount(0);
|
||||
await page.locator('nav [data-route="library"]').click();
|
||||
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Prompt library');
|
||||
await expect(topbar.locator('.top-actions > *')).toHaveCount(2);
|
||||
await expect(topbar.locator('.top-actions > *')).toHaveCount(4);
|
||||
});
|
||||
|
||||
// Concept style.css 1375-1391: at <=700px the breadcrumbs hide, the actions spread across the bar and the Theme
|
||||
@@ -228,13 +254,18 @@ test('mobile topbar hides breadcrumbs and spreads actions', async ({ page }) =>
|
||||
expected: bar.width - 28,
|
||||
label: getComputedStyle(node.querySelector('.theme-label')).fontSize,
|
||||
select: getComputedStyle(node.querySelector('#theme')).fontSize,
|
||||
labelLeft: box(node.querySelector('.theme-label')).left - bar.left,
|
||||
activityLeft: box(node.querySelector('#activity-open')).left - bar.left,
|
||||
actionsGap: [box(node.querySelector('#activity-open')).right, box(node.querySelector('.theme-label')).left,
|
||||
box(node.querySelector('.theme-label')).right, box(node.querySelector('.canvas-link')).left],
|
||||
linkRight: bar.right - box(node.querySelector('.canvas-link')).right,
|
||||
};
|
||||
});
|
||||
expect(layout).toMatchObject({ padding: '14px', actions: { justify: 'space-between', gap: '8px' }, label: '0px', select: '11px' });
|
||||
expect(Math.abs(layout.actions.width - layout.expected)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(layout.labelLeft - 14)).toBeLessThanOrEqual(1);
|
||||
// The concept's three items spread with space-between: Activity first, Theme between equal gaps, ComfyUI last.
|
||||
expect(Math.abs(layout.activityLeft - 14)).toBeLessThanOrEqual(1);
|
||||
const [activityRight, themeLeft, themeRight, linkLeft] = layout.actionsGap;
|
||||
expect(Math.abs((themeLeft - activityRight) - (linkLeft - themeRight))).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(layout.linkRight - 14)).toBeLessThanOrEqual(1);
|
||||
await expect(page.getByRole('link', { name: 'ComfyUI', exact: true })).toBeVisible();
|
||||
await theme.selectOption('light');
|
||||
@@ -308,7 +339,7 @@ test('leaving a loading overview prevents its delayed result from replacing the
|
||||
await expect(page.locator('#primary-nav')).toHaveCount(1);
|
||||
await expect(page.locator('#system-nav')).toHaveCount(1);
|
||||
await expect(page.getByRole('combobox', { name: 'Theme', exact: true })).toHaveCount(1);
|
||||
await expect(page.locator('#workspace-main a[href="/prompt_manager/gallery"]')).toBeVisible();
|
||||
await expect(page.locator('#workspace-main .section-caption')).toBeVisible();
|
||||
await countThemeWrites(page);
|
||||
await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption('light');
|
||||
expect(await page.evaluate(() => window.__themeChanges)).toBe(1);
|
||||
@@ -415,10 +446,13 @@ test('sidebar matches the concept groups and shows real counts', async ({ page,
|
||||
await expect(page.locator('body > .app-shell > .workspace > #workspace-main')).toHaveCount(1);
|
||||
await expect(page.locator('.workspace-shell, .workspace-body, .sidebar-footer, .connection-dot, .workspace-icon')).toHaveCount(0);
|
||||
const sidebar = page.locator('aside.sidebar');
|
||||
expect(await childShape(sidebar)).toEqual(['a.brand', 'div.sidebar-label', 'nav#primary-nav', 'div.sidebar-bottom']);
|
||||
expect(await childShape(sidebar)).toEqual(['a.brand', 'div.sidebar-label', 'nav#primary-nav', 'div.sidebar-label',
|
||||
'nav#saved-nav', 'div.sidebar-bottom']);
|
||||
await expect(sidebar.locator('.brand')).toHaveAttribute('href', '#/images');
|
||||
await expect(sidebar.locator('.sidebar-label')).toHaveText(['WORKSPACE']);
|
||||
await expect(page.locator('#saved-nav, #collections-nav, .new-label')).toHaveCount(0);
|
||||
// D49: SAVED VIEWS without the concept's PROPOSED badge; D28: still no COLLECTIONS.
|
||||
await expect(sidebar.locator('.sidebar-label')).toHaveText(['WORKSPACE', 'SAVED VIEWS']);
|
||||
await expect(page.locator('nav#saved-nav')).toHaveAttribute('aria-label', 'Saved views');
|
||||
await expect(page.locator('#collections-nav, .new-label')).toHaveCount(0);
|
||||
const primary = page.locator('nav#primary-nav');
|
||||
await expect(primary).toHaveAttribute('aria-label', 'Workspace');
|
||||
for (const [id] of NAV_TOTALS) await expect(navCount(page, id)).toHaveText(totals[id].toLocaleString('en-US'));
|
||||
@@ -445,7 +479,7 @@ test('sidebar matches the concept groups and shows real counts', async ({ page,
|
||||
// At phone width the concept wraps the links and hides counts and the connection (concept 950px block).
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await expect(navCount(page, 'library')).toBeHidden();
|
||||
await expect(page.locator('.sidebar-label, .connection')).toHaveCount(2);
|
||||
await expect(page.locator('.sidebar-label, .connection')).toHaveCount(3);
|
||||
for (const hidden of await page.locator('.sidebar-label, .connection').all()) await expect(hidden).toBeHidden();
|
||||
await expect(primary.locator('a[href="#/inspect"]')).toBeVisible();
|
||||
});
|
||||
@@ -489,18 +523,18 @@ test('a slow Images count does not delay the Library and Tags badges', async ({
|
||||
await expect(navCount(page, 'images')).toHaveText(String(totals.images));
|
||||
});
|
||||
|
||||
test('sidebar bottom holds System and Overview with a neutral connection', async ({ page }) => {
|
||||
test('sidebar bottom holds Activity, System and Overview with a neutral connection', async ({ page }) => {
|
||||
await page.goto(`${workspace}#/system`);
|
||||
const bottom = page.locator('aside.sidebar > .sidebar-bottom');
|
||||
expect(await childShape(bottom)).toEqual(['nav#system-nav', 'div.connection']);
|
||||
const system = page.locator('nav#system-nav');
|
||||
await expect(system).toHaveAttribute('aria-label', 'Tools and system');
|
||||
expect(await system.locator(':scope > a').evaluateAll(nodes => nodes.map(node => [node.getAttribute('href'), node.textContent]))).toEqual([
|
||||
['#/system', 'System'], ['#/overview', 'Overview'],
|
||||
['#/activity', 'Activity'], ['#/system', 'System'], ['#/overview', 'Overview'],
|
||||
]);
|
||||
await expect(system.locator('.nav-count')).toHaveCount(0);
|
||||
await expect(system.locator('a[href="#/system"]')).toHaveAttribute('aria-current', 'page');
|
||||
await expect(system.locator('svg.icon')).toHaveCount(2);
|
||||
await expect(system.locator('svg.icon')).toHaveCount(3);
|
||||
const connection = bottom.locator('.connection');
|
||||
expect(await childShape(connection)).toEqual(['i', 'div']);
|
||||
await expect(connection.locator(':scope > div')).toHaveText('ComfyUI workspaceYour library, on your server');
|
||||
@@ -662,8 +696,14 @@ async function serveUnderProxyMount(page) {
|
||||
await page.route(url => url.pathname.startsWith(`${PROXY_MOUNT}/`), async route => {
|
||||
const url = new URL(route.request().url());
|
||||
url.pathname = url.pathname.slice(PROXY_MOUNT.length);
|
||||
const response = await route.fetch({ url: url.href });
|
||||
await route.fulfill({ response });
|
||||
// A request still in flight when the test ends (a thumbnail the last route started) meets a page that is
|
||||
// closing: its fetched response is disposed under it. Only that teardown error is ignored.
|
||||
try {
|
||||
const response = await route.fetch({ url: url.href });
|
||||
await route.fulfill({ response });
|
||||
} catch (error) {
|
||||
if (!/has been disposed|has been closed|Target page, context or browser has been closed/.test(String(error?.message))) throw error;
|
||||
}
|
||||
});
|
||||
return escaped;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Fake Activity jobs for the browser tests (#249 Activity-1).
|
||||
|
||||
A fake job is a real registry job whose runner waits for commands instead of
|
||||
reading files, so a test sets its progress and counters exactly:
|
||||
|
||||
- ``POST /__test__/jobs/start`` starts one (``kind``, ``title``, ``total``,
|
||||
``output_folders``) and returns its summary, or 409 while one runs;
|
||||
- ``POST /__test__/jobs/{id}/step`` records ``records`` (``{group, path,
|
||||
root, root_index, detail}`` file outcomes, counted, listed and logged as the scan
|
||||
records them) and ``bulk`` (``{group, count, root}``: ``count`` numbered
|
||||
rows), sets ``processed`` (and ``total``) and ``counters``, then publishes
|
||||
the progress to every subscriber;
|
||||
- ``POST /__test__/jobs/{id}/finish`` ends it as ``finished``, ``cancelled``
|
||||
or ``failed``; a cancel through the real route ends it ``cancelled``;
|
||||
- ``POST /__test__/jobs/restart`` acts out a ComfyUI restart: the open job
|
||||
streams drop without a final event and the registry starts empty.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
EVENTS_PATH = re.compile(r"^/prompt_manager/jobs/[0-9a-f]{32}/events$")
|
||||
FINAL_STATES = {"finished", "cancelled", "failed"}
|
||||
POLL_SECONDS = 0.02
|
||||
|
||||
|
||||
PREFIXES = {"added": "+", "known": "=", "review": "?", "errors": "!"}
|
||||
PREFIXES["no_metadata"] = "-"
|
||||
ROOT_INDEXES = {"output": 0, "input": 1}
|
||||
|
||||
|
||||
def outcome_records(data):
|
||||
"""The step's file outcomes: its ``records``, then its ``bulk`` rows."""
|
||||
records = data.get("records", [])
|
||||
bulk = data.get("bulk")
|
||||
if not isinstance(records, list):
|
||||
raise web.HTTPBadRequest()
|
||||
rows = []
|
||||
for record in records:
|
||||
if not isinstance(record, dict) or record.get("group") not in PREFIXES:
|
||||
raise web.HTTPBadRequest()
|
||||
root = str(record.get("root", "output"))
|
||||
rows.append(
|
||||
{
|
||||
"group": record["group"],
|
||||
"path": str(record.get("path", "")),
|
||||
"root": root,
|
||||
# The fixture's listing order: output is root 0, input root 1.
|
||||
"root_index": record.get("root_index", ROOT_INDEXES.get(root)),
|
||||
"detail": str(record.get("detail", "")),
|
||||
}
|
||||
)
|
||||
if bulk is not None:
|
||||
if not isinstance(bulk, dict) or bulk.get("group") not in PREFIXES:
|
||||
raise web.HTTPBadRequest()
|
||||
total = bulk.get("count")
|
||||
if isinstance(total, bool) or not isinstance(total, int) or total < 0:
|
||||
raise web.HTTPBadRequest()
|
||||
rows.extend(
|
||||
{
|
||||
"group": bulk["group"],
|
||||
"path": f"bulk/file_{index:05d}.png",
|
||||
"root": str(bulk.get("root", "output")),
|
||||
"root_index": 0,
|
||||
"detail": f"Bulk row {index}",
|
||||
}
|
||||
for index in range(total)
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def register_activity_commands(app, routes, api):
|
||||
from py.api.jobs import SCAN_GROUP_NAMES, JobBusy, JobRegistry
|
||||
|
||||
streams = set()
|
||||
endings = {}
|
||||
|
||||
@web.middleware
|
||||
async def track_streams(request, handler):
|
||||
if not EVENTS_PATH.match(request.path):
|
||||
return await handler(request)
|
||||
transport = request.transport
|
||||
streams.add(transport)
|
||||
try:
|
||||
return await handler(request)
|
||||
finally:
|
||||
streams.discard(transport)
|
||||
|
||||
app.middlewares.append(track_streams)
|
||||
|
||||
def fake_runner(ending):
|
||||
async def run(job):
|
||||
while not ending.done() and not job.cancelled:
|
||||
await asyncio.sleep(POLL_SECONDS)
|
||||
state = ending.result() if ending.done() else "cancelled"
|
||||
job.publish({"type": "complete", "progress": job.progress})
|
||||
job.finish(state)
|
||||
|
||||
return run
|
||||
|
||||
def job_or_404(request):
|
||||
job = api._jobs.get(request.match_info["job_id"])
|
||||
if job is None or job.id not in endings:
|
||||
raise web.HTTPNotFound()
|
||||
return job
|
||||
|
||||
async def body(request):
|
||||
data = await request.json() if request.can_read_body else {}
|
||||
if not isinstance(data, dict):
|
||||
raise web.HTTPBadRequest()
|
||||
return data
|
||||
|
||||
def count(value, default=0):
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
||||
raise web.HTTPBadRequest()
|
||||
return value
|
||||
|
||||
@routes.post("/__test__/jobs/start")
|
||||
async def start_job(request):
|
||||
data = await body(request)
|
||||
ending = asyncio.get_running_loop().create_future()
|
||||
try:
|
||||
job = api._jobs.start(
|
||||
str(data.get("kind", "scan")),
|
||||
str(data.get("title", "Scan for metadata")),
|
||||
record_changing=True,
|
||||
scope={
|
||||
"files": str(data.get("files", "new")),
|
||||
"output_folders": count(data.get("output_folders"), 2),
|
||||
},
|
||||
runner=fake_runner(ending),
|
||||
groups=SCAN_GROUP_NAMES,
|
||||
)
|
||||
except JobBusy:
|
||||
raise web.HTTPConflict()
|
||||
job.update(total=count(data.get("total")))
|
||||
endings[job.id] = ending
|
||||
return web.json_response({"success": True, "job": job.summary()})
|
||||
|
||||
@routes.post("/__test__/jobs/{job_id}/step")
|
||||
async def step_job(request):
|
||||
job = job_or_404(request)
|
||||
data = await body(request)
|
||||
total = count(data.get("total"), job.total)
|
||||
processed = count(data.get("processed"), job.processed)
|
||||
counters = data.get("counters", {})
|
||||
if not isinstance(counters, dict) or set(counters) - set(job.counters):
|
||||
raise web.HTTPBadRequest()
|
||||
for record in outcome_records(data):
|
||||
group = record["group"]
|
||||
path = record["path"]
|
||||
detail = record["detail"]
|
||||
job.record(group, record, f"{PREFIXES[group]} {path} · {detail}")
|
||||
for key, value in counters.items():
|
||||
job.counters[key] = count(value)
|
||||
progress = processed * 100 // total if total else 0
|
||||
job.update(
|
||||
progress=progress,
|
||||
processed=processed,
|
||||
total=total,
|
||||
status_line=f"Processing file {processed:,} of {total:,}",
|
||||
)
|
||||
job.publish({"type": "progress", "progress": progress})
|
||||
return web.json_response({"success": True, "job": job.summary()})
|
||||
|
||||
@routes.post("/__test__/jobs/{job_id}/finish")
|
||||
async def finish_job(request):
|
||||
job = job_or_404(request)
|
||||
state = (await body(request)).get("state", "finished")
|
||||
if state not in FINAL_STATES:
|
||||
raise web.HTTPBadRequest()
|
||||
ending = endings[job.id]
|
||||
if not ending.done():
|
||||
ending.set_result(state)
|
||||
while job.running:
|
||||
await asyncio.sleep(POLL_SECONDS)
|
||||
return web.json_response({"success": True, "job": job.summary()})
|
||||
|
||||
@routes.post("/__test__/jobs/restart")
|
||||
async def restart(request):
|
||||
old = api._jobs
|
||||
api._jobs = JobRegistry(api.logger)
|
||||
endings.clear()
|
||||
for transport in list(streams):
|
||||
transport.close()
|
||||
tasks = [job.task for job in old.list() if job.task and not job.task.done()]
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
return web.json_response({"success": True})
|
||||
@@ -155,6 +155,12 @@ CONCEPT_RUN_COUNT = 4
|
||||
# The concept's Model fact (app.js:408).
|
||||
CONCEPT_MODEL = "illustrative-model.safetensors"
|
||||
MAX_EXTRA_IMAGES = 120
|
||||
# Unlinked, seedless images (D11 "W × H · PNG") newer than the concept's, for D3 checks.
|
||||
MAX_SEEDLESS_IMAGES = 2
|
||||
SEEDLESS_SIZE = (1536, 1152)
|
||||
# The same 4:3 shape with a short "96 × 72 · PNG" subtitle that stays on one line at
|
||||
# 390px beside the card's Info and Tag (the D3 footer check).
|
||||
SHORT_SEEDLESS_SIZE = (96, 72)
|
||||
SESSION = "5c1f9a7e-3f0b-4a55-9d0e-2b8c4f6a7d10"
|
||||
_TABLES = (
|
||||
"execution_run_outputs",
|
||||
@@ -165,6 +171,11 @@ _TABLES = (
|
||||
"execution_contexts",
|
||||
"execution_prompt_snapshots",
|
||||
"execution_context_texts",
|
||||
"saved_views",
|
||||
"image_favorites",
|
||||
"media_image_tags",
|
||||
"media_annotation_bytes",
|
||||
"media_annotation_files",
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
"prompt_pairs",
|
||||
@@ -176,6 +187,11 @@ _TABLES = (
|
||||
)
|
||||
|
||||
|
||||
def concept_starred(prompt_index: int, round_index: int) -> bool:
|
||||
"""The concept's favorites (app.js ``starred``): each other prompt's first image."""
|
||||
return round_index == 0 and prompt_index % 2 == 0
|
||||
|
||||
|
||||
def concept_seed(prompt_index: int, round_index: int) -> int:
|
||||
"""The concept's sample seed (app.js:185)."""
|
||||
return 42001 + prompt_index * 100 + round_index
|
||||
@@ -313,23 +329,45 @@ def _record_run(
|
||||
return start.run_id
|
||||
|
||||
|
||||
def _write_seedless_png(path: Path, size: tuple[int, int] = SEEDLESS_SIZE) -> None:
|
||||
"""A plain PNG with no metadata: no prompt, no seed, no link."""
|
||||
with Image.new("RGB", size, "#2b3442") as image:
|
||||
image.save(path)
|
||||
|
||||
|
||||
def _bounded(name: str, value: int, maximum: int) -> None:
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise TypeError(f"{name} must be an integer")
|
||||
if not 0 <= value <= maximum:
|
||||
raise ValueError(f"{name} is out of range")
|
||||
|
||||
|
||||
def seed_workspace_concept(
|
||||
api: PromptManagerAPI, root: Path, *, extra_images: int = 0
|
||||
api: PromptManagerAPI,
|
||||
root: Path,
|
||||
*,
|
||||
extra_images: int = 0,
|
||||
seedless_images: int = 0,
|
||||
short_seedless: bool = False,
|
||||
) -> dict:
|
||||
"""Replace this server's disposable rows and files with the concept's data.
|
||||
|
||||
``extra_images`` continues the concept's rounds (same prompts, dimensions and
|
||||
seed rule) after its 24 images, for bounded-rendering and Load more checks.
|
||||
``seedless_images`` adds unlinked PNGs without metadata, newest of all;
|
||||
``short_seedless`` makes them ``SHORT_SEEDLESS_SIZE`` instead.
|
||||
"""
|
||||
if isinstance(extra_images, bool) or not isinstance(extra_images, int):
|
||||
raise TypeError("extra_images must be an integer")
|
||||
if not 0 <= extra_images <= MAX_EXTRA_IMAGES:
|
||||
raise ValueError("extra_images is out of range")
|
||||
if not isinstance(short_seedless, bool):
|
||||
raise TypeError("short_seedless must be a boolean")
|
||||
_bounded("extra_images", extra_images, MAX_EXTRA_IMAGES)
|
||||
_bounded("seedless_images", seedless_images, MAX_SEEDLESS_IMAGES)
|
||||
_clear(api, root)
|
||||
db = api.db
|
||||
epoch = db.get_execution_epoch()
|
||||
prompts = []
|
||||
for prompt in CONCEPT_PROMPTS:
|
||||
# The concept lists its prompts most recent first (renderLibrary under Recently
|
||||
# added keeps the array order), so they are created last to first.
|
||||
for prompt in reversed(CONCEPT_PROMPTS):
|
||||
first_name, first_text = prompt.negatives[0]
|
||||
receipt = db.create_library_recipe(
|
||||
prompt.positive,
|
||||
@@ -349,61 +387,91 @@ def seed_workspace_concept(
|
||||
pair_ids.append(db.save_prompt_pair(prompt.positive, text, title=name))
|
||||
# Real queue-hook counting (D23): four runs, the last one now.
|
||||
db.record_prompt_use(positive_id, times=CONCEPT_RUN_COUNT)
|
||||
prompts.append(
|
||||
{"id": prompt.id, "positive_id": positive_id, "pair_ids": pair_ids}
|
||||
prompts.insert(
|
||||
0, {"id": prompt.id, "positive_id": positive_id, "pair_ids": pair_ids}
|
||||
)
|
||||
|
||||
folder = root / "output" / "concepts"
|
||||
folder.mkdir()
|
||||
roots = snapshot_output_roots("posix", str(root / "output"), str(root / "temp"))
|
||||
# The API package needs the host stubs the server installs before importing it.
|
||||
from py.api.images import _favorite_relative_key, _favorite_root_key
|
||||
|
||||
favorite_root = _favorite_root_key(list(api._get_all_output_dirs())[0])
|
||||
total = len(CONCEPT_PROMPTS) * CONCEPT_IMAGE_ROUNDS + extra_images
|
||||
rounds = math.ceil(total / len(CONCEPT_PROMPTS))
|
||||
now = time.time()
|
||||
images, runs = [], []
|
||||
for round_index in range(rounds):
|
||||
for prompt_index, prompt in enumerate(CONCEPT_PROMPTS):
|
||||
index = round_index * len(CONCEPT_PROMPTS) + prompt_index
|
||||
if index >= total:
|
||||
break
|
||||
name = concept_filename(prompt, round_index)
|
||||
path = folder / name
|
||||
facts = write_concept_png(path, prompt_index, round_index)
|
||||
variant, negative, seed = facts["variant"], facts["negative"], facts["seed"]
|
||||
# The concept lists images newest first in this order.
|
||||
stamp = now - 60 - index * 60
|
||||
os.utime(path, (stamp, stamp))
|
||||
owner = prompts[prompt_index]
|
||||
image_id = db.link_image_to_prompt(owner["positive_id"], str(path))
|
||||
pair_id = owner["pair_ids"][variant]
|
||||
db.link_image_to_pair(pair_id, image_id, {"source": "concept fixture"})
|
||||
context = RecipeContext(
|
||||
"3", _snapshot(prompt.positive), _snapshot(negative), "inferred"
|
||||
slots = [
|
||||
(round_index, prompt_index, prompt)
|
||||
for round_index in range(rounds)
|
||||
for prompt_index, prompt in enumerate(CONCEPT_PROMPTS)
|
||||
if round_index * len(CONCEPT_PROMPTS) + prompt_index < total
|
||||
]
|
||||
# Linked and recorded oldest first, as real runs arrive, so link and run ids
|
||||
# grow with time and every newest-first read (by id or by mtime) lists the
|
||||
# concept's order (app.js:180-239). The manifest keeps the concept's order.
|
||||
for round_index, prompt_index, prompt in reversed(slots):
|
||||
index = round_index * len(CONCEPT_PROMPTS) + prompt_index
|
||||
name = concept_filename(prompt, round_index)
|
||||
path = folder / name
|
||||
facts = write_concept_png(path, prompt_index, round_index)
|
||||
variant, negative, seed = facts["variant"], facts["negative"], facts["seed"]
|
||||
# The concept lists images newest first in this order.
|
||||
stamp = now - 60 - index * 60
|
||||
os.utime(path, (stamp, stamp))
|
||||
owner = prompts[prompt_index]
|
||||
image_id = db.link_image_to_prompt(owner["positive_id"], str(path))
|
||||
pair_id = owner["pair_ids"][variant]
|
||||
db.link_image_to_pair(pair_id, image_id, {"source": "concept fixture"})
|
||||
# Real pair counting: each image is one run of its pair, as the
|
||||
# concept's "<name> · N runs" counts its images (app.js a598, D26).
|
||||
db.record_prompt_pair_use(pair_id)
|
||||
if concept_starred(prompt_index, round_index):
|
||||
db.set_image_favorite(
|
||||
favorite_root, _favorite_relative_key(f"concepts/{name}")
|
||||
)
|
||||
reference = OutputReference(
|
||||
"4", 0, "output", "concepts", name, "posix", roots.output_key
|
||||
)
|
||||
runs.append(
|
||||
_record_run(
|
||||
api,
|
||||
epoch=epoch,
|
||||
item=index,
|
||||
context=context,
|
||||
output=reference,
|
||||
observed_at=int(stamp * 1000),
|
||||
)
|
||||
)
|
||||
images.append(
|
||||
{
|
||||
"filename": name,
|
||||
"image_id": image_id,
|
||||
"pair_id": pair_id,
|
||||
"prompt": prompt.id,
|
||||
"variant": variant,
|
||||
"seed": seed,
|
||||
"width": facts["width"],
|
||||
"height": facts["height"],
|
||||
}
|
||||
context = RecipeContext(
|
||||
"3", _snapshot(prompt.positive), _snapshot(negative), "inferred"
|
||||
)
|
||||
reference = OutputReference(
|
||||
"4", 0, "output", "concepts", name, "posix", roots.output_key
|
||||
)
|
||||
runs.append(
|
||||
_record_run(
|
||||
api,
|
||||
epoch=epoch,
|
||||
item=index,
|
||||
context=context,
|
||||
output=reference,
|
||||
observed_at=int(stamp * 1000),
|
||||
)
|
||||
)
|
||||
images.append(
|
||||
{
|
||||
"filename": name,
|
||||
"image_id": image_id,
|
||||
"pair_id": pair_id,
|
||||
"prompt": prompt.id,
|
||||
"variant": variant,
|
||||
"seed": seed,
|
||||
"path": f"concepts/{name}",
|
||||
"width": facts["width"],
|
||||
"height": facts["height"],
|
||||
}
|
||||
)
|
||||
images.reverse()
|
||||
runs.reverse()
|
||||
seedless = []
|
||||
for number in range(seedless_images):
|
||||
name = f"seedless_{number + 1:05}.png"
|
||||
path = folder / name
|
||||
_write_seedless_png(
|
||||
path, SHORT_SEEDLESS_SIZE if short_seedless else SEEDLESS_SIZE
|
||||
)
|
||||
stamp = now - 10 - number * 10
|
||||
os.utime(path, (stamp, stamp))
|
||||
seedless.append({"filename": name, "path": f"concepts/{name}"})
|
||||
with db.model.get_connection() as conn:
|
||||
counts = conn.execute(
|
||||
"SELECT COUNT(*), MIN(run_count), MAX(run_count) FROM prompts "
|
||||
@@ -416,6 +484,9 @@ def seed_workspace_concept(
|
||||
api.invalidate_gallery_cache()
|
||||
return {
|
||||
"epoch": epoch,
|
||||
# The newest file's mtime (epoch seconds), for the D17 caption date.
|
||||
"newest_modified": now - 10 if seedless_images else now - 60,
|
||||
"seedless": seedless,
|
||||
"prompts": prompts,
|
||||
"images": images,
|
||||
"runs": runs,
|
||||
|
||||
@@ -479,8 +479,14 @@ def seed_workspace_history(
|
||||
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")
|
||||
clean_pair = api.db.save_prompt_pair(
|
||||
canonical_positive, "flat light", title="Clean geometry"
|
||||
)
|
||||
api.db.save_prompt_pair(canonical_positive, "", title="No negative")
|
||||
# Queue-hook pair counts (#249 D26): both lanes show the repeat-run dot; only
|
||||
# the baseline has a recorded execution, so Clean geometry opens Repeat run.
|
||||
api.db.record_prompt_pair_use(canonical_pair, 2)
|
||||
api.db.record_prompt_pair_use(clean_pair, 2)
|
||||
for number, name in enumerate(("canonical.png", "canonical-alt.png")):
|
||||
path = folder / name
|
||||
_write_art(path, number, canonical_positive, canonical_negative)
|
||||
@@ -500,6 +506,8 @@ def seed_workspace_history(
|
||||
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"})
|
||||
if number:
|
||||
api.db.link_image_to_pair(clean_pair, image_id, {"source": "fixture"})
|
||||
canonical_run = run(
|
||||
(
|
||||
RecipeContext(
|
||||
@@ -529,6 +537,7 @@ def seed_workspace_history(
|
||||
"detail_image_id": current,
|
||||
"canonical_id": canonical_id,
|
||||
"canonical_pair": canonical_pair,
|
||||
"clean_pair": clean_pair,
|
||||
"canonical_run": canonical_run,
|
||||
"canonical_positive": canonical_positive,
|
||||
"canonical_negative": canonical_negative,
|
||||
|
||||
@@ -26,6 +26,11 @@ EDITOR_METADATA = {
|
||||
"notes": "Keep the soft light.",
|
||||
}
|
||||
_TABLES = (
|
||||
"saved_views",
|
||||
"image_favorites",
|
||||
"media_image_tags",
|
||||
"media_annotation_bytes",
|
||||
"media_annotation_files",
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
"prompt_pairs",
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
"""Owned, deterministic files for the Tag image dialog workflows (#249 PR C).
|
||||
|
||||
Two files share one positive prompt and one saved pair, an equal-byte copy of
|
||||
the first sits at another path, and a video and an unlinked file follow. The
|
||||
injected fake model (``tests/browser/server.py`` ``TestModel``) answers
|
||||
Generate; no model file exists and nothing is ever downloaded.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from aiohttp import web
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from py.api import PromptManagerAPI
|
||||
|
||||
POSITIVE = (
|
||||
"Monolithic sandstone pavilion in a vast desert, sweeping dunes, warm "
|
||||
"terracotta, late afternoon sun, architectural photography, quiet geometry"
|
||||
)
|
||||
NEGATIVE = "plastic, harsh shadows, noise, watermark"
|
||||
TITLE = "Quiet geometry"
|
||||
PROMPT_TAGS = ["minimal", "warm_light"]
|
||||
PAIR_TAGS = ["editorial"]
|
||||
# Newest first, so the Images order (Recently added) is the manifest's.
|
||||
FILES = (
|
||||
("tagging/dunes_00001.png", "linked"),
|
||||
("tagging/dunes_00002.png", "linked"),
|
||||
("copies/dunes_00001.png", "copy"),
|
||||
("tagging/clip.webm", "video"),
|
||||
("loose/unlinked.png", "unlinked"),
|
||||
)
|
||||
MODELS = ("wd14-swinv2", "wd14-vit", "gguf", "hf")
|
||||
# Unlinked copies enough to virtualize the Images grid (it renders all up to 120).
|
||||
MAX_EXTRA_IMAGES = 160
|
||||
_TABLES = (
|
||||
"saved_views",
|
||||
"image_favorites",
|
||||
"media_image_tags",
|
||||
"media_annotation_bytes",
|
||||
"media_annotation_files",
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
"prompt_pairs",
|
||||
"generated_images",
|
||||
"prompt_tags",
|
||||
"tags",
|
||||
"prompt_library_state",
|
||||
"prompts",
|
||||
)
|
||||
# Every prompt-side owner row a Tag save could touch, for before/after
|
||||
# comparisons: tagging one image never changes a prompt, a pair or a link.
|
||||
_SNAPSHOT = {
|
||||
"prompts": "SELECT id, text, category, tags, updated_at FROM prompts ORDER BY id",
|
||||
"prompt_tags": "SELECT prompt_id, tag_id FROM prompt_tags ORDER BY 1, 2",
|
||||
"prompt_pairs": "SELECT id, positive_prompt_id, negative_prompt_id, category,"
|
||||
" archived, updated_at FROM prompt_pairs ORDER BY id",
|
||||
"prompt_pair_tags": "SELECT pair_id, tag_id FROM prompt_pair_tags ORDER BY 1, 2",
|
||||
"generated_images": "SELECT id, prompt_id, image_path FROM generated_images"
|
||||
" ORDER BY id",
|
||||
"image_prompt_pairs": "SELECT id, image_id, pair_id FROM image_prompt_pairs"
|
||||
" ORDER BY id",
|
||||
}
|
||||
|
||||
|
||||
def _png(path: Path, colour: tuple[int, int, int], size=(96, 64)) -> None:
|
||||
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], "seed": 7},
|
||||
},
|
||||
"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", size, colour) as image:
|
||||
image.save(path, pnginfo=info)
|
||||
|
||||
|
||||
def seed_tagging(api: PromptManagerAPI, root: Path, extra_images: int = 0) -> dict:
|
||||
"""Replace this server's disposable rows and files with the tagging set."""
|
||||
db = api.db
|
||||
with db.model.get_connection() as conn:
|
||||
for table in _TABLES:
|
||||
conn.execute(f"DELETE FROM {table}")
|
||||
for name in ("output", "input"):
|
||||
shutil.rmtree(root / name)
|
||||
(root / name).mkdir()
|
||||
output = root / "output"
|
||||
_png(output / FILES[0][0], (196, 140, 92))
|
||||
_png(output / FILES[1][0], (150, 110, 80))
|
||||
(output / FILES[2][0]).parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(output / FILES[0][0], output / FILES[2][0])
|
||||
shutil.copyfile(
|
||||
Path(__file__).resolve().parents[1] / "fixtures" / "tiny.webm",
|
||||
output / FILES[3][0],
|
||||
)
|
||||
_png(output / FILES[4][0], (60, 70, 90), size=(64, 64))
|
||||
now = time.time()
|
||||
for index, (relative, _) in enumerate(FILES):
|
||||
stamp = now - 60 - index * 10
|
||||
os.utime(output / relative, (stamp, stamp))
|
||||
for number in range(extra_images):
|
||||
extra = output / "bulk" / f"bulk_{number:04}.png"
|
||||
extra.parent.mkdir(exist_ok=True)
|
||||
shutil.copyfile(output / FILES[4][0], extra)
|
||||
stamp = now - 600 - number
|
||||
os.utime(extra, (stamp, stamp))
|
||||
|
||||
receipt = db.create_library_recipe(
|
||||
POSITIVE,
|
||||
NEGATIVE,
|
||||
expected_epoch=db.get_execution_epoch(),
|
||||
parent_metadata={
|
||||
"title": TITLE,
|
||||
"category": "Architecture",
|
||||
"tags": list(PROMPT_TAGS),
|
||||
},
|
||||
)
|
||||
positive_id = receipt["positive"]["id"]
|
||||
pair_id = receipt["pair"]["id"]
|
||||
with db.model.get_connection() as conn:
|
||||
tag_ids = []
|
||||
for name in PAIR_TAGS:
|
||||
conn.execute("INSERT OR IGNORE INTO tags (name) VALUES (?)", (name,))
|
||||
tag_ids.append(
|
||||
conn.execute("SELECT id FROM tags WHERE name=?", (name,)).fetchone()[0]
|
||||
)
|
||||
conn.executemany(
|
||||
"INSERT INTO prompt_pair_tags (pair_id, tag_id) VALUES (?, ?)",
|
||||
[(pair_id, tag_id) for tag_id in tag_ids],
|
||||
)
|
||||
for relative, kind in FILES:
|
||||
if kind in {"linked", "video"}:
|
||||
image_id = db.link_image_to_prompt(positive_id, str(output / relative))
|
||||
if kind == "linked":
|
||||
db.link_image_to_pair(pair_id, image_id, {"source": "fixture"})
|
||||
api._generate_gallery_thumbnails_sync(api._get_all_output_dirs(), (150, 150))
|
||||
api.invalidate_gallery_cache()
|
||||
return {
|
||||
"positive_id": positive_id,
|
||||
"pair_id": pair_id,
|
||||
"title": TITLE,
|
||||
"prompt_tags": list(PROMPT_TAGS),
|
||||
"pair_tags": list(PAIR_TAGS),
|
||||
"files": [
|
||||
{"path": relative, "filename": Path(relative).name, "kind": kind}
|
||||
for relative, kind in FILES
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def tagging_snapshot(api: PromptManagerAPI) -> dict:
|
||||
"""Plain rows of every prompt, pair and link table."""
|
||||
with api.db.model.get_connection() as conn:
|
||||
return {
|
||||
name: [list(row) for row in conn.execute(query)]
|
||||
for name, query in _SNAPSHOT.items()
|
||||
}
|
||||
|
||||
|
||||
def register_tagging_commands(
|
||||
routes: web.RouteTableDef, api: PromptManagerAPI, root: Path, model
|
||||
) -> None:
|
||||
"""Register fixed seed, snapshot and fake-model commands; no paths or SQL input."""
|
||||
|
||||
@routes.post("/__test__/tagging/reset")
|
||||
async def reset(request: web.Request) -> web.Response:
|
||||
data = await request.json() if request.can_read_body else {}
|
||||
if request.query or not isinstance(data, dict) or set(data) - {"extra_images"}:
|
||||
raise web.HTTPBadRequest()
|
||||
extra = data.get("extra_images", 0)
|
||||
if isinstance(extra, bool) or not isinstance(extra, int):
|
||||
raise web.HTTPBadRequest()
|
||||
if not 0 <= extra <= MAX_EXTRA_IMAGES:
|
||||
raise web.HTTPBadRequest()
|
||||
model.unavailable = set()
|
||||
model.suggestions = None
|
||||
model.loaded = None
|
||||
_set_defaults(model, DEFAULTS)
|
||||
# A test that ends with the dialog open leaves its capture behind; drop
|
||||
# every capture so the next test starts below the store's limits.
|
||||
await api._run_in_executor(api.close_annotation_captures)
|
||||
manifest = await api._run_in_executor(seed_tagging, api, root, extra)
|
||||
return web.json_response({"success": True, **manifest})
|
||||
|
||||
@routes.get("/__test__/tagging/snapshot")
|
||||
async def snapshot(request: web.Request) -> web.Response:
|
||||
if request.query:
|
||||
raise web.HTTPBadRequest()
|
||||
return web.json_response(await api._run_in_executor(tagging_snapshot, api))
|
||||
|
||||
@routes.post("/__test__/tagging/models")
|
||||
async def models(request: web.Request) -> web.Response:
|
||||
"""Mark fake models as not downloaded (``{"unavailable": [...]}``)."""
|
||||
data = await request.json() if request.can_read_body else None
|
||||
if (
|
||||
request.query
|
||||
or not isinstance(data, dict)
|
||||
or set(data) != {"unavailable"}
|
||||
or not isinstance(data["unavailable"], list)
|
||||
or any(name not in MODELS for name in data["unavailable"])
|
||||
):
|
||||
raise web.HTTPBadRequest()
|
||||
model.unavailable = set(data["unavailable"])
|
||||
return web.json_response({"success": True})
|
||||
|
||||
@routes.post("/__test__/tagging/suggestions")
|
||||
async def suggestions(request: web.Request) -> web.Response:
|
||||
"""Answer Generate with fixed scored names (the concept's own sample).
|
||||
|
||||
``{"scored": [[name, confidence], ...]}`` (at most 100, confidence in
|
||||
[0, 1]) or ``{"scored": null}`` for the default list. Every capture is
|
||||
released too, so a concept comparison starts below the store's limits
|
||||
whatever the previous test left open.
|
||||
"""
|
||||
data = await request.json() if request.can_read_body else None
|
||||
scored = data.get("scored") if isinstance(data, dict) else None
|
||||
if (
|
||||
request.query
|
||||
or not isinstance(data, dict)
|
||||
or set(data) != {"scored"}
|
||||
or not (scored is None or _valid_scored(scored))
|
||||
):
|
||||
raise web.HTTPBadRequest()
|
||||
model.suggestions = (
|
||||
None if scored is None else [(name, float(value)) for name, value in scored]
|
||||
)
|
||||
model.unavailable = set()
|
||||
await api._run_in_executor(api.close_annotation_captures)
|
||||
return web.json_response({"success": True})
|
||||
|
||||
@routes.post("/__test__/tagging/defaults")
|
||||
async def defaults(request: web.Request) -> web.Response:
|
||||
"""Serve other tagging defaults from ``GET /autotag/models``.
|
||||
|
||||
``{"general": g, "character": c, "limit": n}``: the WD14 thresholds
|
||||
(in [0, 1]) and the persisted suggestion count (1..100), so a test can
|
||||
tell the server's values from the dialog's built-in ones. Reset
|
||||
restores the standard 0.35 / 0.85 / 10.
|
||||
"""
|
||||
data = await request.json() if request.can_read_body else None
|
||||
if request.query or not _valid_defaults(data):
|
||||
raise web.HTTPBadRequest()
|
||||
_set_defaults(model, (data["general"], data["character"], data["limit"]))
|
||||
return web.json_response({"success": True})
|
||||
|
||||
@routes.get("/__test__/tagging/captures")
|
||||
async def captures(request: web.Request) -> web.Response:
|
||||
"""How many captures the store holds (a released capture is gone)."""
|
||||
if request.query:
|
||||
raise web.HTTPBadRequest()
|
||||
store = api._captures
|
||||
count = store.stats()["captures"] if store is not None else 0
|
||||
return web.json_response({"captures": count})
|
||||
|
||||
|
||||
# The standard tagging defaults: WD14 general and character thresholds, and
|
||||
# the suggestion count (``AutotagConfig``).
|
||||
DEFAULTS = (0.35, 0.85, 10)
|
||||
|
||||
|
||||
def _set_defaults(model, values) -> None:
|
||||
from py.config import AutotagConfig
|
||||
|
||||
general, character, limit = values
|
||||
model.wd14_general_threshold = general
|
||||
model.wd14_character_threshold = character
|
||||
AutotagConfig.SUGGESTION_LIMIT = limit
|
||||
|
||||
|
||||
def _valid_defaults(data: object) -> bool:
|
||||
unit = (
|
||||
lambda value: isinstance(value, (int, float))
|
||||
and not isinstance(value, bool)
|
||||
and 0 <= value <= 1
|
||||
)
|
||||
return (
|
||||
isinstance(data, dict)
|
||||
and set(data) == {"general", "character", "limit"}
|
||||
and unit(data["general"])
|
||||
and unit(data["character"])
|
||||
and type(data["limit"]) is int
|
||||
and 1 <= data["limit"] <= 100
|
||||
)
|
||||
|
||||
|
||||
def _valid_scored(scored: object) -> bool:
|
||||
return (
|
||||
isinstance(scored, list)
|
||||
and len(scored) <= 100
|
||||
and all(
|
||||
isinstance(row, list)
|
||||
and len(row) == 2
|
||||
and isinstance(row[0], str)
|
||||
and 0 < len(row[0]) <= 50
|
||||
and isinstance(row[1], (int, float))
|
||||
and not isinstance(row[1], bool)
|
||||
and 0 <= row[1] <= 1
|
||||
for row in scored
|
||||
)
|
||||
)
|
||||
@@ -112,6 +112,11 @@ 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 (
|
||||
"saved_views",
|
||||
"image_favorites",
|
||||
"media_image_tags",
|
||||
"media_annotation_bytes",
|
||||
"media_annotation_files",
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
"prompt_pairs",
|
||||
|
||||
@@ -79,6 +79,11 @@ UNUSED_TAG = "unused_vocabulary"
|
||||
MAX_EXTRA_TAGS = 300
|
||||
MAX_EXTRA_CATEGORIES = 200
|
||||
_TABLES = (
|
||||
"saved_views",
|
||||
"image_favorites",
|
||||
"media_image_tags",
|
||||
"media_annotation_bytes",
|
||||
"media_annotation_files",
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
"prompt_pairs",
|
||||
|
||||
@@ -237,13 +237,13 @@ test.describe('public chronological history with the actual acknowledged host br
|
||||
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();
|
||||
const viewer = workspace.locator('dialog#viewer-dialog');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: 'Frozen run prompt texts', exact: true }).getByRole('region', { name: `${role} prompt`, exact: true }).locator('pre').evaluate(node => node.textContent)).toBe(value);
|
||||
for (const [role, value] of [['Positive', frozen.positive[mode.toLowerCase()]], ['Negative', frozen.negative[mode.toLowerCase()]]]) expect(await run.getByRole('region', { name: 'Frozen run prompt texts', exact: true }).getByRole('region', { name: `${role} prompt`, exact: true }).locator('p.prompt-text').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('region', { name: 'Frozen run prompt texts', exact: true }).getByRole('button', { name: 'Send prompt pair', exact: true }).click();const sender = workspace.getByRole('dialog', { name: 'Send to ComfyUI', exact: true });
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {installHostHooks} from './host-fixture.mjs';
|
||||
import {sendablePng,localFixture,POSITIVE_LINES} from '../browser/local-inspect-files.mjs';
|
||||
|
||||
// PR L2 Task 7 of the local-Inspect plan (#249): a file read entirely in the browser by the native Inspect page sends
|
||||
// its exact roles into a real Prompt Manager (+/-) node, in Classic and Nodes 2.0, switching both ways and reloading
|
||||
// the graph each time. Nothing queues or loads a workflow, and a connected destination input rejects the whole send.
|
||||
installHostHooks(test,['PromptManagerPair']);
|
||||
|
||||
// The PNG's positive spans lines and its negative is a ConditioningZeroOut (explicitly empty); the WebM carries the
|
||||
// committed core video's per-key tags.
|
||||
const FILES=[
|
||||
{file:()=>sendablePng('host-send.png'),positive:POSITIVE_LINES,negative:'',shown:'No negative prompt (explicitly empty)'},
|
||||
{file:()=>localFixture('core-tags.webm','video/webm','host-send.webm'),positive:'a red fox in snow, detailed fur',negative:'blurry, lowres',shown:'blurry, lowres'},
|
||||
];
|
||||
|
||||
async function nodeState(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 {inputs:(await app.graphToPrompt()).output[String(id)].inputs,widgets:Object.fromEntries(node.widgets.map(widget=>[widget.name,widget.value])),
|
||||
serialized:Object.fromEntries(node.widgets.map((widget,index)=>[widget.name,serialized.widgets_values[index]])),effects:window.inspectEffects};
|
||||
},id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts every queue or workflow load until `restore` runs; with `reset`, first sets the destination's roles back to
|
||||
* known values (the connected-input case sets them itself, before it links the input, as library-sender.spec does).
|
||||
*/
|
||||
async function watchHost(page,id,reset=true) {
|
||||
await page.evaluate(async ({id,reset})=>{
|
||||
const {app}=await import('/scripts/app.js');const node=app.graph.getNodeById(id);
|
||||
if(reset){node.widgets.find(widget=>widget.name==='text').value='Before positive';node.widgets.find(widget=>widget.name==='negative_text').value='Before negative';}
|
||||
window.inspectEffects={queue:0,load:0};window.inspectOriginals={};
|
||||
for(const [method,key] of [['queuePrompt','queue'],['loadGraphData','load']]){const original=app[method];window.inspectOriginals[method]=original;app[method]=function(...args){window.inspectEffects[key]++;return original.apply(this,args);};}
|
||||
},{id,reset});
|
||||
}
|
||||
const restore=page=>page.evaluate(async()=>{const {app}=await import('/scripts/app.js');Object.assign(app,window.inspectOriginals);});
|
||||
|
||||
/** Chooses `file` on the Inspect page and opens the shared viewer on its local record. */
|
||||
async function inspect(workspace,{file,positive,shown}) {
|
||||
await workspace.locator('#local-file').setInputFiles(file());
|
||||
const open=workspace.locator('#workspace-main .heading-actions').getByRole('button',{name:'Open file metadata',exact:true});
|
||||
await expect(open).toHaveAttribute('aria-disabled','false');await open.click();
|
||||
const rail=workspace.locator('dialog#viewer-dialog[open] #viewer-details');
|
||||
await expect(rail.locator('.prompt-section.positive p.prompt-text')).toHaveText(positive);
|
||||
await expect(rail.locator('.prompt-section.negative p.prompt-text')).toHaveText(shown);
|
||||
return rail;
|
||||
}
|
||||
|
||||
/** The rail's pair send, through to a previewed destination; returns the sender dialog. */
|
||||
async function preview(workspace,rail) {
|
||||
await rail.getByRole('button',{name:/^Send prompt pair to ComfyUI: /}).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();
|
||||
return sender;
|
||||
}
|
||||
|
||||
async function closeViewer(workspace,sender) {
|
||||
await sender.getByRole('button',{name:'Close',exact:true}).click();await expect(sender).toHaveCount(0);
|
||||
await workspace.keyboard.press('Escape');await expect(workspace.locator('dialog#viewer-dialog[open]')).toHaveCount(0);
|
||||
}
|
||||
|
||||
test('local file roles send exactly across renderer transitions',async({page,context},testInfo)=>{
|
||||
test.setTimeout(180000);
|
||||
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 workspace=await context.newPage();const errors=[];workspace.on('pageerror',error=>errors.push(error.message));
|
||||
const requests=[];workspace.on('request',request=>{if(request.method()!=='GET')requests.push(`${request.method()} ${request.url()}`);});
|
||||
const logged=[];workspace.on('console',message=>logged.push(message.text()));
|
||||
try {
|
||||
await workspace.goto('/prompt_manager/workspace#/inspect');
|
||||
await expect(workspace.locator('#workspace-main h1')).toHaveText('Inspect a file');
|
||||
const initial=testInfo.project.metadata.initialRenderer;
|
||||
for(const renderer of [initial,!initial,initial]) {
|
||||
// The renderer switch reloads the graph, as a workflow reload does.
|
||||
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);
|
||||
for(const entry of FILES) {
|
||||
await watchHost(page,id);
|
||||
try {
|
||||
const rail=await inspect(workspace,entry);
|
||||
const sender=await preview(workspace,rail);
|
||||
for(const text of ['Before positive','Before negative',...entry.positive.split('\n'),entry.negative||'(empty)'])await expect(sender).toContainText(text);
|
||||
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
|
||||
const state=await nodeState(page,id);
|
||||
expect(state.effects).toEqual({queue:0,load:0});
|
||||
for(const field of ['inputs','serialized'])expect(state[field]).toMatchObject({text:entry.positive,negative_text:entry.negative});
|
||||
await closeViewer(workspace,sender);
|
||||
} finally {await restore(page);}
|
||||
}
|
||||
}
|
||||
// A connected destination input rejects the whole preview: neither role changes, nothing queues or loads.
|
||||
const link=await page.evaluate(async id=>{
|
||||
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(item=>item.name==='negative_text');
|
||||
if(!node.inputs.some(input=>input.name==='negative_text'))convertToInput(node,widget);
|
||||
node.widgets.find(item=>item.name==='text').value='Before positive';widget.value='Before negative';
|
||||
const source=LiteGraph.createNode('PrimitiveString');app.graph.add(source);source.widgets.find(item=>item.name==='value').value='Upstream negative';
|
||||
source.connect(0,node,node.inputs.findIndex(input=>input.name==='negative_text'));
|
||||
return [String(source.id),0];
|
||||
},id);
|
||||
await watchHost(page,id,false);
|
||||
try {
|
||||
const before=await nodeState(page,id);
|
||||
expect(before.widgets).toMatchObject({text:'Before positive',negative_text:'Before negative'});
|
||||
const rail=await inspect(workspace,FILES[1]);
|
||||
const sender=await preview(workspace,rail);
|
||||
await expect(sender.getByRole('alert')).toContainText(/connected/i);
|
||||
await expect(sender.getByRole('button',{name:'Apply to ComfyUI',exact:true})).toBeDisabled();
|
||||
const rejected=await nodeState(page,id);
|
||||
expect(rejected.effects).toEqual({queue:0,load:0});
|
||||
expect(rejected.inputs.negative_text).toEqual(link);
|
||||
for(const field of ['widgets','serialized'])expect(rejected[field]).toMatchObject({text:'Before positive',negative_text:'Before negative'});
|
||||
await closeViewer(workspace,sender);
|
||||
} finally {await restore(page);}
|
||||
// The workspace page sent nothing to the server (the only crossing is the host bridge) and logged nothing of
|
||||
// the files: no name and no prompt text.
|
||||
expect(requests).toEqual([]);
|
||||
expect(logged.filter(text=>/host-send|lighthouse|long exposure|red fox|lowres/.test(text))).toEqual([]);
|
||||
expect(errors).toEqual([]);
|
||||
} finally {await workspace.close();}
|
||||
});
|
||||
@@ -22,10 +22,10 @@ test('native library sends the selected saved pair into a real ComfyUI destinati
|
||||
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();
|
||||
await workspace.getByRole('searchbox',{name:'Search workspace',exact:true}).fill(positive);
|
||||
await workspace.locator('article[data-prompt-id]').filter({has:workspace.getByText(positive,{exact:true})}).getByRole('button',{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 history.getByRole('button',{name:'Select negative Host saved pair',exact:true}).click();
|
||||
await expect(history.getByRole('region',{name:'Saved prompt texts',exact:true}).getByRole('region',{name:'Negative prompt',exact:true})).toContainText(negative);
|
||||
// The pair send lives in the history heading, as in the approved concept.
|
||||
await workspace.locator('#workspace-main > .heading').getByRole('button',{name:`Send prompt pair to ComfyUI: ${positive}`,exact:true}).click();
|
||||
@@ -67,7 +67,7 @@ test('native editor preview sends exact saved roles across renderer transitions'
|
||||
await expect(workspace).toHaveURL(/#\/library\/\d+$/);const positiveId=Number(new URL(workspace.url()).hash.split('/').at(-1));
|
||||
await expect(workspace.locator('#workspace-main h1')).toHaveText(title);
|
||||
const saved=workspace.getByRole('region',{name:'Saved prompt texts',exact:true});
|
||||
await expect(saved.getByRole('region',{name:'Negative prompt',exact:true})).toContainText('Empty negative prompt');
|
||||
await expect(saved.getByRole('region',{name:'Negative prompt',exact:true})).toContainText('No negative prompt (explicitly empty)');
|
||||
const evidence=async()=>{
|
||||
const history=await(await request.get(`/prompt_manager/library/${positiveId}/history`)).json();
|
||||
const detail=await(await request.get(`/prompt_manager/library/${positiveId}?archive=all`)).json();
|
||||
|
||||
@@ -41,15 +41,17 @@ test('native image inspector previews and sends a pair into real visible widgets
|
||||
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();
|
||||
// D15: Refresh is Import → Rescan.
|
||||
await workspace.getByRole('button',{name:'Import',exact:true}).click();
|
||||
await workspace.getByRole('dialog',{name:'Import & inspect',exact:true}).getByRole('button',{name:'Rescan',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 card.getByRole('button',{name:`Inspect ${filename}`,exact:true}).click();
|
||||
const inspector=workspace.getByRole('complementary',{name:'Selected 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 card.getByRole('button',{name:`Open ${filename}`,exact:true}).click();
|
||||
const viewer=workspace.locator('dialog#viewer-dialog');
|
||||
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 expect(viewer.locator('.meta-grid').first().locator('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');
|
||||
@@ -57,12 +59,12 @@ test('native image inspector previews and sends a pair into real visible widgets
|
||||
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(viewer).toBeVisible();await expect(viewer.locator('.meta-grid 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();
|
||||
await viewer.getByRole('button',{name:/^Send prompt pair to ComfyUI: /}).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);
|
||||
|
||||
@@ -34,9 +34,16 @@ test('workspace serves real library data beside either ComfyUI renderer', async
|
||||
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.getByText('No matching images · 0 images', { exact: true })).toBeVisible();
|
||||
await expect(workspace.locator('.gallery-body > .empty')).toHaveText('No matching images. Try another search or category.');
|
||||
}
|
||||
await expect(workspace.locator('#workspace-main a[href="/prompt_manager/gallery"]')).toHaveAttribute('href', '/prompt_manager/gallery');
|
||||
// D29: Images no longer links the legacy gallery; Overview's "Open existing image gallery" card does.
|
||||
await expect(workspace.locator('#workspace-main a[href="/prompt_manager/gallery"]')).toHaveCount(0);
|
||||
await workspace.evaluate(() => { location.hash = '/overview'; });
|
||||
const gallery = workspace.locator('#workspace-main').getByRole('link', { name: 'Open existing image gallery', exact: true });
|
||||
await expect(gallery).toHaveAttribute('href', '/prompt_manager/gallery');
|
||||
expect((await workspace.request.get('/prompt_manager/gallery')).ok()).toBe(true);
|
||||
await workspace.evaluate(() => { location.hash = '/images'; });
|
||||
await workspace.reload();
|
||||
await expect(workspace.locator('#workspace-main h1')).toHaveText('Images');
|
||||
await expect(workspace.locator('body')).toHaveAttribute('data-ca-theme', 'light');
|
||||
|
||||
Vendored
+69
@@ -13,6 +13,75 @@ ffmpeg -f lavfi -i testsrc=size=32x32:rate=4:duration=1 \
|
||||
|
||||
Browser tests use the committed file; FFmpeg is not required to run them.
|
||||
|
||||
# Local Inspect header fixtures
|
||||
|
||||
`local-inspect/tiny.jpg` (48x32) and `local-inspect/tiny.webp` (40x24) are original
|
||||
synthetic FFmpeg color test patterns, under 4 KB, dedicated to the public domain
|
||||
under CC0-1.0. `tests/js/png-local-reader.test.js` reads their dimensions with the
|
||||
browser header reader and `tests/test_local_inspect_parity.py` checks the same two
|
||||
files against Pillow.
|
||||
|
||||
Reproduce with FFmpeg:
|
||||
|
||||
```sh
|
||||
ffmpeg -f lavfi -i testsrc=size=48x32:rate=1 -frames:v 1 -q:v 10 tiny.jpg
|
||||
ffmpeg -f lavfi -i testsrc=size=40x24:rate=1 -frames:v 1 -c:v libwebp -quality 40 tiny.webp
|
||||
```
|
||||
|
||||
# Local Inspect video fixtures
|
||||
|
||||
`local-inspect/core-faststart.mp4`, `core-moov-end.mp4`, `core.mov`, `core.m4v`,
|
||||
`core.mkv`, `core-tags.webm`, `legacy-comment.mp4` and `legacy-comment.webm` are
|
||||
0.2-second 16x16 black clips (each under 4 KB) carrying an original synthetic
|
||||
ComfyUI graph in their container tags, dedicated to the public domain under
|
||||
CC0-1.0. The `core-*` files carry one tag per key as core SaveVideo/SaveWEBM write
|
||||
them (MP4/MOV/M4V through `-movflags use_metadata_tags`; Matroska upper-cases the
|
||||
names); `core-faststart.mp4` also carries `parameters` and
|
||||
`comfyassets_prompt_pairs`. The `legacy-comment.*` files carry the old
|
||||
VideoHelperSuite `comment` JSON envelope (an ilst `©cmt` atom in MP4, a `COMMENT`
|
||||
tag in WebM). `video-tags-v1.json` records `ffprobe -show_entries format_tags -of
|
||||
json` for each file (ffmpeg 9.0.2); `tests/test_utils_video_metadata.py`
|
||||
`VideoFixtureFfprobeTests` re-checks it when ffprobe is installed, and
|
||||
`tests/js/workspace-local-video.test.js` plus `tests/test_local_inspect_parity.py`
|
||||
hold the browser video reader to the same maps.
|
||||
|
||||
Reproduce with FFmpeg, from `tests/fixtures/local-inspect`:
|
||||
|
||||
```sh
|
||||
PROMPT='{"3":{"class_type":"KSampler","inputs":{"seed":7,"steps":20,"cfg":8.0,"sampler_name":"euler","scheduler":"normal","denoise":1.0,"model":["4",0],"positive":["6",0],"negative":["7",0],"latent_image":["5",0]}},"4":{"class_type":"CheckpointLoaderSimple","inputs":{"ckpt_name":"synthetic-v1.safetensors"}},"5":{"class_type":"EmptyLatentImage","inputs":{"width":512,"height":512,"batch_size":1}},"6":{"class_type":"CLIPTextEncode","inputs":{"text":"a red fox in snow, detailed fur","clip":["4",1]}},"7":{"class_type":"CLIPTextEncode","inputs":{"text":"blurry, lowres","clip":["4",1]}},"8":{"class_type":"VAEDecode","inputs":{"samples":["3",0],"vae":["4",2]}},"9":{"class_type":"SaveVideo","inputs":{"filename_prefix":"video/synthetic","video":["8",0]}}}'
|
||||
WORKFLOW='{"last_node_id":9,"nodes":[{"id":3,"type":"KSampler","widgets_values":[7,"fixed",20,8.0,"euler","normal",1.0]},{"id":6,"type":"CLIPTextEncode","widgets_values":["a red fox in snow, detailed fur"]},{"id":7,"type":"CLIPTextEncode","widgets_values":["blurry, lowres"]}],"links":[],"version":0.4}'
|
||||
PARAMETERS=$'a red fox in snow, detailed fur\nNegative prompt: blurry, lowres\nSteps: 20, Sampler: Euler, CFG scale: 8.0, Seed: 7, Size: 512x512'
|
||||
PAIRS='{"version":1,"nodes":{"6":{"records":[{"positive_text":"a red fox in snow, detailed fur","negative_text":"blurry, lowres","negative_effective_text":"blurry, lowres","prepend_text":"","append_text":""}]}}}'
|
||||
COMMENT=$(PROMPT="$PROMPT" WORKFLOW="$WORKFLOW" python3 -c 'import json,os; print(json.dumps({"prompt": os.environ["PROMPT"], "workflow": json.loads(os.environ["WORKFLOW"])}))')
|
||||
SRC=(-v quiet -y -f lavfi -i "color=c=black:s=16x16:d=0.2")
|
||||
ffmpeg "${SRC[@]}" -movflags use_metadata_tags+faststart -metadata prompt="$PROMPT" -metadata workflow="$WORKFLOW" -metadata parameters="$PARAMETERS" -metadata comfyassets_prompt_pairs="$PAIRS" core-faststart.mp4
|
||||
ffmpeg "${SRC[@]}" -movflags use_metadata_tags -metadata prompt="$PROMPT" -metadata workflow="$WORKFLOW" core-moov-end.mp4
|
||||
ffmpeg "${SRC[@]}" -movflags use_metadata_tags -metadata prompt="$PROMPT" -metadata workflow="$WORKFLOW" core.mov
|
||||
ffmpeg "${SRC[@]}" -movflags use_metadata_tags -metadata prompt="$PROMPT" -metadata workflow="$WORKFLOW" core.m4v
|
||||
ffmpeg "${SRC[@]}" -metadata prompt="$PROMPT" -metadata workflow="$WORKFLOW" core.mkv
|
||||
ffmpeg "${SRC[@]}" -c:v libvpx -metadata prompt="$PROMPT" -metadata workflow="$WORKFLOW" core-tags.webm
|
||||
ffmpeg "${SRC[@]}" -metadata comment="$COMMENT" legacy-comment.mp4
|
||||
ffmpeg "${SRC[@]}" -c:v libvpx -metadata comment="$COMMENT" legacy-comment.webm
|
||||
for f in core-faststart.mp4 core-moov-end.mp4 core.mov core.m4v core.mkv core-tags.webm legacy-comment.mp4 legacy-comment.webm; do
|
||||
ffprobe -v quiet -show_entries format_tags -of json "$f" # -> video-tags-v1.json "fixtures"[f] = .format.tags
|
||||
done
|
||||
```
|
||||
|
||||
# Local Inspect projection cases
|
||||
|
||||
`local-inspect/projection-cases.json` holds synthetic record sets (prompt, workflow,
|
||||
parameters and pair-record texts, optional `record_formats`, a video `carrier_text`
|
||||
and `limited` records) that exercise every branch of the server's inspection
|
||||
projection: Python's Decimal spellings and exact-construction limits, the 4300-digit
|
||||
integer limit, duplicate keys, NaN/Infinity, the depth-64 and node ceilings, prompt
|
||||
node key filtering and the carrier rule. `records: null` stands for an unreadable
|
||||
file. `expect` pins both graph statuses; `needs` names an interpreter feature a case
|
||||
depends on. `tests/js/workspace-local-projection.test.js` runs them through
|
||||
`web/js/workspace/local-projection.mjs`, and `tests/test_local_inspect_parity.py`
|
||||
asserts that its answer equals `utils.inspection_response._metadata` byte for byte.
|
||||
All texts are original synthetic content, dedicated to the public domain under
|
||||
CC0-1.0.
|
||||
|
||||
# Inspection parity fixtures
|
||||
|
||||
`inspection_parity/` holds small synthetic PNGs (`f01b.png` ... `f24.png`, each
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
+849
@@ -0,0 +1,849 @@
|
||||
{
|
||||
"version": 1,
|
||||
"about": "Synthetic record sets for utils/inspection_response.py _metadata parity with web/js/workspace/local-projection.mjs projectMetadata. records null = an unreadable file. record_formats default to original-text for each record. expect pins both graph statuses; needs names an interpreter feature the Python side must have.",
|
||||
"cases": [
|
||||
{
|
||||
"name": "unreadable_read",
|
||||
"records": null,
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "absent_records",
|
||||
"records": {},
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "prompt_and_workflow_parsed",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}",
|
||||
"workflow": "{\"nodes\":[],\"version\":0.4}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "parsed",
|
||||
"workflow_graph": "parsed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "all_four_records",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}",
|
||||
"workflow": "{\"nodes\":[],\"version\":0.4}",
|
||||
"parameters": "cat\nNegative prompt: dog\nSteps: 20, Sampler: Euler, Seed: 7",
|
||||
"comfyassets_prompt_pairs": "{\"version\":1,\"nodes\":{}}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "parsed",
|
||||
"workflow_graph": "parsed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "parameters_only",
|
||||
"records": {
|
||||
"parameters": "cat\nSteps: 20"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "number_spellings",
|
||||
"records": {
|
||||
"workflow": "{\"nodes\":[],\"a\":[1e3,1e0,-0.0,6.50,0.0100,1.0e2,0e5,0.0000001,0.000001,1E+3,1e-0,0.5e1,0.05e1,0.0e0,1.5e+300,123e-8,2.5e-3,1.0,-1.50,0.0,100.0,1234567e-6,0.5E-6,0.5E-5,1e22,0.000000,-0.000000e-3,7.0e-6,7.0e-7,12345678901234567890.123,9007199254740992.0]}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "parsed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "integer_spellings",
|
||||
"records": {
|
||||
"workflow": "{\"a\":[9007199254740991,-9007199254740991,9007199254740992,-9007199254740992,10000000000000000,123456789012345678901234567890,0,-0,42,-7,100]}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "parsed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "nonfinite_literals_are_null",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"n\":NaN,\"i\":Infinity,\"m\":-Infinity},\"_meta\":{\"n\":NaN}}}",
|
||||
"workflow": "{\"a\":[NaN,Infinity,-Infinity],\"b\":NaN}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "parsed",
|
||||
"workflow_graph": "parsed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "exponent_edges_valid",
|
||||
"records": {
|
||||
"workflow": "{\"a\":[1e999999999999999999,1e-1999999999999999997,0.0e1000000000000000000,1.5e999999999999999999,0.1e-1999999999999999996]}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "parsed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "exponent_edge_invalid_1e1000000000000000000",
|
||||
"records": {
|
||||
"workflow": "{\"a\":1e1000000000000000000}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "malformed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "exponent_edge_invalid_15e999999999999999999",
|
||||
"records": {
|
||||
"workflow": "{\"a\":15e999999999999999999}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "malformed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "exponent_edge_invalid_1e_1999999999999999998",
|
||||
"records": {
|
||||
"workflow": "{\"a\":1e-1999999999999999998}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "malformed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "exponent_edge_invalid_1_5e_1999999999999999997",
|
||||
"records": {
|
||||
"workflow": "{\"a\":1.5e-1999999999999999997}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "malformed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "exponent_edge_invalid_0e1000000000000000000",
|
||||
"records": {
|
||||
"workflow": "{\"a\":0e1000000000000000000}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "malformed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "exponent_edge_invalid_1e9223372036854775808",
|
||||
"records": {
|
||||
"workflow": "{\"a\":1e9223372036854775808}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "malformed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "exponent_edge_invalid_0_0e1000000000000000001",
|
||||
"records": {
|
||||
"workflow": "{\"a\":0.0e1000000000000000001}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "malformed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "exponent_edge_invalid_1e99999999999999999999999999",
|
||||
"records": {
|
||||
"workflow": "{\"a\":1e99999999999999999999999999}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "malformed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "int_4300_digits_kept",
|
||||
"records": {
|
||||
"workflow": "{\"a\":7777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "parsed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "int_4301_digits_malformed",
|
||||
"records": {
|
||||
"workflow": "{\"a\":77777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777}"
|
||||
},
|
||||
"needs": "int-max-str-digits",
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "malformed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "int_negative_4301_digits_malformed",
|
||||
"records": {
|
||||
"workflow": "{\"a\":-77777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777}"
|
||||
},
|
||||
"needs": "int-max-str-digits",
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "malformed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "string_escapes",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"t\":\"é😀\\u0000\\u001f/\\\\\\\"\\b\\f\\n\\r\\t\",\"raw\":\"é😀 —
\"}}}",
|
||||
"workflow": "{\"s\":\"\\u00E9\\uD83D\\uDE00\\uD800\\uDC00x\\u0000\\u001f\\/\\\\\\\"\\b\\f\\n\\r\\t\\uD800\\u0041\\uDC00\"}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "parsed",
|
||||
"workflow_graph": "parsed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "lone_surrogate_in_raw_text",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"t\":\"\\udc00\"}}}",
|
||||
"parameters": "\ud800 lone\nSteps: 1"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "parsed",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "keys_with_escapes_proto_and_order",
|
||||
"records": {
|
||||
"prompt": "{\"10\":{\"class_type\":\"B\",\"inputs\":{}},\"3\":{\"class_type\":\"A\",\"inputs\":{}},\"__proto__\":{\"class_type\":\"P\",\"inputs\":{\"constructor\":1}},\"constructor\":{\"class_type\":\"C\",\"inputs\":{}},\"\\u0000\":5,\"\":[]}",
|
||||
"workflow": "{\"10\":1,\"3\":2,\"b\":3,\"a\":4,\"__proto__\":{\"x\":1},\"\":{}}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "parsed",
|
||||
"workflow_graph": "parsed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "duplicate_key_top_level",
|
||||
"records": {
|
||||
"workflow": "{\"a\":1,\"a\":2}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "malformed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "duplicate_key_nested",
|
||||
"records": {
|
||||
"workflow": "{\"a\":{\"b\":[{\"c\":1,\"c\":1}]}}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "malformed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "duplicate_key_in_dropped_prompt_meta",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"a\":1},\"_meta\":{\"t\":1,\"t\":2}}}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "malformed",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "duplicate_key_in_prompt_inputs",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"a\":1,\"a\":1}}}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "malformed",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "duplicate_key_after_deep_nest",
|
||||
"records": {
|
||||
"workflow": "{\"x\":[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]],\"a\":1,\"a\":2}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "malformed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "workflow_depth_64_kept",
|
||||
"records": {
|
||||
"workflow": "{\"a\":[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "parsed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "workflow_depth_65_limited",
|
||||
"records": {
|
||||
"workflow": "{\"a\":[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "limited"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "prompt_inputs_depth_64_kept",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"v\":[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]}}}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "parsed",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "prompt_inputs_depth_65_limited",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"v\":[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]}}}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "limited",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "prompt_dropped_meta_depth_65_limited",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{},\"_meta\":{\"v\":[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]}}}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "limited",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "prompt_non_object_nodes_kept",
|
||||
"records": {
|
||||
"prompt": "{\"1\":5,\"2\":[1,2.50,\"x\",null,true],\"3\":\"s\",\"4\":null,\"5\":{\"inputs\":[1],\"class_type\":null,\"other\":{\"deep\":[NaN]}}}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "parsed",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "prompt_root_array_malformed",
|
||||
"records": {
|
||||
"prompt": "[]"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "malformed",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "prompt_root_string_malformed",
|
||||
"records": {
|
||||
"prompt": "\"{}\""
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "malformed",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "prompt_root_number_malformed",
|
||||
"records": {
|
||||
"prompt": "5"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "malformed",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "prompt_root_null_malformed",
|
||||
"records": {
|
||||
"prompt": "null"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "malformed",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "workflow_root_array_malformed",
|
||||
"records": {
|
||||
"workflow": "[1,2]"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "malformed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "workflow_root_nan_malformed",
|
||||
"records": {
|
||||
"workflow": "NaN"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "malformed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "empty_text_malformed",
|
||||
"records": {
|
||||
"prompt": "",
|
||||
"workflow": " "
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "malformed",
|
||||
"workflow_graph": "malformed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "bom_malformed",
|
||||
"records": {
|
||||
"workflow": "{}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "malformed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "trailing_data_malformed",
|
||||
"records": {
|
||||
"workflow": "{\"a\":1} x",
|
||||
"prompt": "{}\u0000"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "malformed",
|
||||
"workflow_graph": "malformed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "python_whitespace_only",
|
||||
"records": {
|
||||
"workflow": " \t\r\n{\n\t\"a\" :\r 1 }\n",
|
||||
"prompt": "\u000b{}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "malformed",
|
||||
"workflow_graph": "parsed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "syntax_errors",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{},}}",
|
||||
"workflow": "[1,]"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "malformed",
|
||||
"workflow_graph": "malformed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "bad_escape_and_control_char",
|
||||
"records": {
|
||||
"prompt": "{\"1\":\"\\x\"}",
|
||||
"workflow": "{\"a\":\"\u0001\"}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "malformed",
|
||||
"workflow_graph": "malformed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "number_grammar",
|
||||
"records": {
|
||||
"prompt": "{\"1\":01}",
|
||||
"workflow": "{\"a\":1.,\"b\":.5}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "malformed",
|
||||
"workflow_graph": "malformed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "nodes_not_list_counts_zero",
|
||||
"records": {
|
||||
"workflow": "{\"nodes\":{\"a\":1},\"definitions\":{\"subgraphs\":[{\"nodes\":5},7,{\"nodes\":[1,2]}]}}"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "parsed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "limited_workflow_record",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}",
|
||||
"workflow": "{\"nodes\":[],\"version\":0.4}"
|
||||
},
|
||||
"limited": [
|
||||
"workflow"
|
||||
],
|
||||
"expect": {
|
||||
"prompt_graph": "parsed",
|
||||
"workflow_graph": "limited"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "limited_workflow_without_text",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}"
|
||||
},
|
||||
"limited": [
|
||||
"workflow"
|
||||
],
|
||||
"expect": {
|
||||
"prompt_graph": "parsed",
|
||||
"workflow_graph": "limited"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "invalid_workflow_format",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}"
|
||||
},
|
||||
"record_formats": {
|
||||
"prompt": "original-text",
|
||||
"workflow": "invalid"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "parsed",
|
||||
"workflow_graph": "malformed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "reserialized_without_carrier_is_limited",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}",
|
||||
"workflow": "{\"nodes\":[],\"version\":0.4}"
|
||||
},
|
||||
"record_formats": {
|
||||
"prompt": "reserialized-json",
|
||||
"workflow": "reserialized-json"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "limited",
|
||||
"workflow_graph": "limited"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "unknown_format_dropped_but_projected",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}"
|
||||
},
|
||||
"record_formats": {
|
||||
"prompt": "weird"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "parsed",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "non_text_record_dropped",
|
||||
"records": {
|
||||
"prompt": 5,
|
||||
"workflow": "{\"nodes\":[],\"version\":0.4}"
|
||||
},
|
||||
"record_formats": {
|
||||
"prompt": "original-text",
|
||||
"workflow": "original-text"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "malformed",
|
||||
"workflow_graph": "parsed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "format_without_text_is_malformed",
|
||||
"records": {},
|
||||
"record_formats": {
|
||||
"prompt": "original-text"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "malformed",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "unknown_record_keys_ignored",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}",
|
||||
"Software": "ComfyUI",
|
||||
"WORKFLOW": "{\"nodes\":[],\"version\":0.4}"
|
||||
},
|
||||
"record_formats": {
|
||||
"prompt": "original-text",
|
||||
"Software": "original-text",
|
||||
"WORKFLOW": "original-text"
|
||||
},
|
||||
"expect": {
|
||||
"prompt_graph": "parsed",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "carrier_prompt_string_workflow_object",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}",
|
||||
"workflow": "{\"nodes\": [], \"v\": 1}"
|
||||
},
|
||||
"record_formats": {
|
||||
"prompt": "original-text",
|
||||
"workflow": "reserialized-json"
|
||||
},
|
||||
"carrier_text": "{\"prompt\":\"{\\\"1\\\":{\\\"class_type\\\":\\\"A\\\",\\\"inputs\\\":{\\\"x\\\":1}}}\",\"workflow\":{\"nodes\":[],\"v\":1}}",
|
||||
"expect": {
|
||||
"prompt_graph": "parsed",
|
||||
"workflow_graph": "parsed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "carrier_prompt_object_projected",
|
||||
"records": {
|
||||
"prompt": "{\"1\": {\"class_type\": \"A\", \"inputs\": {\"x\": 8.0}, \"_meta\": {\"t\": \"x\"}}}",
|
||||
"workflow": "{\"nodes\":[],\"version\":0.4}"
|
||||
},
|
||||
"record_formats": {
|
||||
"prompt": "reserialized-json",
|
||||
"workflow": "original-text"
|
||||
},
|
||||
"carrier_text": "{\"prompt\":{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":8.0},\"_meta\":{\"t\":\"x\"}}},\"workflow\":{\"nodes\":[],\"version\":0.4},\"title\":\"n\",\"extra\":[1e3]}",
|
||||
"expect": {
|
||||
"prompt_graph": "parsed",
|
||||
"workflow_graph": "parsed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "carrier_root_array_fails_all",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}",
|
||||
"workflow": "{\"nodes\":[],\"version\":0.4}"
|
||||
},
|
||||
"carrier_text": "[1]",
|
||||
"expect": {
|
||||
"prompt_graph": "malformed",
|
||||
"workflow_graph": "malformed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "carrier_duplicate_key_fails_all",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}"
|
||||
},
|
||||
"carrier_text": "{\"prompt\":\"{}\",\"prompt\":\"{}\"}",
|
||||
"expect": {
|
||||
"prompt_graph": "malformed",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "carrier_other_member_too_deep_limited",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}"
|
||||
},
|
||||
"carrier_text": "{\"title\":[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]],\"prompt\":\"{\\\"1\\\":{\\\"class_type\\\":\\\"A\\\",\\\"inputs\\\":{\\\"x\\\":1}}}\"}",
|
||||
"expect": {
|
||||
"prompt_graph": "limited",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "carrier_other_member_depth_64_kept",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}"
|
||||
},
|
||||
"carrier_text": "{\"title\":[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]],\"prompt\":\"{\\\"1\\\":{\\\"class_type\\\":\\\"A\\\",\\\"inputs\\\":{\\\"x\\\":1}}}\"}",
|
||||
"expect": {
|
||||
"prompt_graph": "parsed",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "carrier_prompt_object_depth_shift",
|
||||
"records": {
|
||||
"prompt": "x"
|
||||
},
|
||||
"record_formats": {
|
||||
"prompt": "reserialized-json"
|
||||
},
|
||||
"carrier_text": "{\"prompt\":{\"1\":{\"class_type\":\"A\",\"inputs\":{\"v\":[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]}}}}",
|
||||
"expect": {
|
||||
"prompt_graph": "parsed",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "carrier_prompt_object_depth_shift_limited",
|
||||
"records": {
|
||||
"prompt": "x"
|
||||
},
|
||||
"record_formats": {
|
||||
"prompt": "reserialized-json"
|
||||
},
|
||||
"carrier_text": "{\"prompt\":{\"1\":{\"class_type\":\"A\",\"inputs\":{\"v\":[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]}}}}",
|
||||
"expect": {
|
||||
"prompt_graph": "limited",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "carrier_prompt_string_invalid_json",
|
||||
"records": {
|
||||
"prompt": "{",
|
||||
"workflow": "{\"nodes\":[],\"version\":0.4}"
|
||||
},
|
||||
"carrier_text": "{\"prompt\":\"{\",\"workflow\":\"{\\\"nodes\\\":[],\\\"version\\\":0.4}\"}",
|
||||
"expect": {
|
||||
"prompt_graph": "malformed",
|
||||
"workflow_graph": "parsed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "carrier_prompt_string_too_deep",
|
||||
"records": {
|
||||
"prompt": "x"
|
||||
},
|
||||
"carrier_text": "{\"prompt\":\"{\\\"1\\\":{\\\"class_type\\\":\\\"A\\\",\\\"inputs\\\":{\\\"v\\\":[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]}}}\"}",
|
||||
"expect": {
|
||||
"prompt_graph": "limited",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "carrier_workflow_number_malformed",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}",
|
||||
"workflow": "5"
|
||||
},
|
||||
"record_formats": {
|
||||
"prompt": "original-text",
|
||||
"workflow": "original-text"
|
||||
},
|
||||
"carrier_text": "{\"prompt\":\"{\\\"1\\\":{\\\"class_type\\\":\\\"A\\\",\\\"inputs\\\":{\\\"x\\\":1}}}\",\"workflow\":5}",
|
||||
"expect": {
|
||||
"prompt_graph": "parsed",
|
||||
"workflow_graph": "malformed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "carrier_prompt_nonfinite_malformed",
|
||||
"records": {
|
||||
"prompt": "NaN"
|
||||
},
|
||||
"carrier_text": "{\"prompt\":NaN}",
|
||||
"expect": {
|
||||
"prompt_graph": "malformed",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "carrier_prompt_bigint_malformed",
|
||||
"records": {
|
||||
"prompt": "123456789012345678901"
|
||||
},
|
||||
"carrier_text": "{\"prompt\":123456789012345678901}",
|
||||
"expect": {
|
||||
"prompt_graph": "malformed",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "carrier_prompt_decimal_malformed",
|
||||
"records": {
|
||||
"prompt": "1.5"
|
||||
},
|
||||
"carrier_text": "{\"prompt\":1.5}",
|
||||
"expect": {
|
||||
"prompt_graph": "malformed",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "carrier_missing_member_malformed",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}"
|
||||
},
|
||||
"carrier_text": "{\"workflow\":{}}",
|
||||
"expect": {
|
||||
"prompt_graph": "malformed",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "carrier_without_graph_records",
|
||||
"records": {
|
||||
"parameters": "cat"
|
||||
},
|
||||
"carrier_text": "{\"parameters\":\"cat\"}",
|
||||
"expect": {
|
||||
"prompt_graph": "absent",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "carrier_with_limited_workflow",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}"
|
||||
},
|
||||
"limited": [
|
||||
"workflow"
|
||||
],
|
||||
"carrier_text": "{\"prompt\":\"{\\\"1\\\":{\\\"class_type\\\":\\\"A\\\",\\\"inputs\\\":{\\\"x\\\":1}}}\"}",
|
||||
"expect": {
|
||||
"prompt_graph": "parsed",
|
||||
"workflow_graph": "limited"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "carrier_invalid_workflow_format",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}"
|
||||
},
|
||||
"record_formats": {
|
||||
"prompt": "original-text",
|
||||
"workflow": "invalid"
|
||||
},
|
||||
"carrier_text": "{\"prompt\":\"{\\\"1\\\":{\\\"class_type\\\":\\\"A\\\",\\\"inputs\\\":{\\\"x\\\":1}}}\",\"workflow\":null}",
|
||||
"expect": {
|
||||
"prompt_graph": "parsed",
|
||||
"workflow_graph": "malformed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "carrier_non_string_text_malformed",
|
||||
"records": {
|
||||
"prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}"
|
||||
},
|
||||
"carrier_text": "",
|
||||
"expect": {
|
||||
"prompt_graph": "malformed",
|
||||
"workflow_graph": "absent"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 470 B |
+60
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"generator": "ffmpeg -show_entries format_tags -of json over tests/fixtures/local-inspect, see tests/fixtures/README.md",
|
||||
"fixtures": {
|
||||
"core-faststart.mp4": {
|
||||
"major_brand": "isom",
|
||||
"minor_version": "512",
|
||||
"compatible_brands": "isomiso2avc1mp41",
|
||||
"prompt": "{\"3\":{\"class_type\":\"KSampler\",\"inputs\":{\"seed\":7,\"steps\":20,\"cfg\":8.0,\"sampler_name\":\"euler\",\"scheduler\":\"normal\",\"denoise\":1.0,\"model\":[\"4\",0],\"positive\":[\"6\",0],\"negative\":[\"7\",0],\"latent_image\":[\"5\",0]}},\"4\":{\"class_type\":\"CheckpointLoaderSimple\",\"inputs\":{\"ckpt_name\":\"synthetic-v1.safetensors\"}},\"5\":{\"class_type\":\"EmptyLatentImage\",\"inputs\":{\"width\":512,\"height\":512,\"batch_size\":1}},\"6\":{\"class_type\":\"CLIPTextEncode\",\"inputs\":{\"text\":\"a red fox in snow, detailed fur\",\"clip\":[\"4\",1]}},\"7\":{\"class_type\":\"CLIPTextEncode\",\"inputs\":{\"text\":\"blurry, lowres\",\"clip\":[\"4\",1]}},\"8\":{\"class_type\":\"VAEDecode\",\"inputs\":{\"samples\":[\"3\",0],\"vae\":[\"4\",2]}},\"9\":{\"class_type\":\"SaveVideo\",\"inputs\":{\"filename_prefix\":\"video/synthetic\",\"video\":[\"8\",0]}}}",
|
||||
"workflow": "{\"last_node_id\":9,\"nodes\":[{\"id\":3,\"type\":\"KSampler\",\"widgets_values\":[7,\"fixed\",20,8.0,\"euler\",\"normal\",1.0]},{\"id\":6,\"type\":\"CLIPTextEncode\",\"widgets_values\":[\"a red fox in snow, detailed fur\"]},{\"id\":7,\"type\":\"CLIPTextEncode\",\"widgets_values\":[\"blurry, lowres\"]}],\"links\":[],\"version\":0.4}",
|
||||
"parameters": "a red fox in snow, detailed fur\nNegative prompt: blurry, lowres\nSteps: 20, Sampler: Euler, CFG scale: 8.0, Seed: 7, Size: 512x512",
|
||||
"comfyassets_prompt_pairs": "{\"version\":1,\"nodes\":{\"6\":{\"records\":[{\"positive_text\":\"a red fox in snow, detailed fur\",\"negative_text\":\"blurry, lowres\",\"negative_effective_text\":\"blurry, lowres\",\"prepend_text\":\"\",\"append_text\":\"\"}]}}}",
|
||||
"encoder": "Lavf63.1.102"
|
||||
},
|
||||
"core-moov-end.mp4": {
|
||||
"major_brand": "isom",
|
||||
"minor_version": "512",
|
||||
"compatible_brands": "isomiso2avc1mp41",
|
||||
"prompt": "{\"3\":{\"class_type\":\"KSampler\",\"inputs\":{\"seed\":7,\"steps\":20,\"cfg\":8.0,\"sampler_name\":\"euler\",\"scheduler\":\"normal\",\"denoise\":1.0,\"model\":[\"4\",0],\"positive\":[\"6\",0],\"negative\":[\"7\",0],\"latent_image\":[\"5\",0]}},\"4\":{\"class_type\":\"CheckpointLoaderSimple\",\"inputs\":{\"ckpt_name\":\"synthetic-v1.safetensors\"}},\"5\":{\"class_type\":\"EmptyLatentImage\",\"inputs\":{\"width\":512,\"height\":512,\"batch_size\":1}},\"6\":{\"class_type\":\"CLIPTextEncode\",\"inputs\":{\"text\":\"a red fox in snow, detailed fur\",\"clip\":[\"4\",1]}},\"7\":{\"class_type\":\"CLIPTextEncode\",\"inputs\":{\"text\":\"blurry, lowres\",\"clip\":[\"4\",1]}},\"8\":{\"class_type\":\"VAEDecode\",\"inputs\":{\"samples\":[\"3\",0],\"vae\":[\"4\",2]}},\"9\":{\"class_type\":\"SaveVideo\",\"inputs\":{\"filename_prefix\":\"video/synthetic\",\"video\":[\"8\",0]}}}",
|
||||
"workflow": "{\"last_node_id\":9,\"nodes\":[{\"id\":3,\"type\":\"KSampler\",\"widgets_values\":[7,\"fixed\",20,8.0,\"euler\",\"normal\",1.0]},{\"id\":6,\"type\":\"CLIPTextEncode\",\"widgets_values\":[\"a red fox in snow, detailed fur\"]},{\"id\":7,\"type\":\"CLIPTextEncode\",\"widgets_values\":[\"blurry, lowres\"]}],\"links\":[],\"version\":0.4}",
|
||||
"encoder": "Lavf63.1.102"
|
||||
},
|
||||
"core.mov": {
|
||||
"major_brand": "qt ",
|
||||
"minor_version": "512",
|
||||
"compatible_brands": "qt ",
|
||||
"prompt": "{\"3\":{\"class_type\":\"KSampler\",\"inputs\":{\"seed\":7,\"steps\":20,\"cfg\":8.0,\"sampler_name\":\"euler\",\"scheduler\":\"normal\",\"denoise\":1.0,\"model\":[\"4\",0],\"positive\":[\"6\",0],\"negative\":[\"7\",0],\"latent_image\":[\"5\",0]}},\"4\":{\"class_type\":\"CheckpointLoaderSimple\",\"inputs\":{\"ckpt_name\":\"synthetic-v1.safetensors\"}},\"5\":{\"class_type\":\"EmptyLatentImage\",\"inputs\":{\"width\":512,\"height\":512,\"batch_size\":1}},\"6\":{\"class_type\":\"CLIPTextEncode\",\"inputs\":{\"text\":\"a red fox in snow, detailed fur\",\"clip\":[\"4\",1]}},\"7\":{\"class_type\":\"CLIPTextEncode\",\"inputs\":{\"text\":\"blurry, lowres\",\"clip\":[\"4\",1]}},\"8\":{\"class_type\":\"VAEDecode\",\"inputs\":{\"samples\":[\"3\",0],\"vae\":[\"4\",2]}},\"9\":{\"class_type\":\"SaveVideo\",\"inputs\":{\"filename_prefix\":\"video/synthetic\",\"video\":[\"8\",0]}}}",
|
||||
"workflow": "{\"last_node_id\":9,\"nodes\":[{\"id\":3,\"type\":\"KSampler\",\"widgets_values\":[7,\"fixed\",20,8.0,\"euler\",\"normal\",1.0]},{\"id\":6,\"type\":\"CLIPTextEncode\",\"widgets_values\":[\"a red fox in snow, detailed fur\"]},{\"id\":7,\"type\":\"CLIPTextEncode\",\"widgets_values\":[\"blurry, lowres\"]}],\"links\":[],\"version\":0.4}",
|
||||
"encoder": "Lavf63.1.102"
|
||||
},
|
||||
"core.m4v": {
|
||||
"major_brand": "M4V ",
|
||||
"minor_version": "512",
|
||||
"compatible_brands": "M4V isomiso2avc1",
|
||||
"prompt": "{\"3\":{\"class_type\":\"KSampler\",\"inputs\":{\"seed\":7,\"steps\":20,\"cfg\":8.0,\"sampler_name\":\"euler\",\"scheduler\":\"normal\",\"denoise\":1.0,\"model\":[\"4\",0],\"positive\":[\"6\",0],\"negative\":[\"7\",0],\"latent_image\":[\"5\",0]}},\"4\":{\"class_type\":\"CheckpointLoaderSimple\",\"inputs\":{\"ckpt_name\":\"synthetic-v1.safetensors\"}},\"5\":{\"class_type\":\"EmptyLatentImage\",\"inputs\":{\"width\":512,\"height\":512,\"batch_size\":1}},\"6\":{\"class_type\":\"CLIPTextEncode\",\"inputs\":{\"text\":\"a red fox in snow, detailed fur\",\"clip\":[\"4\",1]}},\"7\":{\"class_type\":\"CLIPTextEncode\",\"inputs\":{\"text\":\"blurry, lowres\",\"clip\":[\"4\",1]}},\"8\":{\"class_type\":\"VAEDecode\",\"inputs\":{\"samples\":[\"3\",0],\"vae\":[\"4\",2]}},\"9\":{\"class_type\":\"SaveVideo\",\"inputs\":{\"filename_prefix\":\"video/synthetic\",\"video\":[\"8\",0]}}}",
|
||||
"workflow": "{\"last_node_id\":9,\"nodes\":[{\"id\":3,\"type\":\"KSampler\",\"widgets_values\":[7,\"fixed\",20,8.0,\"euler\",\"normal\",1.0]},{\"id\":6,\"type\":\"CLIPTextEncode\",\"widgets_values\":[\"a red fox in snow, detailed fur\"]},{\"id\":7,\"type\":\"CLIPTextEncode\",\"widgets_values\":[\"blurry, lowres\"]}],\"links\":[],\"version\":0.4}",
|
||||
"encoder": "Lavf63.1.102"
|
||||
},
|
||||
"core.mkv": {
|
||||
"WORKFLOW": "{\"last_node_id\":9,\"nodes\":[{\"id\":3,\"type\":\"KSampler\",\"widgets_values\":[7,\"fixed\",20,8.0,\"euler\",\"normal\",1.0]},{\"id\":6,\"type\":\"CLIPTextEncode\",\"widgets_values\":[\"a red fox in snow, detailed fur\"]},{\"id\":7,\"type\":\"CLIPTextEncode\",\"widgets_values\":[\"blurry, lowres\"]}],\"links\":[],\"version\":0.4}",
|
||||
"PROMPT": "{\"3\":{\"class_type\":\"KSampler\",\"inputs\":{\"seed\":7,\"steps\":20,\"cfg\":8.0,\"sampler_name\":\"euler\",\"scheduler\":\"normal\",\"denoise\":1.0,\"model\":[\"4\",0],\"positive\":[\"6\",0],\"negative\":[\"7\",0],\"latent_image\":[\"5\",0]}},\"4\":{\"class_type\":\"CheckpointLoaderSimple\",\"inputs\":{\"ckpt_name\":\"synthetic-v1.safetensors\"}},\"5\":{\"class_type\":\"EmptyLatentImage\",\"inputs\":{\"width\":512,\"height\":512,\"batch_size\":1}},\"6\":{\"class_type\":\"CLIPTextEncode\",\"inputs\":{\"text\":\"a red fox in snow, detailed fur\",\"clip\":[\"4\",1]}},\"7\":{\"class_type\":\"CLIPTextEncode\",\"inputs\":{\"text\":\"blurry, lowres\",\"clip\":[\"4\",1]}},\"8\":{\"class_type\":\"VAEDecode\",\"inputs\":{\"samples\":[\"3\",0],\"vae\":[\"4\",2]}},\"9\":{\"class_type\":\"SaveVideo\",\"inputs\":{\"filename_prefix\":\"video/synthetic\",\"video\":[\"8\",0]}}}",
|
||||
"ENCODER": "Lavf63.1.102"
|
||||
},
|
||||
"core-tags.webm": {
|
||||
"WORKFLOW": "{\"last_node_id\":9,\"nodes\":[{\"id\":3,\"type\":\"KSampler\",\"widgets_values\":[7,\"fixed\",20,8.0,\"euler\",\"normal\",1.0]},{\"id\":6,\"type\":\"CLIPTextEncode\",\"widgets_values\":[\"a red fox in snow, detailed fur\"]},{\"id\":7,\"type\":\"CLIPTextEncode\",\"widgets_values\":[\"blurry, lowres\"]}],\"links\":[],\"version\":0.4}",
|
||||
"PROMPT": "{\"3\":{\"class_type\":\"KSampler\",\"inputs\":{\"seed\":7,\"steps\":20,\"cfg\":8.0,\"sampler_name\":\"euler\",\"scheduler\":\"normal\",\"denoise\":1.0,\"model\":[\"4\",0],\"positive\":[\"6\",0],\"negative\":[\"7\",0],\"latent_image\":[\"5\",0]}},\"4\":{\"class_type\":\"CheckpointLoaderSimple\",\"inputs\":{\"ckpt_name\":\"synthetic-v1.safetensors\"}},\"5\":{\"class_type\":\"EmptyLatentImage\",\"inputs\":{\"width\":512,\"height\":512,\"batch_size\":1}},\"6\":{\"class_type\":\"CLIPTextEncode\",\"inputs\":{\"text\":\"a red fox in snow, detailed fur\",\"clip\":[\"4\",1]}},\"7\":{\"class_type\":\"CLIPTextEncode\",\"inputs\":{\"text\":\"blurry, lowres\",\"clip\":[\"4\",1]}},\"8\":{\"class_type\":\"VAEDecode\",\"inputs\":{\"samples\":[\"3\",0],\"vae\":[\"4\",2]}},\"9\":{\"class_type\":\"SaveVideo\",\"inputs\":{\"filename_prefix\":\"video/synthetic\",\"video\":[\"8\",0]}}}",
|
||||
"ENCODER": "Lavf63.1.102"
|
||||
},
|
||||
"legacy-comment.mp4": {
|
||||
"major_brand": "isom",
|
||||
"minor_version": "512",
|
||||
"compatible_brands": "isomiso2avc1mp41",
|
||||
"encoder": "Lavf63.1.102",
|
||||
"comment": "{\"prompt\": \"{\\\"3\\\":{\\\"class_type\\\":\\\"KSampler\\\",\\\"inputs\\\":{\\\"seed\\\":7,\\\"steps\\\":20,\\\"cfg\\\":8.0,\\\"sampler_name\\\":\\\"euler\\\",\\\"scheduler\\\":\\\"normal\\\",\\\"denoise\\\":1.0,\\\"model\\\":[\\\"4\\\",0],\\\"positive\\\":[\\\"6\\\",0],\\\"negative\\\":[\\\"7\\\",0],\\\"latent_image\\\":[\\\"5\\\",0]}},\\\"4\\\":{\\\"class_type\\\":\\\"CheckpointLoaderSimple\\\",\\\"inputs\\\":{\\\"ckpt_name\\\":\\\"synthetic-v1.safetensors\\\"}},\\\"5\\\":{\\\"class_type\\\":\\\"EmptyLatentImage\\\",\\\"inputs\\\":{\\\"width\\\":512,\\\"height\\\":512,\\\"batch_size\\\":1}},\\\"6\\\":{\\\"class_type\\\":\\\"CLIPTextEncode\\\",\\\"inputs\\\":{\\\"text\\\":\\\"a red fox in snow, detailed fur\\\",\\\"clip\\\":[\\\"4\\\",1]}},\\\"7\\\":{\\\"class_type\\\":\\\"CLIPTextEncode\\\",\\\"inputs\\\":{\\\"text\\\":\\\"blurry, lowres\\\",\\\"clip\\\":[\\\"4\\\",1]}},\\\"8\\\":{\\\"class_type\\\":\\\"VAEDecode\\\",\\\"inputs\\\":{\\\"samples\\\":[\\\"3\\\",0],\\\"vae\\\":[\\\"4\\\",2]}},\\\"9\\\":{\\\"class_type\\\":\\\"SaveVideo\\\",\\\"inputs\\\":{\\\"filename_prefix\\\":\\\"video/synthetic\\\",\\\"video\\\":[\\\"8\\\",0]}}}\", \"workflow\": {\"last_node_id\": 9, \"nodes\": [{\"id\": 3, \"type\": \"KSampler\", \"widgets_values\": [7, \"fixed\", 20, 8.0, \"euler\", \"normal\", 1.0]}, {\"id\": 6, \"type\": \"CLIPTextEncode\", \"widgets_values\": [\"a red fox in snow, detailed fur\"]}, {\"id\": 7, \"type\": \"CLIPTextEncode\", \"widgets_values\": [\"blurry, lowres\"]}], \"links\": [], \"version\": 0.4}}"
|
||||
},
|
||||
"legacy-comment.webm": {
|
||||
"COMMENT": "{\"prompt\": \"{\\\"3\\\":{\\\"class_type\\\":\\\"KSampler\\\",\\\"inputs\\\":{\\\"seed\\\":7,\\\"steps\\\":20,\\\"cfg\\\":8.0,\\\"sampler_name\\\":\\\"euler\\\",\\\"scheduler\\\":\\\"normal\\\",\\\"denoise\\\":1.0,\\\"model\\\":[\\\"4\\\",0],\\\"positive\\\":[\\\"6\\\",0],\\\"negative\\\":[\\\"7\\\",0],\\\"latent_image\\\":[\\\"5\\\",0]}},\\\"4\\\":{\\\"class_type\\\":\\\"CheckpointLoaderSimple\\\",\\\"inputs\\\":{\\\"ckpt_name\\\":\\\"synthetic-v1.safetensors\\\"}},\\\"5\\\":{\\\"class_type\\\":\\\"EmptyLatentImage\\\",\\\"inputs\\\":{\\\"width\\\":512,\\\"height\\\":512,\\\"batch_size\\\":1}},\\\"6\\\":{\\\"class_type\\\":\\\"CLIPTextEncode\\\",\\\"inputs\\\":{\\\"text\\\":\\\"a red fox in snow, detailed fur\\\",\\\"clip\\\":[\\\"4\\\",1]}},\\\"7\\\":{\\\"class_type\\\":\\\"CLIPTextEncode\\\",\\\"inputs\\\":{\\\"text\\\":\\\"blurry, lowres\\\",\\\"clip\\\":[\\\"4\\\",1]}},\\\"8\\\":{\\\"class_type\\\":\\\"VAEDecode\\\",\\\"inputs\\\":{\\\"samples\\\":[\\\"3\\\",0],\\\"vae\\\":[\\\"4\\\",2]}},\\\"9\\\":{\\\"class_type\\\":\\\"SaveVideo\\\",\\\"inputs\\\":{\\\"filename_prefix\\\":\\\"video/synthetic\\\",\\\"video\\\":[\\\"8\\\",0]}}}\", \"workflow\": {\"last_node_id\": 9, \"nodes\": [{\"id\": 3, \"type\": \"KSampler\", \"widgets_values\": [7, \"fixed\", 20, 8.0, \"euler\", \"normal\", 1.0]}, {\"id\": 6, \"type\": \"CLIPTextEncode\", \"widgets_values\": [\"a red fox in snow, detailed fur\"]}, {\"id\": 7, \"type\": \"CLIPTextEncode\", \"widgets_values\": [\"blurry, lowres\"]}], \"links\": [], \"version\": 0.4}}",
|
||||
"ENCODER": "Lavf63.1.102"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
"""One stand-in for ComfyUI's ``folder_paths`` module, shared by every test.
|
||||
|
||||
Test modules that import the API standalone need a ``folder_paths`` module.
|
||||
It must be a real module, not a ``MagicMock``: ``patch("folder_paths.x",
|
||||
create=True)`` then adds and removes plain attributes, while a mock would
|
||||
hand a later test an auto-created child (or lose the attribute on exit), so
|
||||
the outcome depended on which test module discovery imported first.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
|
||||
|
||||
def install_folder_paths_stub() -> types.ModuleType:
|
||||
"""Install the shared ``folder_paths`` stub unless a real module is there.
|
||||
|
||||
A test that installs its own stand-in for a while (and restores it) is
|
||||
left alone; a ``MagicMock`` left behind by older code is replaced.
|
||||
"""
|
||||
current = sys.modules.get("folder_paths")
|
||||
if isinstance(current, types.ModuleType):
|
||||
return current
|
||||
stub = types.ModuleType("folder_paths")
|
||||
stub.models_dir = tempfile.gettempdir()
|
||||
stub.base_path = tempfile.gettempdir()
|
||||
stub.get_output_directory = tempfile.gettempdir
|
||||
sys.modules["folder_paths"] = stub
|
||||
return stub
|
||||
@@ -163,6 +163,19 @@ test("post, put and del send JSON bodies with the right method", async () => {
|
||||
await Promise.all([p1, p2, p3]);
|
||||
});
|
||||
|
||||
test("del sends an optional JSON body", async () => {
|
||||
const fetch = fakeFetch();
|
||||
const api = createApiClient(fetch);
|
||||
const controller = new AbortController();
|
||||
const pending = api.del("/prompt_manager/images/favorite", { body: { root_index: 1, path: "a/b.png" }, signal: controller.signal });
|
||||
assert.equal(fetch.calls[0].init.method, "DELETE");
|
||||
assert.equal(fetch.calls[0].init.body, JSON.stringify({ root_index: 1, path: "a/b.png" }));
|
||||
assert.equal(fetch.calls[0].init.headers["Content-Type"], "application/json");
|
||||
assert.equal(fetch.calls[0].init.signal, controller.signal);
|
||||
fetch.calls[0].resolve(jsonResponse(200, { success: true }));
|
||||
await pending;
|
||||
});
|
||||
|
||||
test("network failures propagate as ordinary errors", async () => {
|
||||
const fetch = fakeFetch();
|
||||
const api = createApiClient(fetch);
|
||||
@@ -283,3 +296,33 @@ test("get forwards an explicit cache mode and keeps the default request otherwis
|
||||
fetch.calls[2].resolve(jsonResponse(200, { success: true }));
|
||||
await writes;
|
||||
});
|
||||
|
||||
test("ApiError exposes only a validated current tag set from a tag-conflict body", async () => {
|
||||
const permitted = [{ tags: [], tag_revision: 0 }, { tags: ["warm", "Dune"], tag_revision: 7 }];
|
||||
const denied = [undefined, null, { tags: ["a"] }, { tag_revision: 1 }, { tags: "a", tag_revision: 1 },
|
||||
{ tags: [1], tag_revision: 1 }, { tags: ["a"], tag_revision: -1 }, { tags: ["a"], tag_revision: 1.5 },
|
||||
{ tags: ["a"], tag_revision: true }, { tags: Array(101).fill("a"), tag_revision: 1 }];
|
||||
assert.equal(new ApiError(409, "old caller").current, null);
|
||||
for (const current of permitted.concat(denied)) {
|
||||
const api = createApiClient(async () => jsonResponse(409, {
|
||||
success: false, error: "changed", code: "tag-conflict", ...(current || {}),
|
||||
}));
|
||||
await assert.rejects(api.put("/x", {}), error => {
|
||||
assert.ok(error instanceof ApiError);
|
||||
assert.deepEqual(error.current, permitted.includes(current) ? current : null);
|
||||
if (permitted.includes(current)) assert.notEqual(error.current.tags, current.tags, "server arrays must be copied");
|
||||
return true;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("keepalive is forwarded only when asked, so a request can outlive its page", async () => {
|
||||
const fetch = fakeFetch();
|
||||
const api = createApiClient(fetch);
|
||||
const writes = [api.del("/x", { keepalive: true }), api.put("/x", {}, { keepalive: true }), api.post("/x", {}, { keepalive: true }),
|
||||
api.del("/x"), api.put("/x", {}), api.get("/x")];
|
||||
assert.deepEqual(fetch.calls.map(call => call.init.keepalive), [true, true, true, undefined, undefined, undefined]);
|
||||
assert.equal("keepalive" in fetch.calls[3].init, false);
|
||||
for (const call of fetch.calls) call.resolve(jsonResponse(200, { success: true }));
|
||||
await Promise.all(writes);
|
||||
});
|
||||
|
||||
@@ -224,6 +224,22 @@ test("facts use resolution.sampler_id for detailer-stage nodes", () => {
|
||||
assert.ok(Object.values(factsFor(wide, { resolution: { state: "ambiguous", sampler_id: "d" } })).every(fact => fact.status === "unresolved"), "the stage walk is bounded by MAX_VISITS");
|
||||
});
|
||||
|
||||
test("a shared pair resolved across sampler branches merges facts per field (#249)", () => {
|
||||
// The server resolves identical branches with no sampler named: settings the
|
||||
// branches agree on are known, those they differ on (a LoRA per epoch) are ambiguous.
|
||||
const shared = { resolution: { state: "resolved", sampler_id: null, reason: "Every sampler branch uses the same prompt pair" } };
|
||||
const grid = factGraph();
|
||||
grid.lora = { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "epoch_10.safetensors" } };
|
||||
grid.other = structuredClone(grid.s); grid.other.inputs.model = ["lora", 0];
|
||||
assert.deepEqual(statusValues(factsFor(grid, shared)), {
|
||||
seed: ["known", "9007199254740993"], steps: ["known", "28"], cfg: ["known", "6.5000000000000000001"],
|
||||
sampler: ["known", "dpmpp_2m"], scheduler: ["known", "karras"], model: ["ambiguous", null],
|
||||
});
|
||||
assert.ok(Object.values(factsFor(grid, shared)).every(fact => fact.sampler_id === null));
|
||||
// One listed sampler keeps reading that sampler alone.
|
||||
assert.equal(factsFor(factGraph(), shared).model.value, "model.safetensors");
|
||||
});
|
||||
|
||||
test("ambiguous results without a sampler_id merge facts across every listed stage", () => {
|
||||
const ambiguous = { resolution: { state: "ambiguous", sampler_id: null, reason: "Multiple sampler branches" } };
|
||||
// f01b's shape: an upstream sampler and a detailer whose settings partly differ.
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// A minimal DOM for mounting the workspace viewer and inspector in Node: elements with
|
||||
// children, attributes, listeners (fire() dispatches), dialogs and media methods.
|
||||
export 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;}
|
||||
// As in the DOM, a string argument becomes a text node.
|
||||
static wrap(node){if(typeof node!=='string')return node;const text=new Node('#text');text.textContent=node;return text;}
|
||||
append(...nodes){for(const node of nodes.map(Node.wrap)){node.parent=this;this.children.push(node);}}
|
||||
prepend(...nodes){nodes=nodes.map(Node.wrap);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;}};
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
// Byte-exact PNG builders for the local reader tests (real CRC32 and zlib), and
|
||||
// File-like blobs: one that records every [start, end) slice over a sparse layout
|
||||
// that is never allocated, one that answers slowly, one that fails or shrinks.
|
||||
import zlib from "node:zlib";
|
||||
|
||||
export const SIGNATURE = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
export function u32(n) {
|
||||
return Uint8Array.from([(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]);
|
||||
}
|
||||
|
||||
export function latin1(text) {
|
||||
return Buffer.from(text, "latin1");
|
||||
}
|
||||
|
||||
export function utf8(text) {
|
||||
return Buffer.from(text, "utf8");
|
||||
}
|
||||
|
||||
export function concat(...parts) {
|
||||
return Uint8Array.from(Buffer.concat(parts.map((part) => Buffer.from(part))));
|
||||
}
|
||||
|
||||
/**
|
||||
* One chunk with a real CRC32 over type + data. `crc` overrides the checksum and
|
||||
* `declaredLength` the length field (to declare more data than is present).
|
||||
*/
|
||||
export function chunk(type, data, { crc, declaredLength } = {}) {
|
||||
const typeBytes = latin1(type);
|
||||
const body = Buffer.from(data);
|
||||
const sum = crc ?? zlib.crc32(Buffer.concat([typeBytes, body]));
|
||||
return concat(u32(declaredLength ?? body.length), typeBytes, body, u32(sum));
|
||||
}
|
||||
|
||||
export function ihdr(width = 1, height = 1, options) {
|
||||
return chunk("IHDR", concat(u32(width), u32(height), [8, 6, 0, 0, 0]), options);
|
||||
}
|
||||
|
||||
export function idat(data = [0]) {
|
||||
return chunk("IDAT", data);
|
||||
}
|
||||
|
||||
export function iend(options) {
|
||||
return chunk("IEND", [], options);
|
||||
}
|
||||
|
||||
/** tEXt: keyword NUL text, Latin-1 (text may be a string or raw bytes). */
|
||||
export function tEXt(keyword, text, options) {
|
||||
const body = typeof text === "string" ? latin1(text) : Buffer.from(text);
|
||||
return chunk("tEXt", concat(latin1(keyword), [0], body), options);
|
||||
}
|
||||
|
||||
/** zTXt: keyword NUL method zlib(text). `compressed` replaces the deflated body. */
|
||||
export function zTXt(keyword, text, { method = 0, compressed, level = 6 } = {}) {
|
||||
const body = compressed ?? zlib.deflateSync(typeof text === "string" ? latin1(text) : Buffer.from(text), { level });
|
||||
return chunk("zTXt", concat(latin1(keyword), [0, method], body));
|
||||
}
|
||||
|
||||
/** iTXt: keyword NUL flag method lang NUL translated NUL text (UTF-8, deflated when flag). */
|
||||
export function iTXt(keyword, text, { flag = 0, method = 0, lang = "", translated = "", compressed } = {}) {
|
||||
const raw = typeof text === "string" ? utf8(text) : Buffer.from(text);
|
||||
const body = compressed ?? (flag ? zlib.deflateSync(raw) : raw);
|
||||
const langBytes = typeof lang === "string" ? latin1(lang) : Buffer.from(lang);
|
||||
const translatedBytes = typeof translated === "string" ? utf8(translated) : Buffer.from(translated);
|
||||
return concat(chunk("iTXt", concat(latin1(keyword), [0, flag, method], langBytes, [0], translatedBytes, [0], body)));
|
||||
}
|
||||
|
||||
/** A whole PNG: signature, IHDR, IDAT, the given chunks, IEND. */
|
||||
export function png(...chunks) {
|
||||
return concat(SIGNATURE, ihdr(), idat(), ...chunks, iend());
|
||||
}
|
||||
|
||||
/** Signature plus exactly the given chunks (no implied IHDR/IDAT/IEND). */
|
||||
export function raw(...chunks) {
|
||||
return concat(SIGNATURE, ...chunks);
|
||||
}
|
||||
|
||||
export function blobOf(bytes) {
|
||||
return new Blob([bytes]);
|
||||
}
|
||||
|
||||
/**
|
||||
* A File-like object over a sparse layout: `parts` are [{offset, bytes}] placed in a
|
||||
* file of `size` bytes, zero everywhere else; nothing of `size` is allocated. Every
|
||||
* slice is recorded in `ranges` as [start, end).
|
||||
*/
|
||||
export function sparseBlob(parts, size) {
|
||||
const ranges = [];
|
||||
function bytesOf(start, end) {
|
||||
const out = new Uint8Array(end - start);
|
||||
for (const part of parts) {
|
||||
const from = Math.max(start, part.offset);
|
||||
const to = Math.min(end, part.offset + part.bytes.length);
|
||||
if (from < to) out.set(part.bytes.subarray(from - part.offset, to - part.offset), from - start);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return {
|
||||
size,
|
||||
ranges,
|
||||
slice(start, end) {
|
||||
ranges.push([start, end]);
|
||||
return { arrayBuffer: async () => bytesOf(start, end).buffer };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A recording blob over real bytes. */
|
||||
export function recordingBlob(bytes) {
|
||||
return sparseBlob([{ offset: 0, bytes }], bytes.length);
|
||||
}
|
||||
|
||||
/** Every slice resolves after `delayMs`. */
|
||||
export function slowBlob(bytes, delayMs) {
|
||||
return {
|
||||
size: bytes.length,
|
||||
slice(start, end) {
|
||||
return {
|
||||
arrayBuffer: () => new Promise((resolve) => setTimeout(() => resolve(bytes.slice(start, end).buffer), delayMs)),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Slices fail (as a browser does when the file vanished). */
|
||||
export function failingBlob(size) {
|
||||
return {
|
||||
size,
|
||||
slice() {
|
||||
return { arrayBuffer: () => Promise.reject(new Error("NotReadableError")) };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Reports `size` but every slice beyond `actual` bytes comes back short. */
|
||||
export function shrunkBlob(bytes, size) {
|
||||
return {
|
||||
size,
|
||||
slice(start, end) {
|
||||
return { arrayBuffer: async () => bytes.slice(start, Math.min(end, bytes.length)).buffer };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** An A1111 `parameters` record with one settings footer carrying a batch index. */
|
||||
export function parametersText(positive, batchIndex, { seedField = "Seed: 1" } = {}) {
|
||||
return `${positive}\nNegative prompt: blurry\nSteps: 20, Sampler: Euler, Batch index: ${batchIndex}, ${seedField}`;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user