Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85ee96f24c | ||
|
|
1b8fbdbb75 | ||
|
|
0095db8cbc | ||
|
|
b4c0189225 | ||
|
|
b7cf210f0f | ||
|
|
bc14f29db7 | ||
|
|
f95f66d76a | ||
|
|
2cecfcf0ac | ||
|
|
a6f6697c50 | ||
|
|
6e9d8f81fd | ||
|
|
454ac12e1d | ||
|
|
27b4895795 | ||
|
|
e9996996e2 | ||
|
|
762669758b | ||
|
|
1ff2ddce05 | ||
|
|
10a4847a6b | ||
|
|
3476f8ede9 | ||
|
|
0a7b0238ca | ||
|
|
00bda8f442 | ||
|
|
728db36ac3 | ||
|
|
5ded3cfd39 | ||
|
|
2b8126b7be | ||
|
|
86d82186ee | ||
|
|
6c82098207 | ||
|
|
9d398f9862 | ||
|
|
a44cd27c2e | ||
|
|
673433b2ae | ||
|
|
36564b4767 | ||
|
|
d6eba05d21 | ||
|
|
6d2213ead5 |
@@ -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
|
||||
|
||||
@@ -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]
|
||||
@@ -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}
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -27,16 +36,30 @@ except ImportError:
|
||||
try:
|
||||
from ...database.operations import PromptDatabase
|
||||
from ...database.pair_ingestion import ingest_resolved_pair
|
||||
from ...utils.generation_params import (
|
||||
PLACEHOLDERS,
|
||||
GenerationParamsError,
|
||||
extract_generation_params,
|
||||
parse_js_json,
|
||||
)
|
||||
from ...utils.prompt_pair_resolver import resolve_prompt_pair
|
||||
except ImportError:
|
||||
from database.operations import PromptDatabase
|
||||
from database.pair_ingestion import ingest_resolved_pair
|
||||
from utils.generation_params import (
|
||||
PLACEHOLDERS,
|
||||
GenerationParamsError,
|
||||
extract_generation_params,
|
||||
parse_js_json,
|
||||
)
|
||||
from utils.prompt_pair_resolver import resolve_prompt_pair
|
||||
|
||||
|
||||
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")
|
||||
@@ -126,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.
|
||||
|
||||
@@ -425,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)
|
||||
@@ -885,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):
|
||||
@@ -1307,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):
|
||||
@@ -1336,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."""
|
||||
@@ -1374,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..."
|
||||
)
|
||||
@@ -1397,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(
|
||||
@@ -1409,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
|
||||
)
|
||||
@@ -1478,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
|
||||
@@ -1490,29 +1727,46 @@ 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
|
||||
def _graph_prompt_text(metadata):
|
||||
"""The positive prompt the browser parser traces from the graphs, or None."""
|
||||
records = {}
|
||||
for key in ("prompt", "workflow"):
|
||||
value = metadata.get(key)
|
||||
records[key] = parse_js_json(value) if isinstance(value, str) else value
|
||||
try:
|
||||
positive = extract_generation_params(records)["positivePrompt"]
|
||||
except GenerationParamsError:
|
||||
return None
|
||||
if not isinstance(positive, str) or positive == PLACEHOLDERS["positivePrompt"]:
|
||||
return None
|
||||
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)
|
||||
@@ -1525,30 +1779,64 @@ 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 resolution["state"] == "ambiguous":
|
||||
return "ambiguous"
|
||||
if resolution["state"] == "unresolved":
|
||||
if canonical.get("prompt") or not isinstance(
|
||||
canonical.get("parameters"), str
|
||||
):
|
||||
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",
|
||||
):
|
||||
# The resolver could not decide: link the prompt the inspector and the
|
||||
# legacy pages show for this file (the browser parser, in Python).
|
||||
# Graphs without a recognized sampler keep the legacy extractor below.
|
||||
prompt_text = self._graph_prompt_text(metadata)
|
||||
if not prompt_text:
|
||||
return resolution["state"]
|
||||
parsed = self._parse_comfyui_prompt(metadata)
|
||||
if not (parsed.get("prompt") or parsed.get("parameters")):
|
||||
return None
|
||||
elif resolution["state"] == "ambiguous":
|
||||
return "ambiguous"
|
||||
elif resolution["state"] == "unresolved" and not isinstance(
|
||||
canonical.get("parameters"), str
|
||||
):
|
||||
return resolution["state"]
|
||||
else:
|
||||
parsed = self._parse_comfyui_prompt(metadata)
|
||||
if not (parsed.get("prompt") or parsed.get("parameters")):
|
||||
return None
|
||||
prompt_text = self._extract_readable_prompt(parsed)
|
||||
if prompt_text is not None and not isinstance(prompt_text, str):
|
||||
prompt_text = str(prompt_text)
|
||||
if not (prompt_text and prompt_text.strip()):
|
||||
return "found"
|
||||
prompt_text = prompt_text.strip()
|
||||
|
||||
prompt_text = self._extract_readable_prompt(parsed)
|
||||
if prompt_text is not None and not isinstance(prompt_text, str):
|
||||
prompt_text = str(prompt_text)
|
||||
if not (prompt_text and prompt_text.strip()):
|
||||
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
|
||||
@@ -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)
|
||||
|
||||
@@ -3,12 +3,18 @@
|
||||
import os
|
||||
import stat
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
try:
|
||||
from ...utils.prompt_inspector import (
|
||||
MetadataReadError,
|
||||
inspect_prompt_evidence,
|
||||
read_inspection_file,
|
||||
unreadable_evidence,
|
||||
)
|
||||
from ...utils.prompt_pair_resolver import (
|
||||
INSPECTION_PROFILE,
|
||||
PREVIEW_INSPECTION_PROFILE,
|
||||
)
|
||||
from ...utils.video_metadata import is_video_path
|
||||
except ImportError:
|
||||
@@ -16,6 +22,11 @@ except ImportError:
|
||||
MetadataReadError,
|
||||
inspect_prompt_evidence,
|
||||
read_inspection_file,
|
||||
unreadable_evidence,
|
||||
)
|
||||
from utils.prompt_pair_resolver import (
|
||||
INSPECTION_PROFILE,
|
||||
PREVIEW_INSPECTION_PROFILE,
|
||||
)
|
||||
from utils.video_metadata import is_video_path
|
||||
|
||||
@@ -60,9 +71,42 @@ def _current_file_facts(path: Path) -> os.stat_result:
|
||||
return observed
|
||||
|
||||
|
||||
def _in_host_temp(path: Path) -> bool:
|
||||
"""Whether a file lies in ComfyUI's temp folder, where previews are written.
|
||||
|
||||
An unknown temp folder reads as no: the file is then inspected as saved
|
||||
output.
|
||||
"""
|
||||
try:
|
||||
import folder_paths
|
||||
|
||||
temp = folder_paths.get_temp_directory()
|
||||
except (ImportError, AttributeError, OSError):
|
||||
return False
|
||||
if type(temp) is not str or not temp:
|
||||
return False
|
||||
try:
|
||||
folder = os.path.normcase(os.path.realpath(temp))
|
||||
target = os.path.normcase(os.path.realpath(path))
|
||||
return os.path.commonpath([folder, target]) == folder
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
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)
|
||||
@@ -72,25 +116,22 @@ 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 = {
|
||||
"prompts": {
|
||||
role: {"status": "unresolved", "text": None, "source": None}
|
||||
for role in ("positive", "negative")
|
||||
},
|
||||
"resolution": {
|
||||
"state": "unresolved",
|
||||
"sampler_id": None,
|
||||
"reason": "Media metadata could not be read",
|
||||
"reason_code": None,
|
||||
"node": None,
|
||||
},
|
||||
}
|
||||
evidence = unreadable_evidence()
|
||||
else:
|
||||
role_saved = (
|
||||
None if saved and saved.get("association_status") == "ambiguous" else saved
|
||||
)
|
||||
evidence = inspect_prompt_evidence(inspection.metadata, role_saved)
|
||||
# A preview may be the output of a stage that reaches only previews.
|
||||
profile = (
|
||||
PREVIEW_INSPECTION_PROFILE if _in_host_temp(path) else INSPECTION_PROFILE
|
||||
)
|
||||
evidence = inspect_prompt_evidence(
|
||||
inspection.metadata, role_saved, profile=profile
|
||||
)
|
||||
return {
|
||||
"file": {
|
||||
"observation": "current-file",
|
||||
@@ -106,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
|
||||
@@ -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
|
||||
@@ -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})
|
||||
|
||||
@@ -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)
|
||||
@@ -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'));
|
||||
|
||||
@@ -0,0 +1,640 @@
|
||||
// Shared concept-vs-production inventory for the mock-true rebuild (#249, spec §7). One in-page measurer runs the
|
||||
// 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 './fixtures.mjs';
|
||||
export {diffInventory} from './library-editor-canonical-comparison.mjs';
|
||||
|
||||
export const CONCEPT_URL='/__test__/canonical/workspace';
|
||||
export const PRODUCTION_URL='/prompt_manager/workspace';
|
||||
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});
|
||||
await expect(response).toBeOK();
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function selectTheme(page,theme) {
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
}
|
||||
|
||||
/** The concept at `#<route>` with its own Theme select. */
|
||||
export async function openConcept(page,route,theme,motion='no-preference') {
|
||||
await page.emulateMedia({reducedMotion:motion});
|
||||
await page.goto('about:blank');await page.goto(`${CONCEPT_URL}#${route}`);
|
||||
await selectTheme(page,theme);
|
||||
await expect(page.locator('#main #page-title')).toBeVisible();
|
||||
}
|
||||
|
||||
/** Production at `#/<route>` with the D4 Theme select, once the sidebar's real counts (D27) have settled. */
|
||||
export async function openProduction(page,route,theme,motion='no-preference') {
|
||||
await page.emulateMedia({reducedMotion:motion});
|
||||
await page.goto('about:blank');await page.goto(`${PRODUCTION_URL}#/${route}`);
|
||||
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. */
|
||||
export async function focusRingOf(locator) {
|
||||
return locator.evaluate(node=>{const style=getComputedStyle(node);return {focused:document.activeElement===node,
|
||||
visible:node.matches(':focus-visible'),outlineStyle:style.outlineStyle,outlineWidth:style.outlineWidth,
|
||||
outlineColor:style.outlineColor,outlineOffset:style.outlineOffset};});
|
||||
}
|
||||
|
||||
/** Computed colors of an element while the pointer rests on it; the pointer is parked again afterwards. */
|
||||
export async function hoverStateOf(page,locator) {
|
||||
await locator.hover();
|
||||
const state=await locator.evaluate(node=>{const style=getComputedStyle(node);return {hovered:node.matches(':hover'),
|
||||
color:style.color,backgroundColor:style.backgroundColor,borderTopColor:style.borderTopColor,
|
||||
textDecorationLine:style.textDecorationLine,boxShadow:style.boxShadow};});
|
||||
await page.mouse.move(0,0);
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutate the open page so a comparison can be shown to notice a change (the A7 mutation check): `css` is appended as
|
||||
* the last stylesheet, so it wins over equally specific concept rules, and every element matching a `remove` selector
|
||||
* is detached. Returns an async undo that restores the stylesheet and the removed elements in place.
|
||||
*/
|
||||
export async function mutate(page,{css=null,remove=[]}={}) {
|
||||
const token=await page.evaluate(({css,remove})=>{
|
||||
const token=`mutation-${Math.random().toString(36).slice(2)}`;
|
||||
const undo=[];
|
||||
if(css) {
|
||||
const style=document.createElement('style');style.dataset.mutation=token;style.textContent=css;
|
||||
document.head.append(style);undo.push(()=>style.remove());
|
||||
}
|
||||
for(const selector of remove) {
|
||||
const nodes=[...document.querySelectorAll(selector)];
|
||||
if(!nodes.length)throw new Error(`mutate: nothing matches ${selector}`);
|
||||
for(const node of nodes) {
|
||||
const parent=node.parentNode,next=node.nextSibling;
|
||||
node.remove();undo.push(()=>parent.insertBefore(node,next));
|
||||
}
|
||||
}
|
||||
(window.__conceptMutations??=new Map()).set(token,undo);
|
||||
return token;
|
||||
},{css,remove});
|
||||
return ()=>page.evaluate(token=>{
|
||||
const undo=window.__conceptMutations?.get(token);
|
||||
if(!undo)throw new Error('mutate: this mutation was already undone or the page reloaded');
|
||||
undo.reverse().forEach(step=>step());window.__conceptMutations.delete(token);
|
||||
},token);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
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','borderRightStyle','borderRightColor',
|
||||
'borderBottomWidth','borderBottomStyle','borderBottomColor','borderTopLeftRadius','backgroundColor');
|
||||
const layout=node=>css(node,'display','flexDirection','flexWrap','justifyContent','alignItems','columnGap','rowGap','position');
|
||||
// Text of a node without its descendants matching `skip` (counts, nested controls), whitespace collapsed.
|
||||
const textOf=(node,skip)=>{const copy=node.cloneNode(true);if(skip)copy.querySelectorAll(skip).forEach(item=>item.remove());
|
||||
return copy.textContent.replace(/\s+/g,' ').trim();};
|
||||
const relative=(node,origin)=>{const a=rect(node),b=rect(origin);return {left:px(a.left-b.left),top:px(a.top-b.top),width:px(a.width),height:px(a.height)};};
|
||||
const main=document.querySelector(scope);
|
||||
const sidebar=document.querySelector('aside.sidebar');
|
||||
const workspace=document.querySelector('.workspace');
|
||||
|
||||
function iconOf(node) {
|
||||
const svg=node.querySelector(':scope > svg.icon');
|
||||
if(!svg)return null;
|
||||
const box=rect(svg);
|
||||
return {width:px(box.width),height:px(box.height),viewBox:svg.getAttribute('viewBox'),hidden:svg.getAttribute('aria-hidden'),
|
||||
d:svg.querySelector('path')?.getAttribute('d')??null,...css(svg,'strokeWidth','color','verticalAlign','flexShrink')};
|
||||
}
|
||||
function navRow(link,index,links) {
|
||||
const box=rect(link),count=link.querySelector('.nav-count'),icon=link.querySelector(':scope > svg.icon');
|
||||
const style=getComputedStyle(link);
|
||||
const content=(icon?rect(icon).width+parseFloat(style.columnGap||0):0)+(()=>{
|
||||
const range=document.createRange();const text=[...link.childNodes].find(item=>item.nodeType===Node.TEXT_NODE&&item.textContent.trim());
|
||||
if(!text)return 0;range.selectNodeContents(text);return range.getBoundingClientRect().width;})();
|
||||
const next=links[index+1];let gapAfter=null;
|
||||
if(next){const other=rect(next);gapAfter=px(other.top)===px(box.top)?{axis:'x',size:px(other.left-box.right)}:{axis:'y',size:px(other.top-box.bottom)};}
|
||||
return {text:textOf(link,'.nav-count'),current:link.getAttribute('aria-current'),
|
||||
...layout(link),...edges(link),...font(link),minHeight:style.minHeight,height:px(box.height),
|
||||
fullWidth:px(box.width)===px(rect(link.parentElement).width),
|
||||
hugs:Math.abs(box.width-content-parseFloat(style.paddingLeft)-parseFloat(style.paddingRight))<=1.5,
|
||||
gapAfter,icon:iconOf(link),
|
||||
count:count?{text:count.textContent,shown:shown(count),...font(count),...css(count,'marginInlineStart')}:null};
|
||||
}
|
||||
// Every subsection is null when its element is missing, so a removed node is reported by its path
|
||||
// (for example `chrome.breadcrumbs`) instead of failing the measurement.
|
||||
const when=(node,measure)=>node?measure(node):null;
|
||||
// The accessible text of a node: its text without aria-hidden descendants.
|
||||
const accessibleText=node=>textOf(node,'[aria-hidden="true"]');
|
||||
function itemOf(node,actions) {
|
||||
const base={tag:node.tagName.toLowerCase(),className:node.className,shown:shown(node)};
|
||||
if(!base.shown)return base;
|
||||
const box=relative(node,actions);
|
||||
return {...base,text:textOf(node,'select, .count'),...font(node),...edges(node),...layout(node),
|
||||
left:box.left,top:box.top,width:box.width,height:box.height,
|
||||
count:node.querySelector('.count')?.textContent??null,
|
||||
select:when(node.querySelector('select'),select=>({options:[...select.options].map(option=>[option.value,option.textContent]),
|
||||
label:select.getAttribute('aria-label'),labelled:select.labels?.[0]===node,...font(select),...edges(select),
|
||||
minHeight:getComputedStyle(select).minHeight,width:px(rect(select).width),height:px(rect(select).height)})),
|
||||
hiddenSuffix:[...node.querySelectorAll('[aria-hidden="true"]')].map(child=>child.textContent).join('')||null,
|
||||
whiteSpace:getComputedStyle(node).whiteSpace};
|
||||
}
|
||||
function sidebarOf() {
|
||||
return when(sidebar,()=>({...layout(sidebar),...edges(sidebar),
|
||||
...css(sidebar,'top','overflowY','borderInlineEndWidth'),width:px(rect(sidebar).width),
|
||||
fillsViewport:Math.abs(rect(sidebar).height-innerHeight)<=1}));
|
||||
}
|
||||
function bottomOf(bottom) {
|
||||
// In the column sidebar the bottom group is pinned to the end by `margin-top: auto`, whose used value is only
|
||||
// the free space above it, so the pin (its inset from the sidebar's padding edge) is recorded instead.
|
||||
const sidebarStyle=getComputedStyle(sidebar);
|
||||
const pinned=sidebarStyle.display==='flex';
|
||||
const {marginTop,...rest}=edges(bottom);
|
||||
return {...rest,...css(bottom,'display'),marginTop:pinned?'auto':marginTop,
|
||||
bottomInset:pinned?px(rect(sidebar).bottom-rect(bottom).bottom-parseFloat(sidebarStyle.paddingBottom)):null};
|
||||
}
|
||||
function topActionsOf(actions,topbar) {
|
||||
const style=getComputedStyle(topbar);
|
||||
return {...layout(actions),...edges(actions),width:px(rect(actions).width),height:px(rect(actions).height),
|
||||
fullWidth:Math.abs(rect(actions).width-(rect(topbar).width-parseFloat(style.paddingLeft)-parseFloat(style.paddingRight)))<=1,
|
||||
rightInset:px(rect(topbar).right-rect(actions).right),
|
||||
items:[...actions.children].map(node=>itemOf(node,actions))};
|
||||
}
|
||||
function chromeOf() {
|
||||
if(!sidebar&&!workspace)return null;
|
||||
const shell=document.querySelector('.app-shell');
|
||||
const brand=sidebar?.querySelector('.brand');
|
||||
const topbar=workspace?.querySelector('.topbar');
|
||||
const footer=workspace?.querySelector('.app-footer');
|
||||
return {
|
||||
title:document.title,
|
||||
skip:when(document.querySelector('a.skip'),skip=>({text:skip.textContent.trim(),...css(skip,'position','top','left')})),
|
||||
body:{...font(document.body),backgroundColor:getComputedStyle(document.body).backgroundColor,
|
||||
tokens:Object.fromEntries(['--ca-text','--ca-muted','--ca-focus','--ca-accent','--ca-field','--ca-surface','--ca-line',
|
||||
'--line','--page','--sidebar','--soft-blue','--green'].map(name=>[name,getComputedStyle(document.body).getPropertyValue(name).trim()]))},
|
||||
shell:when(shell,()=>{const columns=getComputedStyle(shell).gridTemplateColumns.split(' ');
|
||||
return {display:getComputedStyle(shell).display,columns:columns.length,sidebarColumn:columns.length>1?columns[0]:null,
|
||||
minHeight:getComputedStyle(shell).minHeight};}),
|
||||
sidebar:sidebarOf(),
|
||||
brand:when(brand,()=>({text:textOf(brand),name:accessibleText(brand),href:brand.getAttribute('href')?.replace(/^#\/?/,''),
|
||||
...relative(brand,sidebar),...font(brand),...layout(brand),...edges(brand),
|
||||
mark:when(brand.querySelector('.brand-mark'),mark=>({text:textOf(mark),hidden:mark.getAttribute('aria-hidden'),...font(mark),
|
||||
width:px(rect(mark).width),height:px(rect(mark).height)})),
|
||||
small:when(brand.querySelector('small'),small=>({text:small.textContent,...font(small),...css(small,'display','marginTop')}))})),
|
||||
labels:sidebar?[...sidebar.querySelectorAll('.sidebar-label')].map(node=>({text:textOf(node),shown:shown(node),...font(node),...layout(node),...edges(node)})):null,
|
||||
navs:sidebar?[...sidebar.querySelectorAll('nav')].map(nav=>{const links=[...nav.querySelectorAll(':scope > a')];return {
|
||||
id:nav.id,label:nav.getAttribute('aria-label'),shown:shown(nav),...layout(nav),...edges(nav),
|
||||
width:px(rect(nav).width),rows:links.map(navRow)};}):null,
|
||||
bottom:when(sidebar?.querySelector('.sidebar-bottom'),bottomOf),
|
||||
connection:when(sidebar?.querySelector('.connection'),connection=>({shown:shown(connection),text:textOf(connection,'small'),
|
||||
small:connection.querySelector('small')?.textContent??null,...layout(connection),...edges(connection),...font(connection),
|
||||
smallFont:when(connection.querySelector('small'),font),
|
||||
dot:when(connection.querySelector('i'),dot=>css(dot,'width','height','borderTopLeftRadius','backgroundColor','marginTop'))})),
|
||||
topbar:when(topbar,()=>({...layout(topbar),...edges(topbar),...css(topbar,'height','minHeight'),...relative(topbar,workspace),
|
||||
fullWidth:px(rect(topbar).width)===px(rect(workspace).width)})),
|
||||
breadcrumbs:when(topbar?.querySelector('.breadcrumbs'),crumbs=>({shown:shown(crumbs),text:textOf(crumbs),...layout(crumbs),...font(crumbs),
|
||||
parts:[...crumbs.querySelectorAll('span')].map(span=>({text:span.textContent,...font(span)})),
|
||||
...(shown(crumbs)?{left:relative(crumbs,topbar).left,middle:px(rect(crumbs).top+rect(crumbs).height/2-rect(topbar).top)}:{})})),
|
||||
topActions:when(topbar?.querySelector('.top-actions'),actions=>topActionsOf(actions,topbar)),
|
||||
main:when(main,()=>({tag:main.tagName.toLowerCase(),tabIndex:main.tabIndex,
|
||||
...css(main,'paddingTop','paddingRight','paddingBottom','paddingLeft','maxWidth','marginLeft','marginRight')})),
|
||||
footer:when(footer,()=>({...layout(footer),...edges(footer),...font(footer),...css(footer,'height'),width:px(rect(footer).width),
|
||||
fullWidth:px(rect(footer).width)===px(rect(workspace).width),
|
||||
spans:[...footer.querySelectorAll(':scope > span')].map(span=>({text:span.textContent.replace(/\s+/g,' ').trim(),...font(span),height:px(rect(span).height)})),
|
||||
buttons:[...footer.querySelectorAll('button')].map(button=>({text:button.textContent.trim(),...font(button),...edges(button),
|
||||
minHeight:getComputedStyle(button).minHeight,height:px(rect(button).height)}))})),
|
||||
};
|
||||
}
|
||||
function headingOf() {
|
||||
const heading=main?.querySelector(':scope > .heading');
|
||||
if(!heading)return null;
|
||||
const copy=heading.firstElementChild,h1=copy?.querySelector('h1'),description=copy?.querySelector('p'),actions=heading.querySelector('.heading-actions');
|
||||
return {...layout(heading),...edges(heading),height:px(rect(heading).height),
|
||||
copy:when(copy,()=>({tag:copy.tagName.toLowerCase(),...layout(copy)})),
|
||||
h1:when(h1,()=>({id:h1.id,tabIndex:h1.tabIndex,text:h1.textContent,...font(h1),...edges(h1)})),
|
||||
description:when(description,()=>({className:description.className,text:description.textContent,...font(description),...edges(description)})),
|
||||
actions:when(actions,()=>({...layout(actions),...edges(actions),
|
||||
fullWidth:px(rect(actions).width)===px(rect(heading).width),stacked:copy?rect(actions).top>=rect(copy).bottom-0.5:null,
|
||||
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)}))}))};
|
||||
}
|
||||
// ---- 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));});
|
||||
@@ -26,7 +30,10 @@ test('inspection facts survive actual API to browser transport', async ({ page,
|
||||
const complete = result.complete;
|
||||
expect(Object.values(complete.generation).map(fact => fact.value)).toEqual(['9007199254740993', '28', '6.5', 'dpmpp_2m', 'karras', 'fixture-model.safetensors']);
|
||||
expect(Object.values(complete.generation).every(fact => fact.source === 'api-graph' && fact.evidence === 'configuration')).toBe(true);
|
||||
expect(Object.values(result.ambiguous.generation).every(fact => fact.status === 'ambiguous' && fact.value === null)).toBe(true);
|
||||
// Differing branches show what the legacy parser shows: the first sampler's seed.
|
||||
expect(Object.values(result.ambiguous.generation).map(fact => [fact.status, fact.value])).toEqual([
|
||||
['known', '9007199254740993'], ['known', '28'], ['known', '6.5'], ['known', 'dpmpp_2m'], ['known', 'karras'], ['known', 'fixture-model.safetensors']]);
|
||||
expect(Object.values(result.ambiguous.generation).every(fact => fact.sampler_id === null)).toBe(true);
|
||||
expect(result['workflow-only'].generation.seed).toMatchObject({ status: 'known', value: '9007199254740993', source: 'workflow', evidence: 'configuration' });
|
||||
expect(result['workflow-only'].prompts.positive.source).toBe('saved-association');
|
||||
expect(Object.values(result['control-heavy'].generation).every(fact => fact.status === 'unresolved' && fact.value === null && fact.reason === 'projection-limited')).toBe(true);
|
||||
@@ -68,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);
|
||||
@@ -82,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`).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');
|
||||
@@ -92,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.');
|
||||
@@ -141,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}/);
|
||||
});
|
||||
@@ -156,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');
|
||||
@@ -170,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');
|
||||
@@ -183,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})=>{
|
||||
@@ -223,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').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');
|
||||
@@ -237,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();});
|
||||
});
|
||||
|
||||
@@ -245,81 +481,165 @@ 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);
|
||||
let release,started;const held=new Promise(resolve=>{release=resolve;});const hit=new Promise(resolve=>{started=resolve;});
|
||||
await page.route('**/images/output?*',async route=>{started();await held;await route.continue();},{times:1});
|
||||
// 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').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})=>{
|
||||
@@ -330,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();
|
||||
@@ -371,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) {
|
||||
@@ -403,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);
|
||||
});
|
||||
|
||||
@@ -434,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);
|
||||
}
|
||||
@@ -482,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');
|
||||
@@ -503,23 +826,23 @@ 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:'Appearance',exact:true}).selectOption(theme);
|
||||
await selectFile(page,'complete.png');await expect(inspector(page).locator('.generation-facts dd').first()).toHaveText('9007199254740993');
|
||||
if(width===1600)await page.evaluate(()=>scrollTo(0,0));else await inspector(page).locator('.file-cover').evaluate(node=>node.scrollIntoView({block:'start'}));
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
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'});
|
||||
@@ -528,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);
|
||||
@@ -543,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('workspace-appearance');preference.value=theme;preference.dispatchEvent(new Event('change'));},theme);
|
||||
const geometry=await modal.evaluate(node=>{const style=getComputedStyle(node),box=node.getBoundingClientRect(),rail=node.querySelector('.image-preview-details').getBoundingClientRect();return{width:box.width,height:box.height,padding:style.padding,radius:style.borderRadius,rail:rail.width,gap:getComputedStyle(node.querySelector('.image-preview-body')).columnGap};});
|
||||
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('#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`)});
|
||||
}
|
||||
@@ -590,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`).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},
|
||||
@@ -651,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) {
|
||||
@@ -675,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);
|
||||
@@ -683,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,14 @@ 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',
|
||||
'Gallery card tag button (#249 D9, image-annotations slice) remains outside #209',
|
||||
'Production keeps mobile toolbar inside media panel and readable scrolling details; the concept toolbar collision is not reproduced',
|
||||
],
|
||||
};
|
||||
@@ -34,29 +34,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 +104,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,563 @@
|
||||
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 once D9 narrows its actions to `actionsWidth`, 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;
|
||||
* - D9: the wider 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;
|
||||
const tag=actions.buttons.find(button=>button.label?.startsWith('Tag or auto-tag '));
|
||||
// D9: no Tag button until the image-annotations slice; the actions group narrows by its box and gap.
|
||||
const freed=tag?tag.width+parseFloat(actions.columnGap):0;
|
||||
// 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 actionsWidth=actions.width-freed;
|
||||
const listRow=list?listInfo(card,actionsWidth):{};
|
||||
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,width:actionsWidth,left:listRow.actionsLeft??actions.left+freed,
|
||||
buttons:actions.buttons.filter(button=>button!==tag)}}};
|
||||
});
|
||||
// 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) 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});
|
||||
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(['1536 × 1152 · PNG','1536 × 1152 · PNG']);
|
||||
// Their own footer is one line shorter than the row it reserves.
|
||||
for(const card of seedless)expect(card.info.height).toBeLessThan(reference.cards[0].info.height);
|
||||
});
|
||||
@@ -0,0 +1,353 @@
|
||||
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} 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']);
|
||||
});
|
||||
|
||||
// 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,
|
||||
@@ -18,8 +18,8 @@ test.beforeEach(async({page,request})=>{
|
||||
for(const [width,height,theme] of [[1600,1100,'dark'],[390,844,'light']])test(`editor state captures for maintainer acceptance ${width} ${theme}`,async({page},testInfo)=>{
|
||||
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:'Appearance',exact:true}).selectOption(theme);
|
||||
const shot=name=>captureCanonicalState(page,testInfo,`production-${name}-${width}-${theme}`,name.startsWith('editor-archived')?'.library-variants':'dialog.library-editor');
|
||||
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-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();
|
||||
|
||||
@@ -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);
|
||||
@@ -111,21 +112,53 @@ test('sender requires explicit destination, previews full changes and acknowledg
|
||||
await chosen.getByRole('button',{name:'Close',exact:true}).click();
|
||||
await page.evaluate(()=>scrollTo(0,0));
|
||||
await page.screenshot({path:testInfo.outputPath('inspector-dark.png'),fullPage:true});
|
||||
await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption('light');
|
||||
await page.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();}
|
||||
});
|
||||
|
||||
// D6: ComfyUI ↗ is a link to the canvas; on Images with a selected file whose two roles resolved, a plain click opens
|
||||
// the acknowledged pair send instead (concept app.js 1750-1757).
|
||||
test('ComfyUI ↗ opens the pair send for a selected resolved image, else follows the link',async({page,context})=>{
|
||||
const host=await fakeHost(context,page);
|
||||
try {
|
||||
await page.goto('/prompt_manager/workspace#/overview');
|
||||
const link=page.locator('header.topbar').getByRole('link',{name:'ComfyUI',exact:true});
|
||||
await expect(link).toHaveAttribute('href','/');
|
||||
// Record whether the link's own handler let the navigation through, then stop it so the page stays.
|
||||
await page.evaluate(()=>{window.followed=[];document.addEventListener('click',event=>{if(!event.target.closest?.('.canvas-link'))return;window.followed.push(!event.defaultPrevented);event.preventDefault();});});
|
||||
await link.click();await expect(dialog(page)).toHaveCount(0);
|
||||
await page.locator('nav [data-route="images"]').click();await expect(page.locator('article[data-image-key]').first()).toBeVisible();
|
||||
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 to ComfyUI: /})).toBeEnabled();
|
||||
await link.click();
|
||||
const sender=await chooseDestination(page);
|
||||
expect(await page.evaluate(()=>window.followed)).toEqual([true,true,false]);
|
||||
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
|
||||
for(const text of ['Cat portrait','blurry, watermark'])await expect(sender).toContainText(text);
|
||||
expect(await host.evaluate(()=>window.applied)).toEqual([]);
|
||||
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:'Cat portrait',negative:'blurry, watermark'}]);
|
||||
await sender.getByRole('button',{name:'Close',exact:true}).click();await expect(dialog(page)).toHaveCount(0);await expect(link).toBeFocused();
|
||||
// Another page has no image selection, so the link is a plain link again.
|
||||
await page.locator('nav [data-route="library"]').click();await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
|
||||
await link.click();await expect(dialog(page)).toHaveCount(0);
|
||||
expect(await page.evaluate(()=>window.followed)).toEqual([true,true,false,true]);
|
||||
} finally {await host.evaluate(()=>window.fixtureHost.dispose());await host.close();}
|
||||
});
|
||||
|
||||
test('host apply rejection retains the current text and requires a new preview',async({page,context})=>{
|
||||
const host=await fakeHost(context,page,{rejectApply:true});
|
||||
try {
|
||||
await page.goto(workspace);await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:'Send positive prompt',exact:true}).click();
|
||||
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();
|
||||
@@ -135,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);
|
||||
});
|
||||
@@ -146,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:''});
|
||||
@@ -155,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 {
|
||||
@@ -171,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);
|
||||
});
|
||||
@@ -195,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();}
|
||||
});
|
||||
@@ -215,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);
|
||||
});
|
||||
|
||||
@@ -232,21 +267,49 @@ test('unverified saved associations explain uncertainty and cannot be sent',asyn
|
||||
await expect(details(page).getByRole('button',{name:/^Send /})).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('unresolved roles name the blocking node as text and offer no prompt actions',async({page})=>{
|
||||
const line='Prompt uses a node the inspector does not support (<img src=x onerror="window.injected=1">).';
|
||||
await page.route('**/images/inspect/uncertain.png?root=0*',route=>route.fulfill({json:{success:true,root_scope:new URL(route.request().url()).searchParams.get('root_scope'),image:{root_index:0,path:'uncertain.png',filename:'uncertain.png'},prompts:{
|
||||
positive:{status:'unresolved',text:null,source:null,message:line},negative:{status:'unresolved',text:null,source:null,message:line},
|
||||
}}}));
|
||||
await page.goto(workspace);await select(page,'0:uncertain.png');
|
||||
for (const name of ['Positive','Negative']) await expect(role(details(page),name)).toContainText(line);
|
||||
await expect(details(page).locator('img[onerror]')).toHaveCount(0);
|
||||
expect(await page.evaluate(()=>window.injected)).toBeUndefined();
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
"""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
|
||||
from tests.browser.workspace_inspector_fixture import seed_workspace_inspector
|
||||
from tests.browser.workspace_history_fixture import (
|
||||
@@ -70,6 +80,68 @@ 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")
|
||||
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")
|
||||
@@ -106,6 +178,8 @@ 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)
|
||||
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]
|
||||
@@ -128,6 +202,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",
|
||||
@@ -305,6 +384,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)
|
||||
@@ -379,6 +459,64 @@ def create_app(root: Path) -> web.Application:
|
||||
)
|
||||
return web.json_response({"success": True})
|
||||
|
||||
@routes.post("/__test__/concept/reset")
|
||||
async def concept_reset(request: web.Request) -> web.Response:
|
||||
"""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) - set(limits)
|
||||
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),
|
||||
seedless_images=data.get("seedless_images", 0),
|
||||
)
|
||||
)
|
||||
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"
|
||||
@@ -399,14 +537,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__":
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
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 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,
|
||||
// 1600x1100 and 390x844, normal and reduced motion. Paired captures are artifacts only; there are no PNG baselines.
|
||||
test.use({locale:'en-US',timezoneId:'UTC'});
|
||||
|
||||
// Routes whose shell is compared. The concept and production share route names for these three pages.
|
||||
const ROUTES=Object.freeze([['images','Images'],['library','Prompt library'],['tags','Tags']]);
|
||||
// The heading's content (description copy, actions) belongs to each page's own PR (C1, E1, F1) and is compared there;
|
||||
// Tags & categories is already rebuilt (#245), so its heading is compared in full.
|
||||
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');
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
const total=items.reduce((sum,item)=>sum+item.width,0)+gap*(items.length-1);
|
||||
let start=0,spacing=gap;
|
||||
if(justify==='flex-end'||justify==='end')start=width-total;
|
||||
else if(justify==='center')start=(width-total)/2;
|
||||
else if(justify==='space-between'&&items.length>1)spacing=gap+(width-total)/(items.length-1);
|
||||
let x=start;
|
||||
return items.map(item=>{const left=Math.round(x);x+=item.width+spacing;return left;});
|
||||
}
|
||||
|
||||
/** The top-actions row once removed items are gone: a centered flex row, so its size and item offsets follow. */
|
||||
function remainingTopActions(actions) {
|
||||
const kept=actions.items.filter(item=>!removedItem(item));
|
||||
const shown=kept.filter(item=>item.shown);
|
||||
const height=Math.max(...shown.map(item=>item.height));
|
||||
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 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)};
|
||||
}
|
||||
|
||||
/** The footer once its button is gone: paddings plus the remaining spans along the footer's flex direction. */
|
||||
function remainingFooter(footer,spans) {
|
||||
const content=footer.flexDirection==='column'
|
||||
?spans.reduce((sum,span)=>sum+span.height,0)+parseFloat(footer.rowGap)*(spans.length-1)
|
||||
:Math.max(...spans.map(span=>span.height));
|
||||
return `${parseFloat(footer.paddingTop)+parseFloat(footer.paddingBottom)+content}px`;
|
||||
}
|
||||
|
||||
// D28 ruling (final review A, Minor 1): Overview gets a distinct outline home glyph. The concept has no home icon, and
|
||||
// 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: 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:[
|
||||
// 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.
|
||||
{...system,text:'Overview',current:null,gapAfter:null,icon:{...system.icon,d:OVERVIEW_GLYPH}},
|
||||
]};
|
||||
});
|
||||
return {...reference,chrome:{...chrome,
|
||||
// D29: no prototype suffix in the document title.
|
||||
title:chrome.title.replace(/ · Prompt Manager concept$/,' · Prompt Manager'),
|
||||
// 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. 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. */
|
||||
function headingFrame(heading) {
|
||||
if(!heading)return heading;
|
||||
const {height,description,actions,...frame}=heading;
|
||||
const {text:copy,...descriptionTokens}=description??{};
|
||||
const {buttons,stacked,...actionTokens}=actions??{};
|
||||
return {...frame,description:description?descriptionTokens:null,actions:actions?actionTokens:null};
|
||||
}
|
||||
|
||||
function compare(route,reference,actual) {
|
||||
const expected=mapShellConcept(reference);
|
||||
const heading=FULL_HEADING.has(route);
|
||||
return [
|
||||
...diffInventory({chrome:expected.chrome,noPageScroll:expected.noPageScroll},{chrome:actual.chrome,noPageScroll:actual.noPageScroll}),
|
||||
...diffInventory(heading?expected.heading:headingFrame(expected.heading),heading?actual.heading:headingFrame(actual.heading))
|
||||
.map(item=>({...item,path:`heading.${item.path}`})),
|
||||
];
|
||||
}
|
||||
|
||||
/** The comparison as a gate: throws, naming every differing path, unless production equals the mapped concept. */
|
||||
function assertShellMatches(route,reference,actual) {
|
||||
const diff=compare(route,reference,actual);
|
||||
if(diff.length)throw new Error(`the shell differs from the concept at ${diff.map(item=>item.path).join(', ')}`);
|
||||
}
|
||||
|
||||
/** Keyboard focus rings on the first WORKSPACE nav link and the first heading button. */
|
||||
async function ringsOf(page,mainSelector) {
|
||||
// Any key press makes the next programmatic focus keyboard-modal (`:focus-visible`); WebKit does not Tab to links.
|
||||
await page.locator('#page-title').focus();await page.keyboard.press('Tab');
|
||||
const button=page.locator(`${mainSelector} .heading-actions button`).first();
|
||||
await expect(button).toBeFocused();
|
||||
const headingButton=await focusRingOf(button);
|
||||
const link=page.locator('#primary-nav a').first();await link.focus();
|
||||
const navLink=await focusRingOf(link);
|
||||
await link.blur();
|
||||
return {navLink,headingButton};
|
||||
}
|
||||
|
||||
/** Hover colors of the first non-current WORKSPACE row, the Theme select and the ComfyUI link. */
|
||||
async function hoversOf(page) {
|
||||
return {
|
||||
navRow:await hoverStateOf(page,page.locator('#primary-nav a:not([aria-current])').first()),
|
||||
theme:await hoverStateOf(page,page.getByRole('combobox',{name:'Theme',exact:true})),
|
||||
canvasLink:await hoverStateOf(page,page.locator('.topbar .canvas-link')),
|
||||
};
|
||||
}
|
||||
|
||||
/** A viewport capture with the footer (and, at 1600, the sidebar's bottom group) in view. */
|
||||
async function captureFooter(page,testInfo,name) {
|
||||
await page.locator('.app-footer').scrollIntoViewIfNeeded();
|
||||
await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
|
||||
await captureCanonicalState(page,testInfo,name);
|
||||
}
|
||||
|
||||
test.beforeEach(async({request})=>{await seedConcept(request);});
|
||||
|
||||
for(const [width,height] of SIZES)for(const theme of THEMES)test(`shell geometry, tokens and controls match the concept ${width}x${height} ${theme}`,async({page},testInfo)=>{
|
||||
await page.setViewportSize({width,height});const result={};
|
||||
for(const motion of MOTIONS)for(const [route,breadcrumb] of ROUTES) {
|
||||
await openConcept(page,route,theme,motion);await page.mouse.move(0,0);
|
||||
const reference=await inventoryOf(page,'#main');
|
||||
if(motion==='no-preference')await captureCanonicalState(page,testInfo,`concept-shell-${route}-${width}-${theme}`);
|
||||
const referenceRings=FULL_HEADING.has(route)?await ringsOf(page,'#main'):null;
|
||||
const referenceHovers=await hoversOf(page);
|
||||
if(motion==='no-preference')await captureFooter(page,testInfo,`concept-footer-${route}-${width}-${theme}`);
|
||||
await openProduction(page,route,theme,motion);await page.mouse.move(0,0);
|
||||
const actual=await inventoryOf(page,'#workspace-main');
|
||||
if(motion==='no-preference')await captureCanonicalState(page,testInfo,`production-shell-${route}-${width}-${theme}`);
|
||||
const actualRings=FULL_HEADING.has(route)?await ringsOf(page,'#workspace-main'):null;
|
||||
const actualHovers=await hoversOf(page);
|
||||
if(motion==='no-preference')await captureFooter(page,testInfo,`production-footer-${route}-${width}-${theme}`);
|
||||
result[`${motion} ${route}`]={route,breadcrumb,reference,actual,diff:compare(route,reference,actual),referenceRings,actualRings,
|
||||
referenceHovers,actualHovers};
|
||||
}
|
||||
const path=testInfo.outputPath('shell-geometry.json');await writeFile(path,JSON.stringify(result,null,2));
|
||||
await testInfo.attach('shell-geometry',{path,contentType:'application/json'});
|
||||
for(const [key,{route,breadcrumb,reference,actual,diff,referenceRings,actualRings,referenceHovers,actualHovers}] of Object.entries(result)) {
|
||||
// The concept itself is what this comparison assumes: its sample counts and its shell controls.
|
||||
expect.soft(reference.chrome.navs[0].rows.map(row=>[row.text,row.count?.text??null]),`${key} concept WORKSPACE rows`)
|
||||
.toEqual([['Images','24'],['Prompt library','6'],['Tags & categories','15'],['Inspect a file',null]]);
|
||||
expect.soft(reference.chrome.topActions.items.map(item=>item.className||item.tag),`${key} concept top actions`)
|
||||
.toEqual(['concept-badge','button','theme-label','canvas-link']);
|
||||
expect.soft(reference.chrome.breadcrumbs.parts.at(-1).text,`${key} concept breadcrumb`).toBe(breadcrumb);
|
||||
expect.soft(reference.chrome.title,`${key} concept title`).toBe(`${breadcrumb} · Prompt Manager concept`);
|
||||
expect.soft(actual.chrome.title,`${key} production title`).toBe(`${breadcrumb} · Prompt Manager`);
|
||||
expect.soft(reference.heading.h1.text,`${key} concept page title`).toBe(route==='tags'?'Tags & categories':breadcrumb);
|
||||
// The concept's own nav-row hover (style.css:183-186) is what production is held to.
|
||||
expect.soft(referenceHovers.navRow,`${key} concept nav-row hover`).toMatchObject({hovered:true,color:reference.chrome.body.color});
|
||||
expect.soft(actualHovers,`${key} hover states`).toEqual(referenceHovers);
|
||||
expect.soft(diff,`${key} differences after the recorded deviations`).toEqual([]);
|
||||
expect.soft(actual.noPageScroll,`${key} no page-level horizontal scroll`).toBe(true);
|
||||
if(referenceRings) {
|
||||
// The toolkit's button ring (2px offset) wins over the concept's 3px for buttons, in the concept too (#245).
|
||||
expect.soft(referenceRings.headingButton,`${key} concept heading-button ring`).toMatchObject({focused:true,visible:true,outlineStyle:'solid',outlineWidth:'2px'});
|
||||
expect.soft(referenceRings.navLink,`${key} concept nav-link ring`).toMatchObject({focused:true,outlineStyle:'solid',outlineWidth:'2px',outlineOffset:'3px'});
|
||||
expect.soft(actualRings,`${key} focus rings`).toEqual(referenceRings);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 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);
|
||||
const reference=mapShellConcept(await inventoryOf(page,'#main'));
|
||||
await openProduction(page,'tags','dark');await page.mouse.move(0,0);
|
||||
expect(diffInventory(reference.chrome,(await inventoryOf(page,'#workspace-main')).chrome)).toEqual([]);
|
||||
await page.locator('.breadcrumbs').evaluate(node=>node.remove());
|
||||
await page.locator('.connection small').evaluate(node=>node.remove());
|
||||
const actual=await inventoryOf(page,'#workspace-main');
|
||||
expect(actual.chrome.breadcrumbs).toBeNull();
|
||||
expect(actual.chrome.connection.smallFont).toBeNull();
|
||||
const paths=diffInventory(reference.chrome,actual.chrome).map(item=>item.path);
|
||||
expect(paths).toContain('breadcrumbs');
|
||||
expect(paths).toContain('connection.smallFont');
|
||||
expect(paths).toContain('connection.small');
|
||||
});
|
||||
|
||||
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 #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: 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
|
||||
// concept never uses) and one DOM mutation (the breadcrumbs removed) are applied to production after an exact match,
|
||||
// and each must make the comparison fail with the path of what changed.
|
||||
for(const [width,height] of SIZES)test(`shell comparison detects mutations ${width}x${height}`,async({page})=>{
|
||||
await page.setViewportSize({width,height});
|
||||
await openConcept(page,'tags','dark');await page.mouse.move(0,0);
|
||||
const reference=await inventoryOf(page,'#main');
|
||||
await openProduction(page,'tags','dark');await page.mouse.move(0,0);
|
||||
const measure=()=>inventoryOf(page,'#workspace-main');
|
||||
const clean=await measure();
|
||||
assertShellMatches('tags',reference,clean);
|
||||
|
||||
const restoreCss=await mutate(page,{css:'.sidebar nav a { min-height: 30px }'});
|
||||
const cssMutated=await measure();
|
||||
expect(()=>assertShellMatches('tags',reference,cssMutated)).toThrow(/navs\.0\.rows\.0\.minHeight/);
|
||||
expect(()=>assertShellMatches('tags',reference,cssMutated)).toThrow(/navs\.0\.rows\.0\.height/);
|
||||
await restoreCss();
|
||||
assertShellMatches('tags',reference,await measure());
|
||||
|
||||
await mutate(page,{remove:['.breadcrumbs']});
|
||||
const domMutated=await measure();
|
||||
expect(domMutated.chrome.breadcrumbs).toBeNull();
|
||||
// The missing section is reported by its own path (A6: absent sections become null), not by a measuring error.
|
||||
expect(()=>assertShellMatches('tags',reference,domMutated)).toThrow(/(at |, )chrome\.breadcrumbs(,|$)/);
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
// mapVocabularyConcept / mapVocabularyDialog apply only the recorded deviations, each named with its ruling.
|
||||
export {inventoryOf, diffInventory} from './library-editor-canonical-comparison.mjs';
|
||||
|
||||
export function pageInventoryOf(main, production) {
|
||||
export function pageInventoryOf(main) {
|
||||
const css=(node,...keys)=>{const style=getComputedStyle(node);return Object.fromEntries(keys.map(key=>[key,style[key]]));};
|
||||
const px=value=>Math.round(value);
|
||||
const visible=node=>node.getClientRects().length>0;
|
||||
@@ -18,8 +18,8 @@ export function pageInventoryOf(main, production) {
|
||||
|
||||
// Rendered line count of a cell's text, so a wrapped short name is reported even when the row height is unchanged.
|
||||
const lines=node=>{const range=document.createRange();range.selectNodeContents(node);return new Set([...range.getClientRects()].map(rect=>px(rect.top))).size;};
|
||||
const heading=main.querySelector(production?':scope > .workspace-heading':':scope > .heading');
|
||||
const copy=production?heading.querySelector('.workspace-heading-copy'):heading.firstElementChild;
|
||||
const heading=main.querySelector(':scope > .heading');
|
||||
const copy=heading.firstElementChild;
|
||||
const h1=copy.querySelector('h1'),subtitle=copy.querySelector('p'),actions=heading.querySelector('.heading-actions');
|
||||
const grid=main.querySelector(':scope > .two-col'),gridBox=grid.getBoundingClientRect();
|
||||
const surfaces=[...grid.querySelectorAll(':scope > section.surface')];
|
||||
|
||||
@@ -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
|
||||
@@ -19,7 +19,7 @@ const categoryOperation=(page,name)=>main(page).locator('.operation').filter({ha
|
||||
async function seed(request,data={}) {const response=await request.post('/__test__/vocabulary/reset',{data});await expect(response).toBeOK();return response.json();}
|
||||
async function open(page,theme,count) {
|
||||
await page.goto('about:blank');await page.goto(route);
|
||||
await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption(theme);
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
if(count!==undefined)await expect(rows(page)).toHaveCount(count);
|
||||
}
|
||||
async function openDialog(page,trigger) {await trigger.click();await expect(dialog(page)).toBeVisible();return dialog(page);}
|
||||
|
||||
@@ -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();
|
||||
@@ -353,7 +369,7 @@ test('route reentry restores older timeline range and both scroll axes after 61
|
||||
|
||||
for(const [width,height] of [[1600,1100],[390,844]])for(const theme of ['dark','light'])test(`final fix review states ${width} ${theme}`,async({page,request},testInfo)=>{
|
||||
test.setTimeout(60000);await page.setViewportSize({width,height});await page.addInitScript(()=>{window.IntersectionObserver=undefined;});
|
||||
const f=page.fixture;await open(page,f.detail_id);await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption(theme);
|
||||
const f=page.fixture;await open(page,f.detail_id);await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
await marker(page,f.runs.shared,'z').click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
|
||||
const missing=dialog(page).locator('[data-output-node-id="2"][data-image-ordinal="1"]');await missing.getByRole('button',{name:'Select reported output',exact:true}).click();
|
||||
await expect(missing).toHaveAttribute('aria-current','true');await expect(missing.getByRole('button',{name:'Retry current availability',exact:true})).toBeVisible();
|
||||
@@ -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();
|
||||
@@ -458,9 +474,49 @@ test('frozen Effective pair uses the shared acknowledged sender and Escape close
|
||||
} finally {await host.evaluate(()=>window.historyHost.dispose());await host.close();}
|
||||
});
|
||||
|
||||
test('the inline current file names its prompts apart from the frozen run fields',async({page})=>{
|
||||
// The current file resolves its own prompts, as a real host's saved PNG does, so the dialog holds two prompt texts per role.
|
||||
await page.route(url=>url.pathname.endsWith('/inspection'),async route=>{const response=await route.fetch();const body=await response.json();
|
||||
body.prompts={positive:{status:'resolved',text:'current file positive',source:'graph-inferred'},negative:{status:'resolved',text:'current file negative',source:'graph-inferred'}};
|
||||
await route.fulfill({response,json:body});});
|
||||
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('p.prompt-text'));
|
||||
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.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('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('p.prompt-text'))).toBe(before);
|
||||
});
|
||||
|
||||
test('the recorded run dialog with a resolved current file has no duplicate button or region names',async({page})=>{
|
||||
await page.route(url=>url.pathname.endsWith('/inspection'),async route=>{const response=await route.fetch();const body=await response.json();
|
||||
body.prompts={positive:{status:'resolved',text:'current file positive',source:'graph-inferred'},negative:{status:'resolved',text:'current file negative',source:'graph-inferred'}};
|
||||
await route.fulfill({response,json:body});});
|
||||
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.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.
|
||||
const snapshot=await dialog(page).ariaSnapshot();const counts=new Map();
|
||||
for(const line of snapshot.split('\n')){const match=/^\s*- (button|region|link) "((?:[^"\\]|\\.)*)"/.exec(line);if(!match)continue;
|
||||
const key=`${match[1]} ${match[2]}`;counts.set(key,(counts.get(key)||0)+1);}
|
||||
expect(counts.size).toBeGreaterThan(10);
|
||||
const receiptNames=await dialog(page).locator('[data-receipt]').first().evaluate(row=>[...row.querySelectorAll('button,a,[role="region"]')].map(node=>node.getAttribute('aria-label')||node.textContent.trim()));
|
||||
const repeated=[...counts].filter(([key,n])=>n>1&&!receiptNames.some(name=>key.endsWith(` ${name}`)));
|
||||
expect(repeated).toEqual([]);
|
||||
});
|
||||
|
||||
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(()=>{});});
|
||||
@@ -522,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'}}}));
|
||||
@@ -533,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();
|
||||
@@ -543,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');
|
||||
@@ -553,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');
|
||||
@@ -566,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');
|
||||
@@ -575,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);
|
||||
});
|
||||
@@ -583,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);
|
||||
};
|
||||
}
|
||||
@@ -662,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})=>{
|
||||
@@ -679,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')).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);
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -22,7 +22,7 @@ test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);
|
||||
|
||||
async function mountEditor(page,theme) {
|
||||
await page.goto(`${workspace}#/overview`);await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
|
||||
if(theme)await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption(theme);
|
||||
if(theme)await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
await page.evaluate(async()=>{
|
||||
const trigger=document.createElement('button');trigger.type='button';trigger.id='editor-trigger';trigger.textContent='Open editor';document.body.append(trigger);
|
||||
window.editorEvents={committed:[],reconciled:[]};
|
||||
@@ -433,12 +433,12 @@ for(const [width,height] of [[1600,1100],[390,844]])for(const theme of ['dark','
|
||||
|
||||
// Real entry actions: Library, Images and prompt history headings, and the Negative variants panel.
|
||||
const historyRegion=page=>page.getByRole('region',{name:'Prompt history',exact:true});
|
||||
const headingActions=page=>page.locator('#workspace-main .workspace-heading .heading-actions');
|
||||
const headingActions=page=>page.locator('#workspace-main > .heading > .heading-actions');
|
||||
const variantsPanel=page=>page.getByRole('region',{name:'Saved negative variants',exact:true});
|
||||
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})=>{
|
||||
@@ -866,3 +896,25 @@ test('long and error states retain footer focus and reflow',async({page})=>{
|
||||
await page.keyboard.press('Escape');await expect(dialog).toHaveCount(0);
|
||||
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);
|
||||
});
|
||||
|
||||
// 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/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)) {
|
||||
const [hash,openMode]=conceptModes[mode];
|
||||
await page.goto('about:blank');await page.goto(`/__test__/canonical/workspace#${hash}`);
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);await openMode(page);await expect(page.locator('#modal')).toBeVisible();
|
||||
if(mode==='revision')await page.locator('#modal-close').focus();
|
||||
references[mode]=mapConcept(await page.locator('#modal').evaluate(inventoryOf));
|
||||
}
|
||||
await mountEditor(page,theme);
|
||||
expect(await page.evaluate(()=>[...document.styleSheets].filter(sheet=>sheet.href).map(sheet=>new URL(sheet.href).pathname.replace(/^\/prompt_manager\//,'')))).toEqual(CONCEPT_BASE);
|
||||
const options={new:{mode:'new'},metadata:{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id},revision:{mode:'revision',positiveId:f.positive_id,pairId:f.pair_id},negative:{mode:'negative',positiveId:f.positive_id}};
|
||||
for(const mode of Object.keys(conceptModes)) {
|
||||
const dialog=await openEditor(page,options[mode]);await ready(dialog);
|
||||
expect(diffInventory(references[mode],await dialog.evaluate(inventoryOf)),`${mode} dialog`).toEqual([]);
|
||||
await page.keyboard.press('Escape');await expect(dialog).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,49 +233,65 @@ 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 page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Overview',exact:true}).click();
|
||||
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);
|
||||
const position=await page.evaluate(()=>scrollY);
|
||||
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Overview',exact:true}).evaluate(node=>node.click());
|
||||
await 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',exact:true}).evaluate(node=>node.click());
|
||||
await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('No matching saved positive');
|
||||
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 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,8 +325,8 @@ 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();
|
||||
await page.locator('.workspace-heading').getByRole('button',{name:'Send prompt pair to ComfyUI: Cat portrait',exact:true}).click();
|
||||
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});
|
||||
await expect(hosts.locator('option')).toHaveCount(2);await hosts.selectOption({label:'Library ComfyUI'});
|
||||
@@ -197,24 +335,26 @@ 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:'Workspace',exact:true}).getByRole('link',{name:'Overview',exact:true}).click();await expect(page.locator('.viewer-container')).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();}
|
||||
});
|
||||
|
||||
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-heading-copy > .intro')).toContainText('One positive.');
|
||||
await expect(page.locator('.library-shelf > .intro')).toHaveCount(0);
|
||||
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:'Appearance',exact:true}).selectOption(theme);
|
||||
await expect(page.locator('.prompt-positive h3').first()).toHaveCSS('color',theme==='dark'?'rgb(115, 221, 169)':'rgb(23, 107, 70)');
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
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);
|
||||
});
|
||||
@@ -3,19 +3,19 @@ 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 = ['overview', 'system'];
|
||||
const tools = ['activity', 'system', 'overview'];
|
||||
|
||||
async function noOverflow(page) {
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
|
||||
}
|
||||
async function groupedNavigation(page) {
|
||||
for (const [group, routes] of [['primary', primary], ['tools', tools]]) {
|
||||
const region = page.locator(`.workspace-nav-${group}`);
|
||||
for (const [group, routes] of [['primary', primary], ['system', tools]]) {
|
||||
const region = page.locator(`#${group}-nav`);
|
||||
await expect(region).toHaveCount(1);
|
||||
expect(await region.locator('[data-route]').evaluateAll(nodes => nodes.map(node => node.dataset.route))).toEqual(routes);
|
||||
}
|
||||
@@ -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', '.workspace-heading', 'h1', '.image-workspace', '.image-grid', '.image-card', '.image-details', '.prompt-block-heading', '.library-toolbar', '.library-grid', '.library-card', '.library-card-art', '.library-card-content'].map(selector => {
|
||||
const 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);
|
||||
@@ -92,25 +92,26 @@ for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of ['
|
||||
|
||||
const reset = await request.post('/__test__/reset', { data: { chronological_history: true } });await expect(reset).toBeOK();const fixture = await reset.json();
|
||||
await page.goto(`${workspace}#/library/${fixture.canonical_id}`);
|
||||
await page.getByRole('combobox', { name: 'Appearance', exact: true }).selectOption(theme);
|
||||
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-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('#workspace-main > .heading button.send')).toBeEnabled();
|
||||
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().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);
|
||||
|
||||
@@ -197,26 +202,26 @@ test('shell matches approved stable geometry in both themes', async ({ page, req
|
||||
for (const route of ['images', 'library']) {
|
||||
await page.goto(`${workspace}#/${route}`);
|
||||
if (width === 1600) await expect(page.locator('.sidebar')).toHaveCSS('width', '218px');
|
||||
await expect(page.locator('.workspace-heading > .workspace-heading-copy > h1')).toBeVisible();
|
||||
await expect(page.locator('#workspace-main > .heading > div > h1#page-title')).toBeVisible();
|
||||
await groupedNavigation(page);
|
||||
await expect(page.locator(route === 'images' ? 'article[data-image-key]' : 'article[data-prompt-id]').first()).toBeVisible();
|
||||
await expect(page.locator('.brand')).toHaveAttribute('href', '#/images');
|
||||
await expect(page.locator('#workspace-main > .eyebrow')).toHaveCount(0);
|
||||
for (const [theme, active] of [['dark', 'rgb(20, 54, 77)'], ['light', 'rgb(228, 241, 251)']]) {
|
||||
await page.getByRole('combobox', { name: 'Appearance', exact: true }).selectOption(theme);
|
||||
await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption(theme);
|
||||
await expect(page.locator('nav [aria-current="page"]')).toHaveCSS('background-color', active);
|
||||
await expect(page.locator('h1')).toHaveCSS('font-weight', '620');
|
||||
await expect(page.locator('h1')).toHaveCSS('letter-spacing', '-0.7px');
|
||||
await expect(page.locator('.workspace-heading')).toHaveCSS('margin-block-end', '22px');
|
||||
await expect(page.locator('#workspace-main > .heading')).toHaveCSS('margin-block-end', '22px');
|
||||
await expect(page.locator('h1')).toHaveCSS('font-size', width === 1600 ? '28px' : '25px');
|
||||
await expect(page.locator('.workspace-heading-copy > .intro')).toHaveCSS('line-height', '18px');
|
||||
await expect(page.locator('#workspace-main > .heading p.muted')).toHaveCSS('line-height', '18px');
|
||||
await expect(page.locator('#workspace-main')).toHaveCSS('padding-inline-start', width === 1600 ? '30px' : '14px');
|
||||
if (width === 1600) {
|
||||
await expect(page.locator('.sidebar')).toHaveCSS('width', '218px');
|
||||
await expect(page.locator('.topbar')).toHaveCSS('height', '68px');
|
||||
} else {
|
||||
await expect(page.locator('.sidebar')).toHaveCSS('position', 'static');
|
||||
expect(await page.locator('.workspace-shell').evaluate(node => getComputedStyle(node).gridTemplateColumns.split(' ').length)).toBe(1);
|
||||
expect(await page.locator('.app-shell').evaluate(node => getComputedStyle(node).gridTemplateColumns.split(' ').length)).toBe(1);
|
||||
}
|
||||
await noOverflow(page);
|
||||
await attachEvidence(page, testInfo, `${route}-${width}-${theme}`);
|
||||
@@ -236,15 +241,16 @@ test('long content and zoom keep grouped navigation reachable', async ({ page, r
|
||||
const link = page.locator(`nav [data-route="${route}"]`);
|
||||
await link.focus(); await expect(link).toBeFocused();
|
||||
}
|
||||
await page.getByRole('combobox', { name: 'Appearance', exact: true }).focus();
|
||||
await expect(page.getByRole('combobox', { name: 'Appearance', exact: true })).toBeFocused();
|
||||
await page.getByRole('combobox', { name: 'Theme', exact: true }).focus();
|
||||
await expect(page.getByRole('combobox', { name: 'Theme', exact: true })).toBeFocused();
|
||||
const text = page.locator('.prompt-text').filter({ hasText: 'A study in very long creative context' });
|
||||
await text.scrollIntoViewIfNeeded();
|
||||
expect((await text.textContent()).length).toBeGreaterThan(1500);
|
||||
await text.focus(); await expect(text).toBeFocused();
|
||||
// #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 = ''; });
|
||||
@@ -257,17 +263,17 @@ test('long content and zoom keep grouped navigation reachable', async ({ page, r
|
||||
}
|
||||
});
|
||||
|
||||
test('Appearance label and control fit the narrow shell at 200 percent zoom', async ({ page, request }, testInfo) => {
|
||||
test('Theme label and control fit the narrow shell at 200 percent zoom', async ({ page, request }, testInfo) => {
|
||||
await request.post('/__test__/reset', { data: { workspace_visual: true } });
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(page.locator('article[data-image-key]')).toHaveCount(24);
|
||||
await page.evaluate(() => { document.documentElement.style.zoom = '2'; });
|
||||
const preference = page.getByRole('combobox', { name: 'Appearance', exact: true });
|
||||
const preference = page.getByRole('combobox', { name: 'Theme', exact: true });
|
||||
const bounds = await preference.evaluate(select => {
|
||||
const field = select.closest('label');
|
||||
return { intendedWrapper: field.classList.contains('workspace-appearance'),
|
||||
boxes: [field, field.querySelector('.ca-field-label'), select].map(node => {
|
||||
return { intendedWrapper: field.classList.contains('theme-label') && field.parentElement.classList.contains('top-actions'),
|
||||
boxes: [field, select].map(node => {
|
||||
const box = node.getBoundingClientRect(); return { left: box.left, right: box.right, width: box.width };
|
||||
}), width: document.documentElement.clientWidth };
|
||||
});
|
||||
@@ -277,7 +283,7 @@ test('Appearance label and control fit the narrow shell at 200 percent zoom', as
|
||||
expect(box.right).toBeLessThanOrEqual(bounds.width + 1);
|
||||
}
|
||||
expect(bounds.intendedWrapper).toBe(true);
|
||||
for (const theme of ['follow', 'light', 'dark']) {
|
||||
for (const theme of ['system', 'light', 'dark']) {
|
||||
await preference.selectOption(theme); await expect(preference).toHaveValue(theme);
|
||||
}
|
||||
await preference.focus(); await expect(preference).toBeFocused();
|
||||
@@ -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 button']) {
|
||||
const node = card.querySelector(selector), box = node.getBoundingClientRect();
|
||||
if (box.width <= 0 || box.height <= 0 || box.left < outer.left - 1 || box.right > outer.right + 1 ||
|
||||
box.top < outer.top - 1 || box.bottom > outer.bottom + 1 ||
|
||||
(selector === 'small' && node.scrollHeight > node.clientHeight + 1)) {
|
||||
(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 button').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').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').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').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(); }
|
||||
});
|
||||
@@ -514,15 +537,15 @@ test('Library visual evidence keeps natural cards and returns from real history
|
||||
const page=await context.newPage();const errors=[];page.on('pageerror',error=>errors.push(error.message));await startCoverage(page,browserName);
|
||||
try {
|
||||
await page.goto(`${workspace}#/library`);await expect(page.locator('article[data-prompt-id]')).toHaveCount(6);
|
||||
await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption(theme);
|
||||
await 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('.library-history > div > .intro')).toHaveCount(1);
|
||||
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-heading-copy > .intro')).toHaveCount(1);
|
||||
await expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('One positive. Reusable negatives. Every experiment stays connected.');
|
||||
expect(errors).toEqual([]);
|
||||
} finally {await saveCoverage(page,browserName);await context.close();}
|
||||
}
|
||||
@@ -533,11 +556,11 @@ 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'];
|
||||
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);
|
||||
return { name, svg: icon instanceof SVGSVGElement, owner: icon.ownerDocument === otherDocument,
|
||||
return { name, svg: icon instanceof SVGSVGElement, className: icon.getAttribute('class'), owner: icon.ownerDocument === otherDocument,
|
||||
viewBox: icon.getAttribute('viewBox'), hidden: icon.getAttribute('aria-hidden'), focusable: icon.getAttribute('focusable'),
|
||||
fill: icon.getAttribute('fill'), stroke: icon.getAttribute('stroke'), width: icon.getAttribute('stroke-width'),
|
||||
path: icon.querySelector('path').getAttribute('d'), children: icon.childElementCount };
|
||||
@@ -549,10 +572,13 @@ test('approved icons are decorative safe SVGs and reject unknown vocabulary', as
|
||||
});
|
||||
expect(icons.unknown).toEqual([true, true, true, true, true]);
|
||||
for (const icon of icons.rendered) {
|
||||
expect(icon).toMatchObject({ svg: true, owner: true, viewBox: '0 0 24 24', hidden: 'true', focusable: 'false', fill: 'none', stroke: 'currentColor', width: '1.65', children: 1 });
|
||||
expect(icon).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);
|
||||
}
|
||||
expect(icons.rendered.find(icon => icon.name === 'overview').path).toBe('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);
|
||||
});
|
||||
|
||||
|
||||
@@ -575,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:'Appearance',exact:true}).selectOption(theme);
|
||||
await page.locator('.image-card .image-open').first().click();
|
||||
await expect(page.locator('.image-details h2')).toHaveText('study-01.png');
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
await page.locator('.media-card .image-card-actions button').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').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-heading-copy > .intro')).toHaveCount(0);
|
||||
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;
|
||||
@@ -43,8 +48,8 @@ test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);
|
||||
test('tags route renders the concept composition without a legacy handoff',async({page})=>{
|
||||
await page.goto(route);
|
||||
await expect(main(page).getByRole('heading',{level:1})).toHaveText('Tags & categories');
|
||||
await expect(main(page).locator('.workspace-heading-copy > p')).toHaveText('Organize once. Reuse everywhere.');
|
||||
const actions=main(page).locator('.workspace-heading .heading-actions button');
|
||||
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']);
|
||||
await expect(actions.first()).not.toHaveClass(/ca-primary/);
|
||||
await expect(main(page).getByRole('button',{name:'Auto-tag images'})).toHaveCount(0);
|
||||
@@ -85,11 +90,33 @@ 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',exact:true}).click();
|
||||
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');
|
||||
await row.getByRole('button',{name:'Browse',exact:true}).click();
|
||||
await expectLibrary(page,{category:'Landscapes',count:3});
|
||||
@@ -127,7 +154,8 @@ test('long vocabularies append and Load more is keyboard reachable',async({page,
|
||||
|
||||
test('read failure shows inline error with Retry and no zero counts',async({page})=>{
|
||||
let failures=0;
|
||||
await page.route('**/prompt_manager/vocabulary/tags?*',route=>{
|
||||
// Tag list pages only: the sidebar's Tags count is its own `limit=1` read.
|
||||
await page.route(url=>url.pathname==='/prompt_manager/vocabulary/tags' && url.searchParams.get('limit')!=='1',route=>{
|
||||
if(failures++) return route.continue();
|
||||
return route.fulfill({status:500,json:{success:false,error:'Database is unavailable'}});
|
||||
});
|
||||
@@ -149,7 +177,7 @@ test('breadcrumb and document title are Tags',async({page})=>{
|
||||
await page.goto(route);
|
||||
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Tags');
|
||||
await expect(page).toHaveTitle('Tags · Prompt Manager');
|
||||
await expect(page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Tags & categories',exact:true})).toHaveAttribute('aria-current','page');
|
||||
await expect(page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name: /^Tags & categories(?:\s*[\d,]+)?$/})).toHaveAttribute('aria-current','page');
|
||||
await page.reload();
|
||||
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Tags');
|
||||
await page.goto('/prompt_manager/workspace#/library');
|
||||
@@ -161,7 +189,8 @@ test('route disposal aborts requests and removes observers',async({page})=>{
|
||||
await page.addInitScript(()=>{
|
||||
window.__vocabularySignals=[];window.__vocabularyObservers=[];
|
||||
const fetch=window.fetch;
|
||||
window.fetch=(input,init={})=>{if(String(input).includes('/vocabulary/') && init.signal) window.__vocabularySignals.push(init.signal);return fetch(input,init);};
|
||||
// The page's own reads; the sidebar's `limit=1` Tags count belongs to the shell.
|
||||
window.fetch=(input,init={})=>{if(String(input).includes('/vocabulary/') && !/[?&]limit=1(?:&|$)/.test(String(input)) && init.signal) window.__vocabularySignals.push(init.signal);return fetch(input,init);};
|
||||
const Observer=window.IntersectionObserver;
|
||||
window.IntersectionObserver=class extends Observer {
|
||||
constructor(...args) {super(...args);this.record={targets:[],disconnected:false};window.__vocabularyObservers.push(this.record);}
|
||||
@@ -175,7 +204,7 @@ test('route disposal aborts requests and removes observers',async({page})=>{
|
||||
await expect.poll(()=>page.evaluate(()=>window.__vocabularySignals.length)).toBe(2);
|
||||
expect(await page.evaluate(()=>window.__vocabularyObservers.filter(item=>item.targets.some(name=>name.includes('vocabulary-continuation'))).length)).toBe(2);
|
||||
await expect(main(page).getByText('Loading…',{exact:true})).toHaveCount(2);
|
||||
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Prompt library',exact:true}).click();
|
||||
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name: /^Prompt library(?:\s*[\d,]+)?$/}).click();
|
||||
await expect(main(page).getByRole('heading',{level:1})).toHaveText('Prompt library');
|
||||
expect(await page.evaluate(()=>window.__vocabularySignals.map(signal=>signal.aborted))).toEqual([true,true]);
|
||||
expect(await page.evaluate(()=>window.__vocabularyObservers.filter(item=>item.targets.some(name=>name.includes('vocabulary-continuation'))).map(item=>item.disconnected))).toEqual([true,true]);
|
||||
@@ -193,10 +222,10 @@ test('390px layout stacks without horizontal page scroll; table scrolls locally'
|
||||
expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
|
||||
const layout=await main(page).evaluate(node=>{
|
||||
const grid=node.querySelector('.two-col'),[left,right]=grid.querySelectorAll(':scope > .surface'),wrap=node.querySelector('.table-wrap');
|
||||
const heading=node.querySelector('.workspace-heading'),operation=node.querySelector('.operation');
|
||||
const heading=node.querySelector(':scope > .heading'),operation=node.querySelector('.operation');
|
||||
return {tracks:getComputedStyle(grid).gridTemplateColumns.split(' ').length,stacked:right.getBoundingClientRect().top>=left.getBoundingClientRect().bottom,
|
||||
overflow:getComputedStyle(wrap).overflowX,heading:getComputedStyle(heading).flexDirection,operation:getComputedStyle(operation).flexDirection,
|
||||
actions:node.querySelector('.workspace-heading .heading-actions').getBoundingClientRect().width===heading.getBoundingClientRect().width};
|
||||
actions:node.querySelector(':scope > .heading > .heading-actions').getBoundingClientRect().width===heading.getBoundingClientRect().width};
|
||||
});
|
||||
expect(layout).toEqual({tracks:1,stacked:true,overflow:'auto',heading:'column',operation:'column',actions:true});
|
||||
});
|
||||
@@ -399,8 +428,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})=>{
|
||||
@@ -482,8 +511,9 @@ test('lost response is outcome-unknown',async({page,request})=>{
|
||||
await expect(main(page).getByRole('heading',{level:1})).toBeFocused();
|
||||
});
|
||||
|
||||
const nav=(page,name)=>page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name,exact:true});
|
||||
const chips=(page,name)=>main(page).locator('article[data-prompt-id] .library-chip').filter({hasText:new RegExp(`^${name}$`)});
|
||||
// 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] :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);
|
||||
@@ -666,10 +696,10 @@ async function openConceptTags(page,theme,motion) {
|
||||
async function openProductionTags(page,theme,motion) {
|
||||
await page.emulateMedia({reducedMotion:motion});
|
||||
await page.goto('about:blank');await page.goto(route);
|
||||
await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption(theme);
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
await expect(rows(page)).toHaveCount(manifest.tags.length);await expect(categoryRow(page,'Landscapes')).toHaveCount(1);
|
||||
}
|
||||
const pageOf=(page,production)=>page.locator(production?'#workspace-main':'#main').evaluate(pageInventoryOf,production);
|
||||
const pageOf=(page,production)=>page.locator(production?'#workspace-main':'#main').evaluate(pageInventoryOf);
|
||||
/** Breadcrumb, document title (D7: "Tags", only the concept suffix differs) and the keyboard focus ring on a row action. */
|
||||
async function chromeOf(page,production) {
|
||||
const scope=page.locator(production?'#workspace-main':'#main');
|
||||
@@ -791,3 +821,29 @@ test('mutation check: the comparison reports a changed surface padding and a cha
|
||||
expect(dialogPaths).toContain('fields.0.control.color');
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
// 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/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});
|
||||
const donors=await conceptDonors(page,theme);
|
||||
// Park the pointer: donor clicks leave it over a heading or row button, and a hover background is not compared.
|
||||
await openConceptTags(page,theme,'no-preference');await page.mouse.move(0,0);const reference=await pageOf(page,false);
|
||||
const dialogs={};
|
||||
for(const mode of Object.keys(conceptDialogs)) {
|
||||
await openConceptTags(page,theme,'no-preference');await conceptDialogs[mode](page);
|
||||
await expect(page.locator('#modal')).toBeVisible();dialogs[mode]=await page.locator('#modal').evaluate(inventoryOf);
|
||||
}
|
||||
await openProductionTags(page,theme,'no-preference');await page.mouse.move(0,0);
|
||||
expect(await loadedSheets(page)).toEqual(CONCEPT_BASE);
|
||||
const {diff,extra}=comparePage(reference,await pageOf(page,true));
|
||||
expect(diff).toEqual([]);expect(extra).toEqual(fixtureOnly());
|
||||
for(const mode of Object.keys(productionDialogs)) {
|
||||
const box=await productionDialogs[mode](page);
|
||||
expect(diffInventory(mapVocabularyDialog(mode,dialogs[mode],donors),await box.evaluate(inventoryOf)),`${mode} dialog`).toEqual([]);
|
||||
await page.keyboard.press('Escape');await expect(box).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,24 +1,35 @@
|
||||
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')
|
||||
.filter({ hasText: new RegExp(`^${label}$`) }).locator('..').locator('dd');
|
||||
// Concept sidebar: WORKSPACE links carry a real `.nav-count` once loaded, so their accessible name may end in it.
|
||||
const escapeName = label => label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const navName = label => new RegExp(`^${escapeName(label)}(?:\\s*[\\d,]+)?$`);
|
||||
const sidebarLink = (page, label) => page.locator('aside.sidebar nav').getByRole('link', { name: navName(label) });
|
||||
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'],
|
||||
['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 countAppearanceWrites(page) {
|
||||
async function countThemeWrites(page) {
|
||||
await page.evaluate(() => {
|
||||
window.__appearanceChanges = 0;
|
||||
window.__themeChanges = 0;
|
||||
const original = Storage.prototype.setItem;
|
||||
Storage.prototype.setItem = function (...args) {
|
||||
if (args[0] === 'pm_workspace_theme') window.__appearanceChanges++;
|
||||
if (args[0] === 'pm_workspace_theme') window.__themeChanges++;
|
||||
return original.apply(this, args);
|
||||
};
|
||||
});
|
||||
@@ -37,6 +48,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([]);
|
||||
});
|
||||
@@ -66,8 +79,7 @@ test('all shell destinations deep-link, reload and expose working existing appli
|
||||
await page.goto(`${workspace}#/${route}`);
|
||||
const main = page.locator('#workspace-main');
|
||||
await expect(main.getByRole('heading', { level: 1, name: label, exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('navigation', { name: 'Workspace', exact: true })
|
||||
.getByRole('link', { name: label, exact: true })).toHaveAttribute('aria-current', 'page');
|
||||
await expect(sidebarLink(page, label)).toHaveAttribute('aria-current', 'page');
|
||||
if (route === 'tags') await expect(main.locator('.handoff')).toHaveCount(0);
|
||||
if (legacy) {
|
||||
await expect(main.locator(`a[href="${legacy}"]`)).toBeVisible();
|
||||
@@ -76,16 +88,26 @@ 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();
|
||||
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 }) => {
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
const navigation = page.getByRole('navigation', { name: 'Workspace', exact: true });
|
||||
const images = navigation.getByRole('link', { name: 'Images', exact: true });
|
||||
const images = navigation.getByRole('link', { name: navName('Images') });
|
||||
await images.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
|
||||
await navigation.getByRole('link', { name: 'Prompt library', exact: true }).click();
|
||||
await navigation.getByRole('link', { name: navName('Prompt library') }).click();
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
|
||||
await page.goBack();
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
|
||||
@@ -93,7 +115,7 @@ test('keyboard navigation, browser history and unknown routes remain usable', as
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
|
||||
await page.goto(`${workspace}#/not-a-page`);
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
|
||||
await expect(navigation.getByRole('link', { name: 'Images', exact: true }))
|
||||
await expect(navigation.getByRole('link', { name: navName('Images') }))
|
||||
.toHaveAttribute('aria-current', 'page');
|
||||
});
|
||||
|
||||
@@ -101,44 +123,151 @@ test('skip to content focuses the current view without changing its route', asyn
|
||||
await page.goto(`${workspace}#/library`);
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(page.getByRole('link', { name: 'Skip to content', exact: true })).toBeFocused();
|
||||
await expect(page.getByRole('link', { name: 'Skip to workspace', exact: true })).toBeFocused();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(page).toHaveURL(`${workspace}#/library`);
|
||||
await expect(page.locator('#workspace-main')).toBeFocused();
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
|
||||
});
|
||||
|
||||
// The concept focuses its page title on every hashchange navigation (docs/concepts/workspace/app.js 1168), so a screen
|
||||
// reader announces "<page>, heading level 1"; the first render leaves focus alone.
|
||||
test('a sidebar route change focuses the page title, as the concept does', async ({ page }) => {
|
||||
await page.goto(`${workspace}#/library`);
|
||||
const title = page.locator('#page-title');
|
||||
await expect(title).toHaveText('Prompt library');
|
||||
await expect(title).not.toBeFocused();
|
||||
await sidebarLink(page, 'Images').click();
|
||||
await expect(title).toHaveText('Images');
|
||||
await expect(title).toBeFocused();
|
||||
await expect(page.locator('#workspace-main')).not.toBeFocused();
|
||||
await sidebarLink(page, 'Tags & categories').click();
|
||||
await expect(title).toHaveText('Tags & categories');
|
||||
await expect(title).toBeFocused();
|
||||
await page.goBack();
|
||||
await expect(title).toHaveText('Images');
|
||||
await expect(title).toBeFocused();
|
||||
});
|
||||
|
||||
test('returning to overview refreshes persisted counts without duplicating navigation', async ({ page, request }) => {
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
const prompts = statistic(page, 'Prompts');
|
||||
await expect(prompts).toHaveText('2');
|
||||
const navigation = page.getByRole('navigation', { name: 'Workspace', exact: true });
|
||||
await navigation.getByRole('link', { name: 'Images', exact: true }).click();
|
||||
await sidebarLink(page, 'Images').click();
|
||||
await expect(await request.post('/prompt_manager/save', { data: { text: 'A new persisted workspace prompt' } })).toBeOK();
|
||||
await navigation.getByRole('link', { name: 'Overview', exact: true }).click();
|
||||
await sidebarLink(page, 'Overview').click();
|
||||
await expect(prompts).toHaveText('3');
|
||||
await expect(navigation).toHaveCount(1);
|
||||
await expect(navigation.getByRole('link')).toHaveCount(routes.length);
|
||||
await expect(sidebarLinks(page)).toHaveCount(routes.length);
|
||||
});
|
||||
|
||||
test('appearance follows ComfyUI and persists an explicit light or dark preference', async ({ page }) => {
|
||||
test('theme System follows ComfyUI and persists an explicit light or dark preference', async ({ page }) => {
|
||||
await page.route('**/api/settings/Comfy.ColorPalette', route => route.fulfill({ json: 'light' }));
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
const theme = page.locator('[data-ca-theme]').first();
|
||||
await expect(theme).toHaveAttribute('data-ca-theme', 'light');
|
||||
const appearance = page.getByRole('combobox', { name: 'Appearance', exact: true });
|
||||
await appearance.selectOption({ label: 'Dark' });
|
||||
const themeSelect = page.getByRole('combobox', { name: 'Theme', exact: true });
|
||||
await expect(themeSelect).toHaveValue('system');
|
||||
await themeSelect.selectOption({ label: 'Dark' });
|
||||
await expect(theme).toHaveAttribute('data-ca-theme', 'dark');
|
||||
await page.reload();
|
||||
await expect(theme).toHaveAttribute('data-ca-theme', 'dark');
|
||||
await expect(appearance).toHaveValue('dark');
|
||||
await appearance.selectOption({ label: 'Light' });
|
||||
await expect(themeSelect).toHaveValue('dark');
|
||||
await themeSelect.selectOption({ label: 'Light' });
|
||||
await expect(theme).toHaveAttribute('data-ca-theme', 'light');
|
||||
await appearance.selectOption({ label: 'Follow ComfyUI' });
|
||||
await themeSelect.selectOption({ label: 'System' });
|
||||
await expect(theme).toHaveAttribute('data-ca-theme', 'light');
|
||||
});
|
||||
|
||||
test('unavailable host palette falls back to system appearance and tracks system changes', async ({ page }) => {
|
||||
// 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`);
|
||||
const topbar = page.locator('.workspace > header.topbar');
|
||||
await expect(topbar).toHaveCount(1);
|
||||
const shape = await topbar.evaluate(node => ({
|
||||
children: [...node.children].map(child => `${child.localName}.${child.className}`),
|
||||
crumbs: [...node.querySelector('.breadcrumbs').childNodes].filter(item => item.nodeType === 1 || item.textContent.trim())
|
||||
.map(item => item.nodeType === 1 ? `${item.localName}${item.id ? `#${item.id}` : ''}:${item.textContent}` : item.textContent.trim()),
|
||||
actions: [...node.querySelector('.top-actions').children].map(child => `${child.localName}.${child.className}`),
|
||||
label: node.querySelector('.theme-label').firstChild.textContent.trim(),
|
||||
labelFor: node.querySelector('.theme-label').htmlFor,
|
||||
options: [...node.querySelectorAll('#theme option')].map(option => [option.value, option.textContent]),
|
||||
arrow: [...node.querySelectorAll('.canvas-link span')].map(span => [span.textContent, span.getAttribute('aria-hidden')]),
|
||||
tokens: {
|
||||
height: getComputedStyle(node).height, padding: getComputedStyle(node).padding,
|
||||
crumbs: getComputedStyle(node.querySelector('.breadcrumbs')).fontSize,
|
||||
current: getComputedStyle(node.querySelector('#workspace-breadcrumb')).color === getComputedStyle(document.body).color,
|
||||
label: getComputedStyle(node.querySelector('.theme-label')).fontSize,
|
||||
select: getComputedStyle(node.querySelector('#theme')).fontSize,
|
||||
link: getComputedStyle(node.querySelector('.canvas-link')).fontSize,
|
||||
gap: getComputedStyle(node.querySelector('.top-actions')).columnGap,
|
||||
},
|
||||
}));
|
||||
expect(shape).toEqual({
|
||||
children: ['div.breadcrumbs', 'div.top-actions'],
|
||||
crumbs: ['ComfyAssets', 'span:/', 'span#workspace-breadcrumb:Images'],
|
||||
actions: ['a.activity-pill', 'button.', 'label.theme-label', 'a.canvas-link'],
|
||||
label: 'Theme', labelFor: 'theme',
|
||||
options: [['dark', 'Dark'], ['light', 'Light'], ['system', 'System']],
|
||||
arrow: [['↗', 'true']],
|
||||
tokens: { height: '68px', padding: '0px 30px', crumbs: '12px', current: true, label: '11px', select: '11px', link: '12px', gap: '12px' },
|
||||
});
|
||||
const theme = page.getByRole('combobox', { name: 'Theme', exact: true });
|
||||
await expect(theme).toHaveValue('system');
|
||||
const link = page.getByRole('link', { name: 'ComfyUI', exact: true });
|
||||
await expect(link).toHaveText('ComfyUI ↗');
|
||||
// D6: the ComfyUI canvas root under the page's mount (resolveUrl), never a hard-coded origin.
|
||||
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', 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(4);
|
||||
});
|
||||
|
||||
// Concept style.css 1375-1391: at <=700px the breadcrumbs hide, the actions spread across the bar and the Theme
|
||||
// label text collapses while the select keeps its accessible name.
|
||||
test('mobile topbar hides breadcrumbs and spreads actions', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto(`${workspace}#/images`);
|
||||
const theme = page.getByRole('combobox', { name: 'Theme', exact: true });
|
||||
await expect(theme).toBeVisible();
|
||||
await expect(page.locator('.breadcrumbs')).toBeHidden();
|
||||
const layout = await page.locator('.topbar').evaluate(node => {
|
||||
const bar = node.getBoundingClientRect(), actions = node.querySelector('.top-actions');
|
||||
const box = element => element.getBoundingClientRect();
|
||||
return {
|
||||
padding: getComputedStyle(node).paddingInline,
|
||||
actions: { width: box(actions).width, justify: getComputedStyle(actions).justifyContent, gap: getComputedStyle(actions).columnGap },
|
||||
expected: bar.width - 28,
|
||||
label: getComputedStyle(node.querySelector('.theme-label')).fontSize,
|
||||
select: getComputedStyle(node.querySelector('#theme')).fontSize,
|
||||
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);
|
||||
// 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');
|
||||
await expect(page.locator('body')).toHaveAttribute('data-ca-theme', 'light');
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
});
|
||||
|
||||
test('unavailable host palette falls back to the system theme and tracks system changes', async ({ page }) => {
|
||||
await page.route('**/api/settings/Comfy.ColorPalette', route => route.fulfill({ status: 503, json: {} }));
|
||||
await page.emulateMedia({ colorScheme: 'light' });
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
@@ -170,15 +299,14 @@ test('overview reports loading, offers retry after failure and renders real empt
|
||||
}
|
||||
});
|
||||
|
||||
test('small screens keep navigation and appearance accessible without horizontal page overflow', async ({ page }) => {
|
||||
test('small screens keep navigation and the theme accessible without horizontal page overflow', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
const navigation = page.getByRole('navigation', { name: 'Workspace', exact: true });
|
||||
for (const [, label] of routes) {
|
||||
await navigation.getByRole('link', { name: label, exact: true }).click();
|
||||
await sidebarLink(page, label).click();
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText(label);
|
||||
}
|
||||
await expect(page.getByRole('combobox', { name: 'Appearance', exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('combobox', { name: 'Theme', exact: true })).toBeVisible();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -196,20 +324,19 @@ test('leaving a loading overview prevents its delayed result from replacing the
|
||||
});
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
await expect(page.locator('#workspace-main').getByRole('status')).toBeVisible();
|
||||
await page.getByRole('navigation', { name: 'Workspace', exact: true })
|
||||
.getByRole('link', { name: 'Images', exact: true }).click();
|
||||
await sidebarLink(page, 'Images').click();
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
|
||||
release();
|
||||
await finished;
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
|
||||
await expect(page.locator('#workspace-main')).not.toContainText('999');
|
||||
await expect(page.locator('.workspace-nav-primary')).toHaveCount(1);
|
||||
await expect(page.locator('.workspace-nav-tools')).toHaveCount(1);
|
||||
await expect(page.getByRole('combobox', { name: 'Appearance', exact: true })).toHaveCount(1);
|
||||
await expect(page.locator('#workspace-main a[href="/prompt_manager/gallery"]')).toBeVisible();
|
||||
await countAppearanceWrites(page);
|
||||
await page.getByRole('combobox', { name: 'Appearance', exact: true }).selectOption('light');
|
||||
expect(await page.evaluate(() => window.__appearanceChanges)).toBe(1);
|
||||
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 .section-caption')).toBeVisible();
|
||||
await countThemeWrites(page);
|
||||
await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption('light');
|
||||
expect(await page.evaluate(() => window.__themeChanges)).toBe(1);
|
||||
});
|
||||
|
||||
test('cached-page restoration remounts one working shell and refreshes its data', async ({ page }) => {
|
||||
@@ -227,15 +354,393 @@ test('cached-page restoration remounts one working shell and refreshes its data'
|
||||
await expect(statistic(page, 'Prompts')).toHaveText('2');
|
||||
}
|
||||
expect(reads).toBe(3);
|
||||
const navigation = page.getByRole('navigation', { name: 'Workspace', exact: true });
|
||||
await expect(navigation.getByRole('link')).toHaveCount(routes.length);
|
||||
await expect(page.locator('.workspace-nav-primary')).toHaveCount(1);
|
||||
await expect(page.locator('.workspace-nav-tools')).toHaveCount(1);
|
||||
await expect(page.getByRole('combobox', { name: 'Appearance', exact: true })).toHaveCount(1);
|
||||
await countAppearanceWrites(page);
|
||||
await navigation.getByRole('link', { name: 'Images', exact: true }).click();
|
||||
await expect(sidebarLinks(page)).toHaveCount(routes.length);
|
||||
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 countThemeWrites(page);
|
||||
await sidebarLink(page, 'Images').click();
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
|
||||
await page.getByRole('combobox', { name: 'Appearance' }).selectOption('light');
|
||||
await page.getByRole('combobox', { name: 'Theme' }).selectOption('light');
|
||||
await expect(page.locator('body')).toHaveAttribute('data-ca-theme', 'light');
|
||||
expect(await page.evaluate(() => window.__appearanceChanges)).toBe(1);
|
||||
expect(await page.evaluate(() => window.__themeChanges)).toBe(1);
|
||||
});
|
||||
|
||||
// Task A2: the Overview and handoff panels use the concept's `.surface` (style.css 709-722) and `.small` from the
|
||||
// concept base stylesheet, not the retired shell sheet (18px h2, 11px panel radius).
|
||||
const surfaceStyle = node => {
|
||||
const style = getComputedStyle(node);
|
||||
const heading = getComputedStyle(node.querySelector(':scope > h2'));
|
||||
const intro = node.querySelector(':scope > p');
|
||||
return {
|
||||
padding: [style.paddingTop, style.paddingRight, style.paddingBottom, style.paddingLeft],
|
||||
radius: style.borderTopLeftRadius, border: `${style.borderTopWidth} ${style.borderTopStyle}`,
|
||||
background: style.backgroundColor,
|
||||
h2: { fontSize: heading.fontSize, fontWeight: heading.fontWeight, marginTop: heading.marginTop, marginBottom: heading.marginBottom },
|
||||
intro: intro && { fontSize: getComputedStyle(intro).fontSize, marginTop: getComputedStyle(intro).marginTop },
|
||||
};
|
||||
};
|
||||
const SURFACE = {
|
||||
padding: ['22px', '22px', '22px', '22px'], radius: '7px', border: '1px solid',
|
||||
h2: { fontSize: '17px', fontWeight: '600', marginTop: '0px', marginBottom: '0px' },
|
||||
intro: { fontSize: '12px', marginTop: '7px' },
|
||||
};
|
||||
|
||||
for (const width of [1600, 390]) test(`overview and handoff render with concept surfaces ${width}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
const main = page.locator('#workspace-main');
|
||||
await expect(statistic(page, 'Prompts')).toHaveText('2');
|
||||
const tools = main.locator('section.surface').filter({ has: page.getByRole('heading', { level: 2, name: 'Continue creating', exact: true }) });
|
||||
await expect(tools).toHaveCount(1);
|
||||
const surface = await tools.evaluate(surfaceStyle);
|
||||
expect(surface).toMatchObject(SURFACE);
|
||||
expect(surface.background).not.toBe('rgba(0, 0, 0, 0)');
|
||||
await expect(main.locator('.panel')).toHaveCount(0);
|
||||
const card = tools.locator('.tool-card').first();
|
||||
await expect(card.locator('p.small')).toHaveCount(1);
|
||||
expect(await card.locator('p.small').evaluate(node => getComputedStyle(node).fontSize)).toBe('12px');
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
|
||||
await sidebarLink(page, 'System').click();
|
||||
const handoff = main.locator('section.handoff');
|
||||
await expect(handoff).toHaveClass(/\bsurface\b/);
|
||||
expect(await handoff.evaluate(surfaceStyle)).toMatchObject(SURFACE);
|
||||
await expect(handoff.locator('a[href="/prompt_manager/admin"]')).toBeVisible();
|
||||
await expect(main.locator('.panel')).toHaveCount(0);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
});
|
||||
|
||||
// Task A3: the concept sidebar (index.html h16-41, app.js nav()) rendered from real data (D27, D28).
|
||||
const NAV_TOTALS = [
|
||||
['images', 'Images', '/prompt_manager/images/output?mode=workspace&limit=1'],
|
||||
['library', 'Prompt library', '/prompt_manager/library?limit=1'],
|
||||
['tags', 'Tags & categories', '/prompt_manager/vocabulary/tags?limit=1'],
|
||||
];
|
||||
async function serverTotals(request) {
|
||||
const totals = {};
|
||||
for (const [id, , url] of NAV_TOTALS) {
|
||||
const response = await request.get(url);
|
||||
await expect(response).toBeOK();
|
||||
totals[id] = (await response.json()).total;
|
||||
expect(Number.isSafeInteger(totals[id]) && totals[id] >= 0).toBe(true);
|
||||
}
|
||||
return totals;
|
||||
}
|
||||
const navCount = (page, route) => page.locator(`#primary-nav a[data-route="${route}"] .nav-count`);
|
||||
const childShape = locator => locator.evaluate(node => [...node.children]
|
||||
.map(child => `${child.localName}${child.id ? `#${child.id}` : ''}${[...child.classList].map(name => `.${name}`).join('')}`));
|
||||
|
||||
test('sidebar matches the concept groups and shows real counts', async ({ page, request }) => {
|
||||
const totals = await serverTotals(request);
|
||||
expect(totals.library).toBe(2);
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(page.locator('body > .app-shell > aside.sidebar')).toHaveCount(1);
|
||||
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-label',
|
||||
'nav#saved-nav', 'div.sidebar-bottom']);
|
||||
await expect(sidebar.locator('.brand')).toHaveAttribute('href', '#/images');
|
||||
// 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'));
|
||||
const links = await primary.evaluate(node => [...node.children].map(link => ({ tag: link.localName,
|
||||
href: link.getAttribute('href'), icon: link.querySelector('svg')?.getAttribute('class'),
|
||||
nodes: [...link.childNodes].map(child => child.nodeType === Node.TEXT_NODE ? `text:${child.textContent}`
|
||||
: `${child.localName}${[...child.classList].map(name => `.${name}`).join('')}`) })));
|
||||
expect(links).toEqual([
|
||||
{ tag: 'a', href: '#/images', nodes: ['svg.icon', 'text:Images', 'span.nav-count'], icon: 'icon' },
|
||||
{ tag: 'a', href: '#/library', nodes: ['svg.icon', 'text:Prompt library', 'span.nav-count'], icon: 'icon' },
|
||||
{ tag: 'a', href: '#/tags', nodes: ['svg.icon', 'text:Tags & categories', 'span.nav-count'], icon: 'icon' },
|
||||
{ tag: 'a', href: '#/inspect', nodes: ['svg.icon', 'text:Inspect a file'], icon: 'icon' },
|
||||
]);
|
||||
await expect(primary.locator('a[href="#/images"]')).toHaveAttribute('aria-current', 'page');
|
||||
await expect(primary.locator('a[aria-current]')).toHaveCount(1);
|
||||
await expect(page.locator('#primary-nav a[data-route="inspect"] .nav-count')).toHaveCount(0);
|
||||
// Concept tokens: 38px rows, 12px muted labels, 10px muted counts pushed to the end.
|
||||
const style = await primary.locator('a[href="#/library"]').evaluate(link => {
|
||||
const own = getComputedStyle(link), count = link.querySelector('.nav-count');
|
||||
return { minHeight: own.minHeight, fontSize: own.fontSize, gap: own.columnGap, count: getComputedStyle(count).fontSize,
|
||||
end: Math.round(link.getBoundingClientRect().right - count.getBoundingClientRect().right) };
|
||||
});
|
||||
expect(style).toEqual({ minHeight: '38px', fontSize: '12px', gap: '11px', count: '10px', end: 12 });
|
||||
// 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(3);
|
||||
for (const hidden of await page.locator('.sidebar-label, .connection').all()) await expect(hidden).toBeHidden();
|
||||
await expect(primary.locator('a[href="#/inspect"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('nav counts stay hidden until loaded and when their request fails', async ({ page, request }) => {
|
||||
const totals = await serverTotals(request);
|
||||
let release;
|
||||
const pending = new Promise(resolve => { release = resolve; });
|
||||
await page.route('**/prompt_manager/library?limit=1', async route => {
|
||||
await pending;
|
||||
await route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } });
|
||||
});
|
||||
await page.route('**/prompt_manager/vocabulary/tags?limit=1', route => route.fulfill({ json: { success: true, total: -4 } }));
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
|
||||
// Nothing renders for a read that is outstanding: no "0" placeholder. The settled Images count does not wait for it.
|
||||
await expect(navCount(page, 'images')).toHaveText(String(totals.images));
|
||||
await expect(navCount(page, 'library')).toHaveCount(0);
|
||||
await expect(navCount(page, 'tags')).toHaveCount(0);
|
||||
release();
|
||||
await expect(navCount(page, 'images')).toHaveText(String(totals.images));
|
||||
await expect(navCount(page, 'library')).toHaveCount(0);
|
||||
await expect(navCount(page, 'tags')).toHaveCount(0);
|
||||
await expect(page.locator('#primary-nav .nav-count')).toHaveCount(1);
|
||||
});
|
||||
|
||||
// A3 review: the Images listing scans output folders, so its slow count must not hold back Library and Tags.
|
||||
test('a slow Images count does not delay the Library and Tags badges', async ({ page, request }) => {
|
||||
const totals = await serverTotals(request);
|
||||
let release;
|
||||
const pending = new Promise(resolve => { release = resolve; });
|
||||
await page.route(url => url.pathname.endsWith('/prompt_manager/images/output') && url.searchParams.get('limit') === '1', async route => {
|
||||
await pending;
|
||||
await route.continue().catch(() => {});
|
||||
});
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
await expect(navCount(page, 'library')).toHaveText(String(totals.library));
|
||||
await expect(navCount(page, 'tags')).toHaveText(String(totals.tags));
|
||||
await expect(navCount(page, 'images')).toHaveCount(0);
|
||||
release();
|
||||
await expect(navCount(page, 'images')).toHaveText(String(totals.images));
|
||||
});
|
||||
|
||||
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([
|
||||
['#/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(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');
|
||||
await expect(connection.locator('small')).toHaveText('Your library, on your server');
|
||||
// D28: no fake connection state. The dot is the muted text colour, not the concept's demo green.
|
||||
const dot = await connection.evaluate(node => ({ dot: getComputedStyle(node.querySelector('i')).backgroundColor,
|
||||
muted: getComputedStyle(node.querySelector('small')).color }));
|
||||
expect(dot.dot).toBe(dot.muted);
|
||||
await page.locator('nav#system-nav').getByRole('link', { name: 'Overview', exact: true }).click();
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
|
||||
await expect(system.locator('a[href="#/overview"]')).toHaveAttribute('aria-current', 'page');
|
||||
await expect(page.locator('aside.sidebar a[aria-current]')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('counts refresh after a confirmed editor save', async ({ page, request }) => {
|
||||
const before = await serverTotals(request);
|
||||
await page.goto(`${workspace}#/library`);
|
||||
await expect(navCount(page, 'library')).toHaveText(String(before.library));
|
||||
await expect(navCount(page, 'tags')).toHaveText(String(before.tags));
|
||||
const reads = [];
|
||||
page.on('request', item => {
|
||||
const url = new URL(item.url());
|
||||
if (url.searchParams.get('limit') === '1') reads.push(url.pathname);
|
||||
});
|
||||
await page.getByRole('button', { name: '+ New prompt', exact: true }).click();
|
||||
const dialog = page.locator('dialog.library-editor');
|
||||
await expect(dialog.getByRole('button', { name: 'Save', exact: true })).toBeEnabled();
|
||||
await dialog.getByLabel('Title', { exact: true }).fill('Harbor at dawn');
|
||||
await dialog.getByLabel('Positive prompt', { exact: true }).fill('A quiet harbor at dawn for the sidebar count');
|
||||
await dialog.getByLabel('Tags, separated by commas', { exact: true }).fill('sidebar_count_tag');
|
||||
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
const after = await serverTotals(request);
|
||||
expect(after.library).toBe(before.library + 1);
|
||||
expect(after.tags).toBeGreaterThan(before.tags);
|
||||
await expect(navCount(page, 'library')).toHaveText(String(after.library));
|
||||
await expect(navCount(page, 'tags')).toHaveText(String(after.tags));
|
||||
expect(reads.filter(path => path === '/prompt_manager/library').length).toBeGreaterThanOrEqual(1);
|
||||
// A Library write cannot change the Images count, so the Images listing is not read again.
|
||||
expect(reads.filter(path => path === '/prompt_manager/images/output')).toEqual([]);
|
||||
});
|
||||
|
||||
test('a superseded count read never lands and a failed refresh removes the stale count', async ({ page, request }) => {
|
||||
const totals = await serverTotals(request);
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
await expect(navCount(page, 'library')).toHaveText(String(totals.library));
|
||||
const held = [];
|
||||
let reads = 0;
|
||||
await page.route('**/prompt_manager/library?limit=1', route => {
|
||||
if (reads++ === 0) { held.push(route); return undefined; }
|
||||
return route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } });
|
||||
});
|
||||
const invalidate = () => page.evaluate(async () => {
|
||||
const { invalidateLibraryAfterWrite } = await import('/prompt_manager/js/workspace/library.mjs');
|
||||
invalidateLibraryAfterWrite({ kind: 'test', receipt: {} });
|
||||
});
|
||||
await invalidate();
|
||||
await expect.poll(() => held.length).toBe(1);
|
||||
await invalidate();
|
||||
await expect(navCount(page, 'library')).toHaveCount(0);
|
||||
await expect(navCount(page, 'images')).toHaveText(String(totals.images));
|
||||
await expect(navCount(page, 'tags')).toHaveText(String(totals.tags));
|
||||
await held[0].fulfill({ json: { success: true, total: 999 } }).catch(() => {});
|
||||
await page.waitForTimeout(100);
|
||||
await expect(navCount(page, 'library')).toHaveCount(0);
|
||||
await page.unroute('**/prompt_manager/library?limit=1');
|
||||
// Leaving the page with a read in flight aborts it; the restored shell reads the counts again.
|
||||
let release;
|
||||
const pending = new Promise(resolve => { release = resolve; });
|
||||
await page.route('**/prompt_manager/library?limit=1', async route => { await pending; await route.continue().catch(() => {}); }, { times: 1 });
|
||||
await invalidate();
|
||||
await page.evaluate(() => {
|
||||
window.dispatchEvent(new PageTransitionEvent('pagehide', { persisted: true }));
|
||||
window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true }));
|
||||
});
|
||||
release();
|
||||
await expect(navCount(page, 'library')).toHaveText(String(totals.library));
|
||||
await expect(page.locator('#primary-nav a')).toHaveCount(4);
|
||||
await expect(page.locator('#primary-nav .nav-count')).toHaveCount(3);
|
||||
});
|
||||
|
||||
// Task A5: the concept heading() (app.js 303-305) with its typography (style.css 73-103, 283-300; <=700 1408-1416) on
|
||||
// every native route, and the production footer copy in the concept `.app-footer` (D29).
|
||||
const headingShape = node => {
|
||||
const style = element => getComputedStyle(element);
|
||||
const shape = element => `${element.localName}${element.id ? `#${element.id}` : ''}${[...element.classList].map(name => `.${name}`).join('')}`;
|
||||
const [copy, actions] = node.children;
|
||||
const [h1, description] = copy.children;
|
||||
const main = node.parentElement.getBoundingClientRect();
|
||||
return {
|
||||
children: [...node.children].map(shape), copy: [...copy.children].map(shape), tabIndex: h1.tabIndex,
|
||||
direction: style(node).flexDirection, marginBottom: style(node).marginBottom,
|
||||
h1: { fontSize: style(h1).fontSize, lineHeight: Math.round(parseFloat(style(h1).lineHeight) / parseFloat(style(h1).fontSize) * 100) / 100, fontWeight: style(h1).fontWeight, letterSpacing: style(h1).letterSpacing, marginTop: style(h1).marginTop },
|
||||
description: { text: description.textContent.length > 0, fontSize: style(description).fontSize, marginTop: style(description).marginTop, muted: style(description).color === style(document.querySelector('.connection small')).color },
|
||||
actions: { gap: style(actions).columnGap, left: Math.round(actions.getBoundingClientRect().left), fullWidth: Math.round(actions.getBoundingClientRect().width) === Math.round(node.getBoundingClientRect().width) },
|
||||
mainLeft: Math.round(main.left + parseFloat(getComputedStyle(node.parentElement).paddingLeft)),
|
||||
pageTitles: document.querySelectorAll('#page-title').length,
|
||||
retired: document.querySelectorAll('.workspace-heading, .workspace-heading-copy, #workspace-main .intro').length,
|
||||
};
|
||||
};
|
||||
|
||||
test('every route renders the concept heading', async ({ page, request }) => {
|
||||
const library = await (await request.get('/prompt_manager/library?limit=1')).json();
|
||||
const positiveId = library.items[0].id;
|
||||
const pages = [['overview', 'Overview'], ['images', 'Images'], ['library', 'Prompt library'], ['tags', 'Tags & categories'], [`library/${positiveId}`, null]];
|
||||
for (const width of [1600, 390]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
for (const [hash, title] of pages) {
|
||||
await page.goto(`${workspace}#/${hash}`);
|
||||
const heading = page.locator('#workspace-main > .heading');
|
||||
await expect(heading).toHaveCount(1);
|
||||
if (title) await expect(heading.locator('h1')).toHaveText(title);
|
||||
else await expect(heading.getByRole('button', { name: '← Prompt library', exact: true })).toBeVisible();
|
||||
await expect(heading.locator('p.muted')).not.toHaveText('');
|
||||
const shape = await heading.evaluate(headingShape);
|
||||
expect(shape, hash).toMatchObject({
|
||||
children: ['div', 'div.heading-actions'], copy: ['h1#page-title', 'p.muted'], tabIndex: -1, marginBottom: '22px',
|
||||
h1: width === 1600 ? { fontSize: '28px', lineHeight: 1.3, fontWeight: '620', letterSpacing: '-0.7px', marginTop: '0px' }
|
||||
: { fontSize: '25px', lineHeight: 1.3, fontWeight: '620', letterSpacing: '-0.7px', marginTop: '0px' },
|
||||
description: { text: true, fontSize: '12px', marginTop: '6px', muted: true },
|
||||
actions: { gap: '8px' }, pageTitles: 1, retired: 0,
|
||||
});
|
||||
if (width === 1600) {
|
||||
expect(shape.direction, hash).toBe('row');
|
||||
} else {
|
||||
// The concept stacks the heading and gives the actions the full row, starting at main's 14px gutter.
|
||||
expect(shape.direction, hash).toBe('column');
|
||||
expect(shape.actions, hash).toMatchObject({ left: 14, fullWidth: true });
|
||||
expect(shape.mainLeft, hash).toBe(14);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('the footer is the concept app footer with production copy', async ({ page }) => {
|
||||
for (const [width, padding, direction] of [[1600, '22px 30px', 'row'], [390, '20px 14px', 'column']]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
const footer = page.locator('.workspace > footer.app-footer');
|
||||
await expect(footer).toHaveCount(1);
|
||||
await expect(page.locator('.page-footer')).toHaveCount(0);
|
||||
expect(await footer.evaluate(node => ({
|
||||
children: [...node.children].map(child => child.localName), text: node.textContent,
|
||||
padding: getComputedStyle(node).padding, fontSize: getComputedStyle(node).fontSize, direction: getComputedStyle(node).flexDirection,
|
||||
muted: getComputedStyle(node).color === getComputedStyle(document.querySelector('.connection small')).color,
|
||||
afterMain: node.previousElementSibling.id,
|
||||
}))).toEqual({ children: ['span'], text: 'ComfyAssets · Local tools. Your library.', padding, fontSize: '10px', direction, muted: true, afterMain: 'workspace-main' });
|
||||
}
|
||||
});
|
||||
|
||||
// #164: the maintainer serves ComfyUI behind a proxy prefix. Everything, the ComfyUI link included, stays on that mount.
|
||||
const PROXY_MOUNT = '/ComfyBackendDirect';
|
||||
async function serveUnderProxyMount(page) {
|
||||
const escaped = [];
|
||||
page.on('request', item => {
|
||||
const { pathname } = new URL(item.url());
|
||||
if (pathname.startsWith('/prompt_manager/')) escaped.push(pathname);
|
||||
});
|
||||
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);
|
||||
// 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;
|
||||
}
|
||||
|
||||
test('the ComfyUI link points to the root of a non-standard proxy mount', async ({ page }) => {
|
||||
const escaped = await serveUnderProxyMount(page);
|
||||
for (const route of ['overview', 'library', 'tags']) {
|
||||
await page.goto(`${PROXY_MOUNT}${workspace}#/${route}`);
|
||||
await expect(page.locator('#workspace-main > .heading h1')).toBeVisible();
|
||||
const link = page.getByRole('link', { name: 'ComfyUI', exact: true });
|
||||
await expect(link).toHaveAttribute('href', `${PROXY_MOUNT}/`);
|
||||
expect(await link.evaluate(node => node.href)).toBe(new URL(`${PROXY_MOUNT}/`, page.url()).href);
|
||||
}
|
||||
await expect(navCount(page, 'library')).toHaveCount(1);
|
||||
expect(escaped).toEqual([]);
|
||||
});
|
||||
|
||||
test('a resolver failure for the ComfyUI link never stops the shell from mounting', async ({ page }) => {
|
||||
await serveUnderProxyMount(page);
|
||||
// Wrap the shared client as it loads so only the topbar's canvas-root lookup throws (the sender reads the same root).
|
||||
// Every engine's stack names the calling module's URL; each injected failure is counted so a renamed module or a
|
||||
// changed call site fails this test on the count instead of silently testing nothing.
|
||||
await page.addInitScript(() => {
|
||||
let client;
|
||||
window.__resolverFailures = { injected: 0 };
|
||||
Object.defineProperty(window, 'ApiClient', { configurable: true, get: () => client, set(value) {
|
||||
client = { ...value, resolveUrl: (url, ...rest) => {
|
||||
if (url === '/prompt_manager/' && /\/workspace\/topbar\.mjs\b/.test(String(new Error().stack))) {
|
||||
window.__resolverFailures.injected += 1;
|
||||
throw new Error('resolver unavailable');
|
||||
}
|
||||
return value.resolveUrl(url, ...rest);
|
||||
} };
|
||||
} });
|
||||
});
|
||||
const warnings = [];
|
||||
page.on('console', message => { if (message.type() === 'warning') warnings.push(message.text()); });
|
||||
await page.goto(`${PROXY_MOUNT}${workspace}#/overview`);
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
|
||||
await expect(sidebarLinks(page)).toHaveCount(routes.length);
|
||||
expect(await page.evaluate(() => window.__resolverFailures.injected), 'the topbar lookup was the one that failed').toBe(1);
|
||||
expect(warnings.filter(text => text.includes('the ComfyUI link falls back to the page mount'))).toHaveLength(1);
|
||||
await expect(page.getByRole('link', { name: 'ComfyUI', exact: true })).toHaveAttribute('href', `${PROXY_MOUNT}/`);
|
||||
await sidebarLink(page, 'Prompt library').click();
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
|
||||
});
|
||||
|
||||
@@ -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})
|
||||
@@ -0,0 +1,485 @@
|
||||
"""Concept-shaped real data for every concept comparison (D30 of #249).
|
||||
|
||||
The approved concept's six prompts (docs/concepts/workspace/app.js:71-178) and its
|
||||
24 sample images (app.js:180-239) become real rows and real PNG files in this
|
||||
server's disposable database and output folder, so production renders the
|
||||
concept's shapes from its own stores: the same titles, texts, categories, tags,
|
||||
ratings and negative names, the same image dimensions, filenames and seeds, and
|
||||
the same sidebar counts (24 images, 6 prompts, 15 tags). Nothing is mocked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID, uuid5
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
from utils.execution_contexts import (
|
||||
ContextBundle,
|
||||
PromptSnapshot,
|
||||
RecipeContext,
|
||||
TextEvidence,
|
||||
)
|
||||
from utils.execution_history_events import make_finish, make_start
|
||||
from utils.execution_outputs import OutputBundle, OutputReference, snapshot_output_roots
|
||||
from utils.pair_execution import EXECUTION_KEY
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from py.api import PromptManagerAPI
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConceptPrompt:
|
||||
id: str
|
||||
title: str
|
||||
category: str
|
||||
tags: tuple[str, ...]
|
||||
positive: str
|
||||
negatives: tuple[tuple[str, str], ...]
|
||||
color: tuple[str, str, str]
|
||||
rating: int
|
||||
|
||||
|
||||
# app.js:71-178, in concept order. Negatives are (name, text) pairs.
|
||||
CONCEPT_PROMPTS = (
|
||||
ConceptPrompt(
|
||||
"alpine",
|
||||
"Alpine observatory",
|
||||
"Landscapes",
|
||||
("cinematic", "blue_hour", "architecture"),
|
||||
"A remote alpine observatory at blue hour, warm light through glass, dramatic "
|
||||
"mountain peaks, cinematic composition, atmospheric mist, fine detail",
|
||||
(
|
||||
("Quality baseline", "blurry, low quality, low resolution"),
|
||||
(
|
||||
"Clean architecture",
|
||||
"blurry, low quality, low resolution, distorted architecture, "
|
||||
"watermark, text",
|
||||
),
|
||||
("No negative", ""),
|
||||
),
|
||||
("#14233c", "#9aaac3", "#d8d9dd"),
|
||||
5,
|
||||
),
|
||||
ConceptPrompt(
|
||||
"dunes",
|
||||
"Quiet geometry",
|
||||
"Architecture",
|
||||
("minimal", "warm_light", "editorial"),
|
||||
"Monolithic sandstone pavilion in a vast desert, sweeping dunes, warm "
|
||||
"terracotta, late afternoon sun, architectural photography, quiet geometry",
|
||||
(
|
||||
("Natural materials", "plastic, harsh shadows, noise, watermark"),
|
||||
(
|
||||
"Clean lines",
|
||||
"plastic, harsh shadows, noise, watermark, distorted geometry",
|
||||
),
|
||||
),
|
||||
("#6c4948", "#ddad91", "#efe0c5"),
|
||||
4,
|
||||
),
|
||||
ConceptPrompt(
|
||||
"forest",
|
||||
"Somewhere in the pines",
|
||||
"Landscapes",
|
||||
("forest", "mist", "peaceful"),
|
||||
"A quiet cedar cabin above a still forest lake, towering evergreens, soft "
|
||||
"morning mist, muted jade and moss, a small amber window, cinematic still",
|
||||
(("Soft atmosphere", "oversaturated, blurry, low quality, text"),),
|
||||
("#163b3d", "#6f9691", "#c0cfc1"),
|
||||
5,
|
||||
),
|
||||
ConceptPrompt(
|
||||
"city",
|
||||
"After the rain",
|
||||
"Environments",
|
||||
("neon", "night", "cinematic"),
|
||||
"Rain-soaked city street at night, blue neon and rose reflections, deep "
|
||||
"perspective, cinematic science fiction, solitary architecture, detailed "
|
||||
"atmosphere",
|
||||
(
|
||||
("Clean atmosphere", "blurry, low resolution, watermark, oversaturated"),
|
||||
(
|
||||
"Less signage",
|
||||
"blurry, low resolution, watermark, oversaturated, text, signs",
|
||||
),
|
||||
),
|
||||
("#1a2143", "#6869a1", "#cf8fbc"),
|
||||
4,
|
||||
),
|
||||
ConceptPrompt(
|
||||
"lunar",
|
||||
"The long way home",
|
||||
"Illustration",
|
||||
("space", "minimal", "concept_art"),
|
||||
"An isolated lunar research outpost, a luminous planet above the horizon, "
|
||||
"sweeping pale terrain, quiet science fiction, finely detailed concept art",
|
||||
(("Quality baseline", "blurry, low quality, watermark"),),
|
||||
("#19283b", "#7398ac", "#e2d3c4"),
|
||||
5,
|
||||
),
|
||||
ConceptPrompt(
|
||||
"coast",
|
||||
"Between sea and sky",
|
||||
"Landscapes",
|
||||
("coast", "soft_light", "peaceful"),
|
||||
"A solitary lighthouse on a rugged coastal cliff, sea mist, glints of morning "
|
||||
"light on turquoise water, atmospheric perspective, fine art landscape",
|
||||
(("Natural light", "oversaturated, flat lighting, watermark, text"),),
|
||||
("#255565", "#8cbcc2", "#e8d9bf"),
|
||||
4,
|
||||
),
|
||||
)
|
||||
# app.js:220-227: the image at flat index n has the n % 6 dimensions; with six
|
||||
# prompts per round that is the prompt's own index.
|
||||
CONCEPT_DIMENSIONS = (
|
||||
(1536, 1152),
|
||||
(1024, 1536),
|
||||
(1280, 1280),
|
||||
(1600, 900),
|
||||
(1152, 1536),
|
||||
(1536, 1024),
|
||||
)
|
||||
CONCEPT_IMAGE_ROUNDS = 4
|
||||
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)
|
||||
SESSION = "5c1f9a7e-3f0b-4a55-9d0e-2b8c4f6a7d10"
|
||||
_TABLES = (
|
||||
"execution_run_outputs",
|
||||
"execution_output_capture",
|
||||
"execution_run_contexts",
|
||||
"execution_context_capture",
|
||||
"execution_runs",
|
||||
"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",
|
||||
"generated_images",
|
||||
"prompt_tags",
|
||||
"tags",
|
||||
"prompt_library_state",
|
||||
"prompts",
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def concept_filename(prompt: ConceptPrompt, round_index: int) -> str:
|
||||
"""The concept's sample filename (app.js:187)."""
|
||||
return f"{prompt.id}_{round_index + 1:05}.png"
|
||||
|
||||
|
||||
def _graph(positive: str, negative: str, seed: int) -> dict:
|
||||
"""A ComfyUI API prompt with the concept's facts (app.js:406-408).
|
||||
|
||||
The recipe is a Prompt Manager pair node, so with its embedded execution record
|
||||
the inspector reports both roles as execution evidence, which the panel badges
|
||||
"Execution recorded" (D21). One sampler keeps the seed unambiguous.
|
||||
"""
|
||||
return {
|
||||
"1": {
|
||||
"class_type": "PromptManagerPair",
|
||||
"inputs": {"text": positive, "negative_text": negative, "clip": ["2", 1]},
|
||||
},
|
||||
"2": {
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"inputs": {"ckpt_name": CONCEPT_MODEL},
|
||||
},
|
||||
"3": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"model": ["2", 0],
|
||||
"positive": ["1", 0],
|
||||
"negative": ["1", 1],
|
||||
"seed": seed,
|
||||
"steps": 28,
|
||||
"cfg": 6.5,
|
||||
"sampler_name": "dpmpp_2m",
|
||||
"scheduler": "karras",
|
||||
},
|
||||
},
|
||||
"4": {"class_type": "SaveImage", "inputs": {"images": ["3", 0]}},
|
||||
}
|
||||
|
||||
|
||||
def _execution_record(positive: str, negative: str) -> dict:
|
||||
"""The pair node's embedded execution evidence (utils/pair_execution.py)."""
|
||||
record = {
|
||||
"text": positive,
|
||||
"negative_text": negative,
|
||||
"positive_text": positive,
|
||||
"negative_effective_text": negative,
|
||||
"prepend_text": "",
|
||||
"append_text": "",
|
||||
}
|
||||
return {"version": 1, "nodes": {"1": {"records": [record]}}}
|
||||
|
||||
|
||||
def _snapshot(text: str) -> PromptSnapshot:
|
||||
"""A recorded prompt snapshot whose raw and effective texts are ``text``."""
|
||||
|
||||
def known(value: str) -> TextEvidence:
|
||||
return TextEvidence(value, "recorded", "runtime_return", None)
|
||||
|
||||
return PromptSnapshot(known(text), known(text), known(""), known(""))
|
||||
|
||||
|
||||
def write_concept_png(path: Path, prompt_index: int, round_index: int) -> dict:
|
||||
"""Write the concept's sample image ``round_index`` of prompt ``prompt_index``.
|
||||
|
||||
Returns its facts: variant, negative, seed and dimensions.
|
||||
"""
|
||||
prompt = CONCEPT_PROMPTS[prompt_index]
|
||||
index = round_index * len(CONCEPT_PROMPTS) + prompt_index
|
||||
variant = round_index % len(prompt.negatives)
|
||||
negative = prompt.negatives[variant][1]
|
||||
seed = concept_seed(prompt_index, round_index)
|
||||
width, height = size = CONCEPT_DIMENSIONS[index % len(CONCEPT_DIMENSIONS)]
|
||||
sky, ground, light = prompt.color
|
||||
info = PngInfo()
|
||||
info.add_text("prompt", json.dumps(_graph(prompt.positive, negative, seed)))
|
||||
info.add_text(
|
||||
EXECUTION_KEY, json.dumps(_execution_record(prompt.positive, negative))
|
||||
)
|
||||
with Image.new("RGB", size, sky) as image:
|
||||
draw = ImageDraw.Draw(image)
|
||||
draw.rectangle((0, height * 2 // 3, width, height), fill=ground)
|
||||
radius = min(width, height) // 9
|
||||
left, top = width * 2 // 3, height // 6
|
||||
draw.ellipse((left, top, left + radius, top + radius), fill=light)
|
||||
image.save(path, pnginfo=info)
|
||||
return {
|
||||
"variant": variant,
|
||||
"negative": negative,
|
||||
"seed": seed,
|
||||
"width": width,
|
||||
"height": height,
|
||||
}
|
||||
|
||||
|
||||
def _clear(api: PromptManagerAPI, root: Path) -> None:
|
||||
with api.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()
|
||||
|
||||
|
||||
def _record_run(
|
||||
api: PromptManagerAPI,
|
||||
*,
|
||||
epoch: str,
|
||||
item: int,
|
||||
context: RecipeContext,
|
||||
output: OutputReference,
|
||||
observed_at: int,
|
||||
) -> str:
|
||||
"""One finished execution with its recipe context and its one output."""
|
||||
job = str(uuid5(UUID(SESSION), f"concept-job-{item}"))
|
||||
start = make_start(SESSION, ((item, job, {}, {}), item), epoch, observed_at)
|
||||
api.db.write_execution_start(start)
|
||||
finish = make_finish(
|
||||
SESSION,
|
||||
item,
|
||||
epoch,
|
||||
SimpleNamespace(messages=[("execution_success", {"prompt_id": job})]),
|
||||
observed_at + 10,
|
||||
)
|
||||
api.db.write_execution_finish(finish)
|
||||
api.db.write_execution_contexts(
|
||||
start.run_id, epoch, ContextBundle((context,), "complete", "captured")
|
||||
)
|
||||
api.db.write_execution_outputs_for_finish(
|
||||
finish, OutputBundle((output,), "complete", "reported")
|
||||
)
|
||||
return start.run_id
|
||||
|
||||
|
||||
def _write_seedless_png(path: Path) -> None:
|
||||
"""A plain PNG with no metadata: no prompt, no seed, no link."""
|
||||
with Image.new("RGB", SEEDLESS_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,
|
||||
seedless_images: int = 0,
|
||||
) -> 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.
|
||||
"""
|
||||
_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 = []
|
||||
# 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,
|
||||
first_text,
|
||||
expected_epoch=epoch,
|
||||
parent_metadata={
|
||||
"title": prompt.title,
|
||||
"category": prompt.category,
|
||||
"tags": list(prompt.tags),
|
||||
"rating": prompt.rating,
|
||||
},
|
||||
)
|
||||
positive_id = receipt["positive"]["id"]
|
||||
pair_ids = [receipt["pair"]["id"]]
|
||||
db.update_prompt_pair_metadata(pair_ids[0], title=first_name)
|
||||
for name, text in prompt.negatives[1:]:
|
||||
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.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 = [], []
|
||||
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}")
|
||||
)
|
||||
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)
|
||||
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 "
|
||||
"WHERE id IN (SELECT prompt_id FROM prompt_library_state)"
|
||||
).fetchone()
|
||||
tags = [row[0] for row in conn.execute("SELECT name FROM tags ORDER BY id")]
|
||||
if tuple(counts) != (len(CONCEPT_PROMPTS), CONCEPT_RUN_COUNT, CONCEPT_RUN_COUNT):
|
||||
raise RuntimeError(f"Concept fixture run counts are not real: {tuple(counts)}")
|
||||
api._generate_gallery_thumbnails_sync(api._get_all_output_dirs(), (150, 150))
|
||||
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,
|
||||
"tags": tags,
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -89,7 +89,9 @@ def seed_workspace_inspector(
|
||||
],
|
||||
"links": [],
|
||||
}
|
||||
ambiguous = {**complete, "5": complete["3"]}
|
||||
# Two branches that differ only in seed: facts merge per field (spec section 10).
|
||||
branch = {**complete["3"], "inputs": {**complete["3"]["inputs"], "seed": 7}}
|
||||
ambiguous = {**complete, "5": branch}
|
||||
examples = (
|
||||
("complete.png", {"prompt": complete, "workflow": workflow}),
|
||||
("empty.png", {}),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,16 +237,16 @@ 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: `${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('button', { name: 'Send prompt pair', exact: true }).click();const sender = workspace.getByRole('dialog', { name: 'Send to ComfyUI', exact: true });
|
||||
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 });
|
||||
const hosts = sender.getByRole('combobox', { name: 'ComfyUI window', exact: true });await expect(hosts.locator('option')).toHaveCount(2);await hosts.selectOption({ index: 1 });
|
||||
await sender.getByRole('combobox', { name: 'Destination node', exact: true }).selectOption({ index: 1 });await sender.getByRole('button', { name: 'Preview changes', exact: true }).click();
|
||||
await sender.getByRole('button', { name: 'Apply to ComfyUI', exact: true }).click();await expect(sender).toContainText('Sent to ComfyUI.');
|
||||
|
||||
@@ -22,13 +22,13 @@ 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-heading').getByRole('button',{name:`Send prompt pair to ComfyUI: ${positive}`,exact:true}).click();
|
||||
await workspace.locator('#workspace-main > .heading').getByRole('button',{name:`Send prompt pair to ComfyUI: ${positive}`,exact:true}).click();
|
||||
const sender=workspace.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
|
||||
const hosts=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});await expect(hosts.locator('option')).toHaveCount(2);await hosts.selectOption({index:1});
|
||||
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({index:1});
|
||||
@@ -60,14 +60,14 @@ test('native editor preview sends exact saved roles across renderer transitions'
|
||||
try {
|
||||
// Created through the native editor entry action, with an explicitly empty negative.
|
||||
await workspace.goto('/prompt_manager/workspace#/library');
|
||||
await workspace.locator('.workspace-heading').getByRole('button',{name:'+ New prompt',exact:true}).click();
|
||||
await workspace.locator('#workspace-main > .heading').getByRole('button',{name:'+ New prompt',exact:true}).click();
|
||||
const dialog=workspace.locator('dialog.library-editor');await expect(dialog.getByRole('button',{name:'Save',exact:true})).toBeEnabled();
|
||||
await dialog.getByLabel('Title',{exact:true}).fill(title);await dialog.getByLabel('Positive prompt',{exact:true}).fill(positive);
|
||||
await dialog.getByRole('button',{name:'Save',exact:true}).click();await expect(dialog).toHaveCount(0);
|
||||
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();
|
||||
@@ -75,7 +75,7 @@ test('native editor preview sends exact saved roles across renderer transitions'
|
||||
pairMetadata:detail.variants.map(({id,title,category,tags,archived,negative_text})=>({id,title,category,tags,archived,negative_text}))};
|
||||
};
|
||||
const before=await evidence();expect(before.pairMetadata).toHaveLength(1);
|
||||
const send=workspace.locator('.workspace-heading').getByRole('button',{name:`Send prompt pair to ComfyUI: ${title}`,exact:true});
|
||||
const send=workspace.locator('#workspace-main > .heading').getByRole('button',{name:`Send prompt pair to ComfyUI: ${title}`,exact:true});
|
||||
for(const renderer of [testInfo.project.metadata.initialRenderer,!testInfo.project.metadata.initialRenderer,testInfo.project.metadata.initialRenderer]) {
|
||||
await page.evaluate(async renderer=>{const {app}=await import('/scripts/app.js');await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled',renderer);await app.loadGraphData(app.graph.serialize());app.canvas.centerOnNode(app.graph._nodes[0]);},renderer);
|
||||
await expect.poll(()=>page.evaluate(()=>LiteGraph.vueNodesMode)).toBe(renderer);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -17,14 +17,14 @@ test('workspace serves real library data beside either ComfyUI renderer', async
|
||||
await expect(workspace.locator('#workspace-main h1')).toHaveText('Overview');
|
||||
await expect(workspace.locator('dt').filter({ hasText: /^Prompts$/ }).locator('+ dd'))
|
||||
.toHaveText(stats.total_prompts.toLocaleString());
|
||||
await workspace.getByRole('combobox', { name: 'Appearance' }).selectOption('light');
|
||||
await workspace.getByRole('combobox', { name: 'Theme' }).selectOption('light');
|
||||
await expect(workspace.locator('body')).toHaveAttribute('data-ca-theme', 'light');
|
||||
const imageResponse = workspace.waitForResponse(response => {
|
||||
const url = new URL(response.url());
|
||||
return url.pathname === '/prompt_manager/images/output' && url.searchParams.get('mode') === 'workspace';
|
||||
});
|
||||
await workspace.getByRole('navigation', { name: 'Workspace', exact: true })
|
||||
.getByRole('link', { name: 'Images', exact: true }).click();
|
||||
.getByRole('link', { name: /^Images(?:\s*[\d,]+)?$/ }).click();
|
||||
await expect(workspace.locator('#workspace-main h1')).toHaveText('Images');
|
||||
const images = await imageResponse;
|
||||
expect(images.ok()).toBe(true);
|
||||
@@ -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');
|
||||
|
||||
@@ -13,9 +13,78 @@ 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` ... `f17.png`, each
|
||||
`inspection_parity/` holds small synthetic PNGs (`f01b.png` ... `f24.png`, each
|
||||
under 32 KiB) and `vectors.json`, used by the #240 inspection parity tests. Every
|
||||
graph, prompt text, model name and filename prefix in them is original synthetic
|
||||
content written for this repository. Nothing comes from user output. They are
|
||||
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 988 B |
|
After Width: | Height: | Size: 989 B |
@@ -6,9 +6,9 @@
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "No supported sampler pair found (saw FaceDetailer, SamplerCustomAdvanced)",
|
||||
"reason_code": "no-sampler",
|
||||
"state": "ambiguous",
|
||||
"reason": "Multiple sampler branches",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
@@ -19,24 +19,24 @@
|
||||
"value": null
|
||||
},
|
||||
"steps": {
|
||||
"status": "ambiguous",
|
||||
"value": null
|
||||
"status": "known",
|
||||
"value": "20"
|
||||
},
|
||||
"cfg": {
|
||||
"status": "ambiguous",
|
||||
"status": "unresolved",
|
||||
"value": null
|
||||
},
|
||||
"sampler": {
|
||||
"status": "ambiguous",
|
||||
"value": null
|
||||
"status": "known",
|
||||
"value": "euler"
|
||||
},
|
||||
"scheduler": {
|
||||
"status": "ambiguous",
|
||||
"value": null
|
||||
},
|
||||
"model": {
|
||||
"status": "ambiguous",
|
||||
"value": null
|
||||
"status": "known",
|
||||
"value": "flux-2-klein-9b-fp8.safetensors"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,12 +48,12 @@
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "No supported sampler pair found (saw FaceDetailer, SamplerCustomAdvanced)",
|
||||
"reason_code": "no-sampler",
|
||||
"state": "absent",
|
||||
"reason": "Guider has no negative input",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"positive": "a red fox in snow, detailed fur, Scene 0: unique shot description soft winter light,",
|
||||
"negative": "<absent>",
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
@@ -61,8 +61,8 @@
|
||||
"value": "1000"
|
||||
},
|
||||
"steps": {
|
||||
"status": "unresolved",
|
||||
"value": null
|
||||
"status": "known",
|
||||
"value": "20"
|
||||
},
|
||||
"cfg": {
|
||||
"status": "not-recorded",
|
||||
@@ -73,8 +73,8 @@
|
||||
"value": "euler"
|
||||
},
|
||||
"scheduler": {
|
||||
"status": "unresolved",
|
||||
"value": null
|
||||
"status": "known",
|
||||
"value": "Flux2Scheduler"
|
||||
},
|
||||
"model": {
|
||||
"status": "known",
|
||||
@@ -258,12 +258,12 @@
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "Unsupported node semantics or output slot",
|
||||
"reason_code": "unsupported-node",
|
||||
"state": "resolved",
|
||||
"reason": "",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"positive": "a red fox in snow, detailed fur",
|
||||
"negative": "blurry, lowres",
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
@@ -300,12 +300,12 @@
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "Unsupported node semantics or output slot",
|
||||
"reason_code": "unsupported-node",
|
||||
"state": "resolved",
|
||||
"reason": "",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"positive": "a red fox in snow, detailed fur",
|
||||
"negative": "blurry, lowres",
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
@@ -342,12 +342,12 @@
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "Unsupported node semantics or output slot",
|
||||
"reason_code": "unsupported-node",
|
||||
"state": "resolved",
|
||||
"reason": "",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"positive": "a red fox in snow, detailed fur",
|
||||
"negative": "blurry, lowres",
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
@@ -385,8 +385,8 @@
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "Unsupported node semantics or output slot",
|
||||
"reason_code": "unsupported-node",
|
||||
"reason": "Combined conditioning has different texts",
|
||||
"reason_code": "composite",
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
@@ -426,12 +426,12 @@
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "Unsupported node semantics or output slot",
|
||||
"reason_code": "unsupported-node",
|
||||
"state": "resolved",
|
||||
"reason": "",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"positive": "a red fox in snow, detailed fur",
|
||||
"negative": "blurry, lowres",
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
@@ -510,12 +510,12 @@
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "No supported sampler pair found (saw SamplerCustomAdvanced, CFGGuider)",
|
||||
"reason_code": "no-sampler",
|
||||
"state": "resolved",
|
||||
"reason": "",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"positive": "a red fox in snow, detailed fur",
|
||||
"negative": "blurry, lowres",
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
@@ -603,28 +603,28 @@
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
"status": "ambiguous",
|
||||
"value": null
|
||||
"status": "known",
|
||||
"value": "42"
|
||||
},
|
||||
"steps": {
|
||||
"status": "ambiguous",
|
||||
"value": null
|
||||
"status": "known",
|
||||
"value": "25"
|
||||
},
|
||||
"cfg": {
|
||||
"status": "ambiguous",
|
||||
"value": null
|
||||
"status": "known",
|
||||
"value": "6.5"
|
||||
},
|
||||
"sampler": {
|
||||
"status": "ambiguous",
|
||||
"value": null
|
||||
"status": "known",
|
||||
"value": "euler"
|
||||
},
|
||||
"scheduler": {
|
||||
"status": "ambiguous",
|
||||
"value": null
|
||||
"status": "known",
|
||||
"value": "normal"
|
||||
},
|
||||
"model": {
|
||||
"status": "ambiguous",
|
||||
"value": null
|
||||
"status": "known",
|
||||
"value": "sdxl_base.safetensors"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -636,12 +636,12 @@
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "Unsupported latent ancestry",
|
||||
"reason_code": "unsupported-node",
|
||||
"state": "resolved",
|
||||
"reason": "",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"positive": "a red fox in snow, detailed fur",
|
||||
"negative": "blurry, lowres",
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
@@ -678,12 +678,12 @@
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "Unsupported latent ancestry",
|
||||
"reason_code": "unsupported-node",
|
||||
"state": "resolved",
|
||||
"reason": "",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"positive": "a red fox in snow, detailed fur",
|
||||
"negative": "blurry, lowres",
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
@@ -888,12 +888,12 @@
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "No supported sampler pair found (saw SamplerCustomAdvanced)",
|
||||
"reason_code": "no-sampler",
|
||||
"state": "absent",
|
||||
"reason": "Guider has no negative input",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"positive": "a red fox in snow, detailed fur",
|
||||
"negative": "<absent>",
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
@@ -923,6 +923,258 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "f19-inspection",
|
||||
"fixture": "f19",
|
||||
"profile": "inspection",
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "ambiguous",
|
||||
"reason": "Sampler stages have different prompts",
|
||||
"reason_code": "multistage-differs",
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
"status": "ambiguous",
|
||||
"value": null
|
||||
},
|
||||
"steps": {
|
||||
"status": "ambiguous",
|
||||
"value": null
|
||||
},
|
||||
"cfg": {
|
||||
"status": "ambiguous",
|
||||
"value": null
|
||||
},
|
||||
"sampler": {
|
||||
"status": "known",
|
||||
"value": "euler"
|
||||
},
|
||||
"scheduler": {
|
||||
"status": "known",
|
||||
"value": "normal"
|
||||
},
|
||||
"model": {
|
||||
"status": "known",
|
||||
"value": "sdxl_base.safetensors"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "f20-inspection",
|
||||
"fixture": "f20",
|
||||
"profile": "inspection",
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "resolved",
|
||||
"reason": "",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": "a red fox in snow, detailed fur",
|
||||
"negative": "blurry, lowres",
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
"status": "known",
|
||||
"value": "42"
|
||||
},
|
||||
"steps": {
|
||||
"status": "known",
|
||||
"value": "25"
|
||||
},
|
||||
"cfg": {
|
||||
"status": "known",
|
||||
"value": "6.5"
|
||||
},
|
||||
"sampler": {
|
||||
"status": "known",
|
||||
"value": "euler"
|
||||
},
|
||||
"scheduler": {
|
||||
"status": "known",
|
||||
"value": "normal"
|
||||
},
|
||||
"model": {
|
||||
"status": "known",
|
||||
"value": "sdxl_base.safetensors"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "f21-inspection",
|
||||
"fixture": "f21",
|
||||
"profile": "inspection",
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "Text is decided at run time",
|
||||
"reason_code": "runtime-value",
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
"status": "known",
|
||||
"value": "42"
|
||||
},
|
||||
"steps": {
|
||||
"status": "known",
|
||||
"value": "25"
|
||||
},
|
||||
"cfg": {
|
||||
"status": "known",
|
||||
"value": "6.5"
|
||||
},
|
||||
"sampler": {
|
||||
"status": "known",
|
||||
"value": "euler"
|
||||
},
|
||||
"scheduler": {
|
||||
"status": "known",
|
||||
"value": "normal"
|
||||
},
|
||||
"model": {
|
||||
"status": "known",
|
||||
"value": "sdxl_base.safetensors"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "f22-inspection",
|
||||
"fixture": "f22",
|
||||
"profile": "inspection",
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "Text is decided at run time",
|
||||
"reason_code": "runtime-value",
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
"status": "known",
|
||||
"value": "42"
|
||||
},
|
||||
"steps": {
|
||||
"status": "known",
|
||||
"value": "25"
|
||||
},
|
||||
"cfg": {
|
||||
"status": "known",
|
||||
"value": "6.5"
|
||||
},
|
||||
"sampler": {
|
||||
"status": "known",
|
||||
"value": "euler"
|
||||
},
|
||||
"scheduler": {
|
||||
"status": "known",
|
||||
"value": "normal"
|
||||
},
|
||||
"model": {
|
||||
"status": "known",
|
||||
"value": "sdxl_base.safetensors"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "f23-inspection",
|
||||
"fixture": "f23",
|
||||
"profile": "inspection",
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "resolved",
|
||||
"reason": "",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": "a red fox in snow, detailed fur",
|
||||
"negative": "",
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
"status": "known",
|
||||
"value": "42"
|
||||
},
|
||||
"steps": {
|
||||
"status": "known",
|
||||
"value": "25"
|
||||
},
|
||||
"cfg": {
|
||||
"status": "known",
|
||||
"value": "6.5"
|
||||
},
|
||||
"sampler": {
|
||||
"status": "known",
|
||||
"value": "euler"
|
||||
},
|
||||
"scheduler": {
|
||||
"status": "known",
|
||||
"value": "normal"
|
||||
},
|
||||
"model": {
|
||||
"status": "known",
|
||||
"value": "sdxl_base.safetensors"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "f24-inspection",
|
||||
"fixture": "f24",
|
||||
"profile": "inspection",
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "resolved",
|
||||
"reason": "",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": "a red fox in snow, detailed fur",
|
||||
"negative": "blurry, lowres",
|
||||
"candidate_count": 0,
|
||||
"facts": {
|
||||
"seed": {
|
||||
"status": "known",
|
||||
"value": "42"
|
||||
},
|
||||
"steps": {
|
||||
"status": "known",
|
||||
"value": "25"
|
||||
},
|
||||
"cfg": {
|
||||
"status": "known",
|
||||
"value": "6.5"
|
||||
},
|
||||
"sampler": {
|
||||
"status": "known",
|
||||
"value": "euler"
|
||||
},
|
||||
"scheduler": {
|
||||
"status": "known",
|
||||
"value": "normal"
|
||||
},
|
||||
"model": {
|
||||
"status": "known",
|
||||
"value": "sdxl_base.safetensors"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "f01-default",
|
||||
"fixture": "f01",
|
||||
@@ -1296,5 +1548,107 @@
|
||||
"candidate_count": 0,
|
||||
"facts": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "f19-default",
|
||||
"fixture": "f19",
|
||||
"profile": "default",
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "resolved",
|
||||
"reason": "",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": "a red fox in snow, detailed fur",
|
||||
"negative": "blurry, lowres",
|
||||
"candidate_count": 0,
|
||||
"facts": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "f20-default",
|
||||
"fixture": "f20",
|
||||
"profile": "default",
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "Unsupported node semantics or output slot",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"candidate_count": 0,
|
||||
"facts": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "f21-default",
|
||||
"fixture": "f21",
|
||||
"profile": "default",
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "Unsupported node semantics or output slot",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"candidate_count": 0,
|
||||
"facts": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "f22-default",
|
||||
"fixture": "f22",
|
||||
"profile": "default",
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "Unsupported node semantics or output slot",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"candidate_count": 0,
|
||||
"facts": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "f23-default",
|
||||
"fixture": "f23",
|
||||
"profile": "default",
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "Unsupported node semantics or output slot",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"candidate_count": 0,
|
||||
"facts": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "f24-default",
|
||||
"fixture": "f24",
|
||||
"profile": "default",
|
||||
"anchor": null,
|
||||
"expect": {
|
||||
"metadata_status": "available",
|
||||
"state": "unresolved",
|
||||
"reason": "Unsupported node semantics or output slot",
|
||||
"reason_code": null,
|
||||
"selection": null,
|
||||
"positive": null,
|
||||
"negative": null,
|
||||
"candidate_count": 0,
|
||||
"facts": null
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 470 B |
@@ -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,461 @@
|
||||
"""Named workflow cases shared by the generation-parameter parser tests.
|
||||
|
||||
Each case is the raw metadata records a file would carry, as text, so the cases
|
||||
exercise ``parse_js_json`` as well as the tracing. The JS parity test runs every
|
||||
case through the legacy browser parser too. Prompt texts and seeds are invented.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
|
||||
def _text(value):
|
||||
return None if value is None else json.dumps(value)
|
||||
|
||||
|
||||
def case(prompt=None, workflow=None):
|
||||
return {"prompt": _text(prompt), "workflow": _text(workflow)}
|
||||
|
||||
|
||||
def encoder(text, clip=("4", 1)):
|
||||
return {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": text, "clip": list(clip)},
|
||||
}
|
||||
|
||||
|
||||
def ksampler(positive, negative, model=("4", 0), **settings):
|
||||
inputs = {
|
||||
"seed": 5,
|
||||
"steps": 20,
|
||||
"cfg": 7,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "normal",
|
||||
"denoise": 1.0,
|
||||
"model": list(model),
|
||||
"positive": list(positive),
|
||||
"negative": list(negative),
|
||||
"latent_image": ["2", 0],
|
||||
}
|
||||
inputs.update(settings)
|
||||
return {"class_type": "KSampler", "inputs": inputs}
|
||||
|
||||
|
||||
CHECKPOINT = {
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"inputs": {"ckpt_name": "example.safetensors"},
|
||||
}
|
||||
LATENT = {
|
||||
"class_type": "EmptyLatentImage",
|
||||
"inputs": {"width": 512, "height": 512, "batch_size": 1},
|
||||
}
|
||||
|
||||
|
||||
def _basic():
|
||||
return {
|
||||
"4": CHECKPOINT,
|
||||
"2": LATENT,
|
||||
"6": encoder("a cat on a windowsill"),
|
||||
"7": encoder("blurry, watermark"),
|
||||
"3": ksampler(("6", 0), ("7", 0)),
|
||||
"9": {"class_type": "SaveImage", "inputs": {"images": ["8", 0]}},
|
||||
}
|
||||
|
||||
|
||||
def _prompt_manager(text, prepend="", append=""):
|
||||
return {
|
||||
"class_type": "PromptManager",
|
||||
"inputs": {
|
||||
"text": text,
|
||||
"category": "",
|
||||
"tags": "",
|
||||
"search_text": "",
|
||||
"prepend_text": prepend,
|
||||
"append_text": append,
|
||||
"clip": ["4", 1],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _shared_prompt_pipeline():
|
||||
"""One PromptManager pair feeds a base pass, an upscale pass and a detailer."""
|
||||
return {
|
||||
"1": CHECKPOINT,
|
||||
"5": ksampler(
|
||||
("25", 0),
|
||||
("26", 0),
|
||||
("1", 0),
|
||||
seed=111,
|
||||
steps=4,
|
||||
cfg=1.0,
|
||||
sampler_name="lcm",
|
||||
latent_image=["20", 0],
|
||||
),
|
||||
"6": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": ["5", 0], "vae": ["1", 2]},
|
||||
},
|
||||
"11": {
|
||||
"class_type": "VAEEncode",
|
||||
"inputs": {"pixels": ["6", 0], "vae": ["1", 2]},
|
||||
},
|
||||
"12": ksampler(
|
||||
("25", 0),
|
||||
("26", 0),
|
||||
("1", 0),
|
||||
seed=222,
|
||||
steps=6,
|
||||
cfg=1.0,
|
||||
sampler_name="lcm",
|
||||
denoise=0.45,
|
||||
latent_image=["11", 0],
|
||||
),
|
||||
"13": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": ["12", 0], "vae": ["1", 2]},
|
||||
},
|
||||
"17": {
|
||||
"class_type": "FaceDetailer",
|
||||
"inputs": {
|
||||
"seed": 333,
|
||||
"steps": 4,
|
||||
"cfg": 1.0,
|
||||
"sampler_name": "lcm",
|
||||
"image": ["13", 0],
|
||||
"model": ["1", 0],
|
||||
"clip": ["1", 1],
|
||||
"vae": ["1", 2],
|
||||
"positive": ["25", 0],
|
||||
"negative": ["26", 0],
|
||||
},
|
||||
},
|
||||
"20": {
|
||||
"class_type": "EmptyLatentImage",
|
||||
"inputs": {"width": 640, "height": 1536, "batch_size": 1},
|
||||
},
|
||||
"25": _prompt_manager("a lighthouse at dusk, oil painting"),
|
||||
"26": _prompt_manager("blurry, watermark"),
|
||||
"48": {
|
||||
"class_type": "KikoSaveImage",
|
||||
"inputs": {"filename_prefix": ["49", 0], "images": ["17", 0]},
|
||||
},
|
||||
"49": {
|
||||
"class_type": "KikoProjectFilePath",
|
||||
"inputs": {"project_name": "example"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _custom_sampler(guider):
|
||||
return {
|
||||
"13": CHECKPOINT,
|
||||
"20": encoder("a red fox in snow", clip=("13", 1)),
|
||||
"21": encoder("lowres", clip=("13", 1)),
|
||||
"22": guider,
|
||||
"25": {"class_type": "RandomNoise", "inputs": {"noise_seed": 42}},
|
||||
"16": {"class_type": "KSamplerSelect", "inputs": {"sampler_name": "dpmpp_2m"}},
|
||||
"17": {
|
||||
"class_type": "BasicScheduler",
|
||||
"inputs": {
|
||||
"scheduler": "karras",
|
||||
"steps": 8,
|
||||
"denoise": 1.0,
|
||||
"model": ["13", 0],
|
||||
},
|
||||
},
|
||||
"1300:8": {
|
||||
"class_type": "SamplerCustomAdvanced",
|
||||
"inputs": {
|
||||
"noise": ["25", 0],
|
||||
"guider": ["22", 0],
|
||||
"sampler": ["16", 0],
|
||||
"sigmas": ["17", 0],
|
||||
"latent_image": ["5", 0],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _workflow_only():
|
||||
return {
|
||||
"nodes": [
|
||||
{
|
||||
"id": 4,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"widgets_values": ["example.safetensors"],
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "CLIPTextEncode",
|
||||
"mode": 0,
|
||||
"inputs": [{"name": "clip", "link": 3}],
|
||||
"widgets_values": ["a tidepool at noon"],
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "CLIPTextEncode",
|
||||
"mode": 0,
|
||||
"inputs": [{"name": "clip", "link": 5}],
|
||||
"widgets_values": ["noisy"],
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "Reroute",
|
||||
"mode": 0,
|
||||
"inputs": [{"name": "", "link": 7}],
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "KSampler",
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "model", "link": 1},
|
||||
{"name": "positive", "link": 8},
|
||||
{"name": "negative", "link": 4},
|
||||
{"name": "latent_image", "link": 2},
|
||||
],
|
||||
"widgets_values": [314, "randomize", 25, 6.5, "dpmpp_2m", "karras", 1],
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"type": "CLIPTextEncode",
|
||||
"mode": 2,
|
||||
"inputs": [],
|
||||
"widgets_values": ["muted node text"],
|
||||
},
|
||||
],
|
||||
"links": [
|
||||
[1, 4, 0, 3, 0, "MODEL"],
|
||||
[3, 4, 1, 6, 0, "CLIP"],
|
||||
[5, 4, 1, 7, 0, "CLIP"],
|
||||
[7, 6, 0, 10, 0, "CONDITIONING"],
|
||||
[8, 10, 0, 3, 1, "CONDITIONING"],
|
||||
[4, 7, 0, 3, 2, "CONDITIONING"],
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 99,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 3,
|
||||
"type": "LATENT",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _nested(depth):
|
||||
"""A value nested ``depth`` arrays deep, as a crafted file can hold."""
|
||||
value = ["leaf", 7]
|
||||
for _ in range(depth):
|
||||
value = [value, None]
|
||||
return value
|
||||
|
||||
|
||||
def _chain(length):
|
||||
graph = {"4": CHECKPOINT, "1": encoder("deep chain text")}
|
||||
previous = "1"
|
||||
for index in range(length):
|
||||
node_id = str(100 + index)
|
||||
graph[node_id] = {
|
||||
"class_type": "ConditioningPass",
|
||||
"inputs": {"conditioning": [previous, 0]},
|
||||
}
|
||||
previous = node_id
|
||||
graph["3"] = ksampler((previous, 0), ("1", 0))
|
||||
return graph
|
||||
|
||||
|
||||
CASES = {
|
||||
"basic": case(_basic()),
|
||||
"prompt-manager-prepend-append": case(
|
||||
{
|
||||
"4": CHECKPOINT,
|
||||
"6": _prompt_manager("a cat", prepend="masterpiece", append="4k"),
|
||||
"7": _prompt_manager(
|
||||
"masterpiece, a dog", prepend="masterpiece", append="4k"
|
||||
),
|
||||
"3": ksampler(("6", 0), ("7", 0)),
|
||||
}
|
||||
),
|
||||
"linked-text-concat": case(
|
||||
{
|
||||
"4": CHECKPOINT,
|
||||
"10": {"class_type": "PrimitiveString", "inputs": {"value": "first part"}},
|
||||
"11": {
|
||||
"class_type": "StringConcatenate",
|
||||
"inputs": {
|
||||
"string_a": ["10", 0],
|
||||
"string_b": "second part",
|
||||
"delimiter": ", ",
|
||||
"seed": 3,
|
||||
"string_c": " ",
|
||||
},
|
||||
},
|
||||
"6": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": ["11", 0], "clip": ["4", 1]},
|
||||
},
|
||||
"7": encoder(""),
|
||||
"3": ksampler(("6", 0), ("7", 0)),
|
||||
}
|
||||
),
|
||||
"prompt-manager-pair": case(
|
||||
{
|
||||
"4": CHECKPOINT,
|
||||
"8": {
|
||||
"class_type": "PromptManagerPair",
|
||||
"inputs": {
|
||||
"text": "pair positive",
|
||||
"negative_text": "pair negative",
|
||||
"prepend_text": "",
|
||||
"append_text": "",
|
||||
"clip": ["4", 1],
|
||||
},
|
||||
},
|
||||
"3": ksampler(("8", 0), ("8", 1)),
|
||||
}
|
||||
),
|
||||
"conditioning-passthrough": case(
|
||||
{
|
||||
"4": CHECKPOINT,
|
||||
"6": encoder("through a controlnet"),
|
||||
"7": encoder("negative through it"),
|
||||
"12": {
|
||||
"class_type": "ControlNetApplyAdvanced",
|
||||
"inputs": {
|
||||
"positive": ["6", 0],
|
||||
"negative": ["7", 0],
|
||||
"control_net": ["13", 0],
|
||||
},
|
||||
},
|
||||
"3": ksampler(("12", 0), ("12", 1)),
|
||||
}
|
||||
),
|
||||
"first-sampler-in-js-key-order": case(
|
||||
{
|
||||
"4": CHECKPOINT,
|
||||
"6": encoder("from sampler twelve"),
|
||||
"7": encoder("from sampler five"),
|
||||
"x": ksampler(("6", 0), ("6", 0)),
|
||||
"12": ksampler(("6", 0), ("6", 0), seed=12),
|
||||
"5": ksampler(("7", 0), ("7", 0), seed=55),
|
||||
}
|
||||
),
|
||||
"custom-sampler-cfg-guider": case(
|
||||
_custom_sampler(
|
||||
{
|
||||
"class_type": "CFGGuider",
|
||||
"inputs": {
|
||||
"model": ["13", 0],
|
||||
"positive": ["20", 0],
|
||||
"negative": ["21", 0],
|
||||
"cfg": 3.5,
|
||||
},
|
||||
}
|
||||
)
|
||||
),
|
||||
"custom-sampler-basic-guider": case(
|
||||
_custom_sampler(
|
||||
{
|
||||
"class_type": "BasicGuider",
|
||||
"inputs": {"model": ["13", 0], "conditioning": ["20", 0]},
|
||||
}
|
||||
)
|
||||
),
|
||||
"no-sampler-loose-settings": case(
|
||||
{
|
||||
"4": CHECKPOINT,
|
||||
"3": {
|
||||
"class_type": "MyKSamplerVariant",
|
||||
"inputs": {
|
||||
"noise_seed": 9,
|
||||
"steps": 12,
|
||||
"cfg": 2,
|
||||
"sampler_name": "ddim",
|
||||
},
|
||||
},
|
||||
}
|
||||
),
|
||||
"primitive-seed": case(
|
||||
{
|
||||
"4": CHECKPOINT,
|
||||
"6": encoder("seeded"),
|
||||
"30": {
|
||||
"class_type": "Seed (rgthree)",
|
||||
"inputs": {"other": "x", "seed": 77},
|
||||
},
|
||||
"3": ksampler(("6", 0), ("6", 0), seed=["30", 0]),
|
||||
}
|
||||
),
|
||||
"workflow-only": case(workflow=_workflow_only()),
|
||||
"overwritten-prompt-manager-text": case(
|
||||
{
|
||||
"4": CHECKPOINT,
|
||||
"25": _prompt_manager("same saved text"),
|
||||
"26": _prompt_manager("same saved text"),
|
||||
"3": ksampler(("25", 0), ("26", 0)),
|
||||
},
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": 25,
|
||||
"type": "PromptManager",
|
||||
"widgets_values": ["the real positive"],
|
||||
},
|
||||
{
|
||||
"id": 26,
|
||||
"type": "PromptManager",
|
||||
"widgets_values": ["the real negative"],
|
||||
},
|
||||
],
|
||||
"links": [],
|
||||
},
|
||||
),
|
||||
"conditioning-cycle": case(
|
||||
{
|
||||
"4": CHECKPOINT,
|
||||
"20": {
|
||||
"class_type": "ConditioningCombine",
|
||||
"inputs": {"conditioning_1": ["21", 0]},
|
||||
},
|
||||
"21": {
|
||||
"class_type": "ConditioningCombine",
|
||||
"inputs": {"conditioning_1": ["20", 0]},
|
||||
},
|
||||
"3": ksampler(("20", 0), ("21", 0)),
|
||||
}
|
||||
),
|
||||
"chain-within-visit-limit": case(_chain(450)),
|
||||
"chain-beyond-visit-limit": case(_chain(600)),
|
||||
"chain-at-last-visit": case(_chain(499)),
|
||||
"chain-one-past-last-visit": case(_chain(500)),
|
||||
"blank-text-and-zero-seed": case(
|
||||
{
|
||||
"4": CHECKPOINT,
|
||||
"6": encoder(" "),
|
||||
"7": encoder("kept negative"),
|
||||
"3": ksampler(("6", 0), ("7", 0), seed=0),
|
||||
}
|
||||
),
|
||||
"shared-prompt-pipeline": case(_shared_prompt_pipeline()),
|
||||
"null-workflow-node": case(_basic(), {"nodes": [None], "links": []}),
|
||||
"unparsable-records": {"prompt": '{"3": NaN}', "workflow": "not json"},
|
||||
"seed-beyond-double-precision": case(
|
||||
{
|
||||
"4": CHECKPOINT,
|
||||
"6": encoder("x"),
|
||||
"3": ksampler(("6", 0), ("6", 0), cfg=1.0, seed=4974052037430000001),
|
||||
}
|
||||
),
|
||||
"deeply-nested-values": case(
|
||||
{
|
||||
"4": CHECKPOINT,
|
||||
"6": encoder("nested neighbours"),
|
||||
"3": ksampler(("6", 0), ("6", 0)),
|
||||
"8": {"class_type": _nested(900), "inputs": {"text": "x"}},
|
||||
},
|
||||
{"nodes": [{"id": _nested(900), "type": _nested(900)}], "links": []},
|
||||
),
|
||||
"nothing": case(),
|
||||
}
|
||||
@@ -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
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Every graph here is original synthetic content (CC0): neutral texts, generic model
|
||||
file names and invented filename prefixes. Nothing is copied from user outputs.
|
||||
The shapes reproduce the parity audit's fixture numbering (``f01``..``f17``).
|
||||
The shapes reproduce the parity audit's fixture numbering (``f01``..``f24``).
|
||||
|
||||
``build(name)`` returns ``{"prompt", "workflow", "extra_chunks"}``. ``write_png``
|
||||
writes the two records the way core ``SaveImage`` does (Pillow ``PngInfo.add_text``,
|
||||
@@ -66,6 +66,12 @@ COMMITTED = (
|
||||
"f15",
|
||||
"f16",
|
||||
"f17",
|
||||
"f19",
|
||||
"f20",
|
||||
"f21",
|
||||
"f22",
|
||||
"f23",
|
||||
"f24",
|
||||
)
|
||||
# Built in a temporary directory at test time (too large to commit).
|
||||
TEST_TIME = ("f01", "f04", "f05")
|
||||
@@ -100,6 +106,10 @@ def _enc(text):
|
||||
return {"class_type": "CLIPTextEncode", "inputs": {"text": text, "clip": ["4", 1]}}
|
||||
|
||||
|
||||
def _linked_enc(link):
|
||||
return {"class_type": "CLIPTextEncode", "inputs": {"text": link, "clip": ["4", 1]}}
|
||||
|
||||
|
||||
def _ksampler_graph(positive, negative, sampler="KSampler", latent=None, extra=None):
|
||||
graph = {
|
||||
"4": {
|
||||
@@ -691,6 +701,105 @@ def _f17():
|
||||
return _basic(graph, _ui(graph))
|
||||
|
||||
|
||||
DETAILER_POS = "a red fox in snow, sharp detailed face"
|
||||
|
||||
|
||||
def _f19():
|
||||
"""KSampler -> VAEDecode -> FaceDetailer -> SaveImage; the detailer re-prompts."""
|
||||
graph = _ksampler_graph(_enc(POS), _enc(NEG))
|
||||
graph["10"] = _enc(DETAILER_POS)
|
||||
graph["50"] = {
|
||||
"class_type": "FaceDetailer",
|
||||
"inputs": {
|
||||
"image": ["8", 0],
|
||||
"model": ["4", 0],
|
||||
"clip": ["4", 1],
|
||||
"vae": ["4", 2],
|
||||
"positive": ["10", 0],
|
||||
"negative": ["7", 0],
|
||||
"seed": 7,
|
||||
"steps": 12,
|
||||
"cfg": 4.5,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "normal",
|
||||
"denoise": 0.4,
|
||||
},
|
||||
}
|
||||
graph["9"]["inputs"]["images"] = ["50", 0]
|
||||
return _basic(graph)
|
||||
|
||||
|
||||
def _f20():
|
||||
"""``easy ifElse`` with a literal boolean picks the positive branch."""
|
||||
select = {
|
||||
"class_type": "easy ifElse",
|
||||
"inputs": {"boolean": True, "on_true": ["10", 0], "on_false": ["11", 0]},
|
||||
}
|
||||
extra = {"10": _enc(POS), "11": _enc("a grey wolf in fog")}
|
||||
return _basic(_ksampler_graph(select, _enc(NEG), extra=extra))
|
||||
|
||||
|
||||
def _f21():
|
||||
"""``JoinStringMulti`` with ``return_list: true`` feeds the positive encoder."""
|
||||
join = {
|
||||
"class_type": "JoinStringMulti",
|
||||
"inputs": {
|
||||
"inputcount": 2,
|
||||
"delimiter": ", ",
|
||||
"return_list": True,
|
||||
"string_1": ["11", 0],
|
||||
"string_2": ["12", 0],
|
||||
},
|
||||
}
|
||||
extra = {
|
||||
"10": join,
|
||||
"11": {"class_type": "PrimitiveString", "inputs": {"value": "a red fox"}},
|
||||
"12": {"class_type": "PrimitiveString", "inputs": {"value": "in snow"}},
|
||||
}
|
||||
positive = _linked_enc(["10", 0])
|
||||
return _basic(_ksampler_graph(positive, _enc(NEG), extra=extra))
|
||||
|
||||
|
||||
def _f22():
|
||||
"""``PromptSearchList`` (database search at run time) feeds the positive."""
|
||||
search = {
|
||||
"class_type": "PromptSearchList",
|
||||
"inputs": {
|
||||
"text": "fox",
|
||||
"category": "",
|
||||
"tags": "",
|
||||
"min_rating": 0,
|
||||
"limit": 50,
|
||||
},
|
||||
}
|
||||
positive = _linked_enc(["10", 0])
|
||||
return _basic(_ksampler_graph(positive, _enc(NEG), extra={"10": search}))
|
||||
|
||||
|
||||
def _f24():
|
||||
"""``TextEncodeQwenImage21``: slot 0 is the positive, slot 1 the negative."""
|
||||
qwen = {
|
||||
"class_type": "TextEncodeQwenImage21",
|
||||
"inputs": {
|
||||
"clip": ["4", 1],
|
||||
"prompt": POS,
|
||||
"negative_prompt": NEG,
|
||||
"vae": ["4", 2],
|
||||
"resolution": 1024,
|
||||
},
|
||||
}
|
||||
graph = _ksampler_graph(qwen, None)
|
||||
del graph["7"]
|
||||
graph["3"]["inputs"]["negative"] = ["6", 1]
|
||||
return _basic(graph)
|
||||
|
||||
|
||||
def _f23():
|
||||
"""The negative is ``ConditioningZeroOut`` of the positive encoder."""
|
||||
zero = {"class_type": "ConditioningZeroOut", "inputs": {"conditioning": ["6", 0]}}
|
||||
return _basic(_ksampler_graph(_enc(POS), zero))
|
||||
|
||||
|
||||
_BUILDERS = {
|
||||
"f01": _f01,
|
||||
"f01b": _f01b,
|
||||
@@ -714,6 +823,12 @@ _BUILDERS = {
|
||||
"f15": _f15,
|
||||
"f16": _f16,
|
||||
"f17": _f17,
|
||||
"f19": _f19,
|
||||
"f20": _f20,
|
||||
"f21": _f21,
|
||||
"f22": _f22,
|
||||
"f23": _f23,
|
||||
"f24": _f24,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -164,6 +164,248 @@ test("facts require model output ancestry with supported loaders and passthrough
|
||||
assert.equal(factsFor(numericModel).model.status, "unresolved", "model names require string evidence");
|
||||
});
|
||||
|
||||
// D19: a FaceDetailer re-samples the KSampler's decoded image with its own settings.
|
||||
function detailerFactGraph(detailer = {}) {
|
||||
const graph = factGraph({ seed: "42", steps: 25, cfg: "6.5", sampler_name: "euler", scheduler: "normal" });
|
||||
graph.decode = { class_type: "VAEDecode", inputs: { samples: ["s", 0], vae: ["model", 2] } };
|
||||
graph.d = { class_type: "FaceDetailer", inputs: { image: ["decode", 0], model: ["model", 0], seed: "7", steps: 12, cfg: "6.5", sampler_name: "euler", scheduler: "normal", denoise: "0.4", ...detailer } };
|
||||
return graph;
|
||||
}
|
||||
const statusValues = facts => Object.fromEntries(factFields.map(field => [field, [facts[field].status, facts[field].value]]));
|
||||
|
||||
test("facts use resolution.sampler_id for detailer-stage nodes", () => {
|
||||
// Resolved (the stages' prompts agree): the final stage the server named supplies every fact.
|
||||
const resolved = factsFor(detailerFactGraph(), { resolution: { state: "resolved", sampler_id: "d" } });
|
||||
assert.deepEqual(statusValues(resolved), {
|
||||
seed: ["known", "7"], steps: ["known", "12"], cfg: ["known", "6.5"],
|
||||
sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"],
|
||||
});
|
||||
assert.ok(factFields.every(field => resolved[field].sampler_id === "d" && resolved[field].evidence === "configuration"));
|
||||
// The upstream KSampler is also accepted when it is the one resolved.
|
||||
assert.equal(factsFor(detailerFactGraph(), { resolution: { state: "resolved", sampler_id: "s" } }).seed.value, "42");
|
||||
// Ambiguous stages: merged per field, never the detailer's or the upstream value alone.
|
||||
const ambiguous = factsFor(detailerFactGraph(), { resolution: { state: "ambiguous", sampler_id: "d", reason_code: "multistage-differs" } });
|
||||
assert.deepEqual(statusValues(ambiguous), {
|
||||
seed: ["ambiguous", null], steps: ["ambiguous", null], cfg: ["known", "6.5"],
|
||||
sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"],
|
||||
});
|
||||
assert.ok(factFields.every(field => ambiguous[field].sampler_id === null), "a merged fact names no single sampler");
|
||||
// A field one stage cannot read and the others agree on stays unresolved; not-recorded is not agreement.
|
||||
const unreadable = factsFor(detailerFactGraph({ cfg: ["missing", 0] }), { resolution: { state: "ambiguous", sampler_id: "d" } });
|
||||
assert.deepEqual([unreadable.cfg.status, unreadable.cfg.value], ["unresolved", null]);
|
||||
const unrecorded = detailerFactGraph(); delete unrecorded.d.inputs.scheduler;
|
||||
assert.equal(factsFor(unrecorded, { resolution: { state: "ambiguous", sampler_id: "d" } }).scheduler.status, "unresolved");
|
||||
// Two detailer stages over one KSampler: three stages merge.
|
||||
const chained = detailerFactGraph({ seed: "42" });
|
||||
chained.d2 = { class_type: "FaceDetailer", inputs: { image: ["d", 0], model: ["model", 0], seed: "42", steps: 25, cfg: "3", sampler_name: "euler", scheduler: "normal" } };
|
||||
assert.deepEqual(statusValues(factsFor(chained, { resolution: { state: "ambiguous", sampler_id: "d2" } })), {
|
||||
seed: ["known", "42"], steps: ["ambiguous", null], cfg: ["ambiguous", null],
|
||||
sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"],
|
||||
});
|
||||
// Without a resolution, two samplers stay ambiguous (today's rules, now over the samplers table).
|
||||
assert.ok(Object.values(factsFor(detailerFactGraph())).every(fact => fact.status === "ambiguous"));
|
||||
// A sampler_id the graph or the samplers table does not hold never falls back to a guess.
|
||||
for (const sampler_id of ["missing", "decode", "model", "__proto__"]) {
|
||||
for (const state of ["resolved", "ambiguous"]) {
|
||||
const facts = factsFor(detailerFactGraph(), { resolution: { state, sampler_id } });
|
||||
assert.ok(Object.values(facts).every(fact => fact.status === "unresolved" && fact.value === null), `${state} ${sampler_id}`);
|
||||
}
|
||||
}
|
||||
// Stage ancestry that cannot be walked is unresolved, not a partial merge.
|
||||
const cyclic = detailerFactGraph(); cyclic.decode.inputs.samples = ["d", 0];
|
||||
const unlinked = detailerFactGraph({ image: "not a link" });
|
||||
for (const graph of [cyclic, unlinked]) {
|
||||
assert.ok(Object.values(factsFor(graph, { resolution: { state: "ambiguous", sampler_id: "d" } })).every(fact => fact.status === "unresolved"));
|
||||
}
|
||||
const wide = detailerFactGraph();
|
||||
let previous = ["s", 0];
|
||||
for (let index = 0; index < 600; index++) { wide[`hop${index}`] = { class_type: "ImageScale", inputs: { image: previous } }; previous = [`hop${index}`, 0]; }
|
||||
wide.decode.inputs.samples = ["s", 0]; wide.d.inputs.image = previous;
|
||||
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.
|
||||
assert.deepEqual(statusValues(factsFor(detailerFactGraph(), ambiguous)), {
|
||||
seed: ["ambiguous", null], steps: ["ambiguous", null], cfg: ["known", "6.5"],
|
||||
sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"],
|
||||
});
|
||||
assert.ok(Object.values(factsFor(detailerFactGraph(), ambiguous)).every(fact => fact.sampler_id === null));
|
||||
// Equal independent branches agree on every field once the server says ambiguous;
|
||||
// without a resolution they stay ambiguous (today's rules, reversed in PR3).
|
||||
const equal = factGraph(); equal.other = structuredClone(equal.s);
|
||||
assert.ok(Object.values(factsFor(equal, ambiguous)).every(fact => fact.status === "known"));
|
||||
assert.ok(Object.values(factsFor(equal)).every(fact => fact.status === "ambiguous"));
|
||||
// A walk that fails keeps every field unresolved; so does a graph with no listed sampler.
|
||||
const cyclic = detailerFactGraph(); cyclic.decode.inputs.samples = ["d", 0];
|
||||
const broken = detailerFactGraph(); broken.d.inputs.image = ["gone", 0];
|
||||
for (const graph of [cyclic, broken, { model: factGraph().model }]) {
|
||||
assert.ok(Object.values(factsFor(graph, ambiguous)).every(fact => fact.status === "unresolved" && fact.value === null));
|
||||
}
|
||||
const many = factGraph();
|
||||
for (let index = 0; index < 40; index++) many[`s${index}`] = structuredClone(many.s);
|
||||
assert.ok(Object.values(factsFor(many, ambiguous)).every(fact => fact.status === "unresolved"), "more stages than the candidate cap");
|
||||
});
|
||||
|
||||
test("stage walks fail on unlisted sampler-shaped nodes like the resolver", () => {
|
||||
const shaped = [
|
||||
{ positive: ["model", 0] }, { latent_image: ["model", 0] }, { guider: ["model", 0] }, { negative: ["model", 0] },
|
||||
{ pipe: ["model", 0] }, { basic_pipe: ["model", 0] }, { detailer_pipe: ["model", 0] }, { sampler: ["model", 0] },
|
||||
{ sigmas: ["model", 0] }, { noise: ["model", 0] }, { text_embeds: ["model", 0] }, { hyvid_embeds: ["model", 0] },
|
||||
{ model: ["model", 0], conditioning: ["model", 0] }, { model: ["model", 0], neg_conditioning: ["model", 0] },
|
||||
{ model: ["model", 0], samples: ["model", 0] }, { model: ["model", 0], latent: ["model", 0] },
|
||||
];
|
||||
for (const inputs of shaped) {
|
||||
const graph = detailerFactGraph();
|
||||
graph.efficient = { class_type: "MysterySampler", inputs: { ...inputs, image: ["decode", 0] } };
|
||||
graph.d.inputs.image = ["efficient", 0];
|
||||
for (const sampler_id of ["d", null]) {
|
||||
const facts = factsFor(graph, { resolution: { state: "ambiguous", sampler_id } });
|
||||
assert.ok(Object.values(facts).every(fact => fact.status === "unresolved"), `${JSON.stringify(inputs)} ${sampler_id}`);
|
||||
}
|
||||
}
|
||||
// Model patches (model alone, or with clip) and listed table classes are walked through.
|
||||
for (const [class_type, inputs] of [["ModelPatch", { model: ["model", 0] }], ["LoraPatch", { model: ["model", 0], clip: ["model", 1] }], ["ImageScale", { samples: ["model", 0] }], ["CFGGuider", { model: ["model", 0], positive: ["model", 0] }]]) {
|
||||
const graph = detailerFactGraph();
|
||||
graph.hop = { class_type, inputs: { ...inputs, image: ["decode", 0] } };
|
||||
graph.d.inputs.image = ["hop", 0];
|
||||
assert.equal(factsFor(graph, { resolution: { state: "ambiguous", sampler_id: "d" } }).seed.status, "ambiguous", class_type);
|
||||
}
|
||||
});
|
||||
|
||||
test("unresolved and absent results over several stages merge facts per field", () => {
|
||||
// Review I-1: the run's settings are facts even when its prompt is not, e.g. an
|
||||
// upstream prompt behind an unsupported node (LoRAfromText) leaves the server unresolved.
|
||||
const merged = {
|
||||
seed: ["ambiguous", null], steps: ["ambiguous", null], cfg: ["known", "6.5"],
|
||||
sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"],
|
||||
};
|
||||
for (const resolution of [
|
||||
{ state: "unresolved", sampler_id: "d", reason_code: "unsupported-node" },
|
||||
{ state: "unresolved", sampler_id: null, reason_code: "malformed" },
|
||||
{ state: "absent", sampler_id: "d" },
|
||||
]) {
|
||||
const facts = factsFor(detailerFactGraph(), { resolution });
|
||||
assert.deepEqual(statusValues(facts), merged, JSON.stringify(resolution));
|
||||
assert.ok(factFields.every(field => facts[field].sampler_id === null));
|
||||
}
|
||||
// A stage missing a field leaves it unresolved; a failed walk leaves everything unresolved.
|
||||
const unrecorded = detailerFactGraph(); delete unrecorded.d.inputs.scheduler;
|
||||
assert.equal(factsFor(unrecorded, { resolution: { state: "unresolved", sampler_id: "d" } }).scheduler.status, "unresolved");
|
||||
const cyclic = detailerFactGraph(); cyclic.decode.inputs.samples = ["d", 0];
|
||||
assert.ok(Object.values(factsFor(cyclic, { resolution: { state: "unresolved", sampler_id: "d" } })).every(fact => fact.status === "unresolved"));
|
||||
// One listed sampler keeps today's rule: its own facts, attributed to it.
|
||||
const single = factsFor(factGraph(), { resolution: { state: "unresolved", sampler_id: "s" } });
|
||||
assert.ok(factFields.every(field => single[field].status === "known" && single[field].sampler_id === "s"));
|
||||
});
|
||||
|
||||
test("a named sampler limits absent and unresolved facts to its own stages", () => {
|
||||
// Codex P2: the server dropped a preview-only detailer and named the saved sampler, so
|
||||
// the detailer's settings never leak into the saved file's facts.
|
||||
const graph = detailerFactGraph();
|
||||
graph.save = { class_type: "SaveImage", inputs: { images: ["decode", 0] } };
|
||||
graph.preview = { class_type: "PreviewImage", inputs: { images: ["d", 0] } };
|
||||
const own = {
|
||||
seed: ["known", "42"], steps: ["known", "25"], cfg: ["known", "6.5"],
|
||||
sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"],
|
||||
};
|
||||
for (const state of ["absent", "unresolved"]) assert.deepEqual(statusValues(factsFor(graph, { resolution: { state, sampler_id: "s" } })), own, state);
|
||||
// Without a named sampler every listed sampler still merges.
|
||||
assert.equal(factsFor(graph, { resolution: { state: "absent", sampler_id: null } }).seed.status, "ambiguous");
|
||||
});
|
||||
|
||||
test("stage walks follow only the resolver's links", () => {
|
||||
// Review M-2: `["eff", -1]` is not a link to the server, so it is not walked.
|
||||
for (const slot of [-1, 0.5, "0"]) {
|
||||
const graph = detailerFactGraph();
|
||||
graph.eff = { class_type: "MysterySampler", inputs: { positive: ["model", 0], model: ["model", 0] } };
|
||||
graph.decode.inputs.extra = ["eff", slot];
|
||||
assert.equal(factsFor(graph, { resolution: { state: "ambiguous", sampler_id: "d" } }).seed.status, "ambiguous", String(slot));
|
||||
const shaped = detailerFactGraph();
|
||||
shaped.eff = { class_type: "Unlisted", inputs: { positive: ["model", slot], image: ["decode", 0] } };
|
||||
shaped.d.inputs.image = ["eff", 0];
|
||||
assert.equal(factsFor(shaped, { resolution: { state: "ambiguous", sampler_id: "d" } }).seed.status, "ambiguous", `shaped ${slot}`);
|
||||
}
|
||||
const linked = detailerFactGraph();
|
||||
linked.eff = { class_type: "MysterySampler", inputs: { positive: ["model", 0], model: ["model", 0] } };
|
||||
linked.decode.inputs.extra = ["eff", 0];
|
||||
assert.equal(factsFor(linked, { resolution: { state: "ambiguous", sampler_id: "d" } }).seed.status, "unresolved");
|
||||
});
|
||||
|
||||
test("Flux2Scheduler provides steps and scheduler is its node type", () => {
|
||||
const graph = customFactGraph();
|
||||
graph.sigmas = { class_type: "Flux2Scheduler", inputs: { steps: 4, width: 1024, height: 1024 } };
|
||||
const facts = factsFor(graph);
|
||||
assert.deepEqual([facts.steps.status, facts.steps.value], ["known", "4"]);
|
||||
assert.deepEqual([facts.scheduler.status, facts.scheduler.value, facts.scheduler.evidence], ["known", "Flux2Scheduler", "configuration"]);
|
||||
for (const slot of [1, -1]) {
|
||||
const linked = customFactGraph(); linked.sigmas = graph.sigmas; linked.s.inputs.sigmas = ["sigmas", slot];
|
||||
assert.equal(factsFor(linked).scheduler.status, "unresolved", `output${slot}`);
|
||||
}
|
||||
// Its own inputs cannot replace the node type, and steps keep exact-integer rules.
|
||||
const named = customFactGraph(); named.sigmas = { class_type: "Flux2Scheduler", inputs: { steps: 0, scheduler: "karras" } };
|
||||
assert.equal(factsFor(named).scheduler.value, "Flux2Scheduler");
|
||||
assert.equal(factsFor(named).steps.status, "unresolved");
|
||||
});
|
||||
|
||||
test("ModelSampling passthroughs reach UNETLoader", () => {
|
||||
const graph = customFactGraph();
|
||||
graph.g.inputs.model = ["flux", 0];
|
||||
graph.flux = { class_type: "ModelSamplingFlux", inputs: { model: ["aura", 0], max_shift: "1.15", base_shift: "0.5" } };
|
||||
graph.aura = { class_type: "ModelSamplingAuraFlow", inputs: { model: ["sd3", 0], shift: "1.73" } };
|
||||
graph.sd3 = { class_type: "ModelSamplingSD3", inputs: { model: ["lora", 0], shift: "3" } };
|
||||
graph.lora = { class_type: "LoraLoaderModelOnly", inputs: { model: ["m", 0], lora_name: "style", strength_model: "1" } };
|
||||
assert.deepEqual([factsFor(graph).model.status, factsFor(graph).model.value], ["known", "flux.safetensors"]);
|
||||
for (const node of ["flux", "aura", "sd3"]) {
|
||||
const wrongSlot = structuredClone(graph);
|
||||
const parent = { flux: "g", aura: "flux", sd3: "aura" }[node];
|
||||
wrongSlot[parent].inputs.model = [node, 1];
|
||||
assert.equal(factsFor(wrongSlot).model.status, "unresolved", `${node} output1`);
|
||||
const unlisted = structuredClone(graph); unlisted[node].class_type = "ModelSamplingUnknown";
|
||||
assert.equal(factsFor(unlisted).model.status, "unresolved", `${node} unlisted`);
|
||||
}
|
||||
});
|
||||
|
||||
test("vectors.json pins full facts for every inspection fixture", () => {
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const vectors = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "fixtures", "inspection_parity", "vectors.json"), "utf8"));
|
||||
const inspection = vectors.filter(vector => vector.profile === "inspection");
|
||||
assert.ok(inspection.length > 0);
|
||||
const statuses = new Set(["known", "ambiguous", "unresolved", "not-recorded"]);
|
||||
for (const vector of inspection) {
|
||||
const facts = vector.expect.facts;
|
||||
assert.deepEqual(Object.keys(facts), factFields, vector.name);
|
||||
for (const field of factFields) {
|
||||
assert.ok(statuses.has(facts[field].status), `${vector.name} ${field}`);
|
||||
assert.equal(facts[field].value === null, facts[field].status !== "known", `${vector.name} ${field} value`);
|
||||
}
|
||||
}
|
||||
// f19: KSampler seed 42 / steps 25 / cfg 6.5 then FaceDetailer seed 7 / steps 12 / cfg 4.5, different prompts.
|
||||
const f19 = inspection.find(vector => vector.fixture === "f19");
|
||||
assert.equal(f19.expect.state, "ambiguous");
|
||||
assert.deepEqual(Object.fromEntries(factFields.map(field => [field, f19.expect.facts[field].status])), {
|
||||
seed: "ambiguous", steps: "ambiguous", cfg: "ambiguous", sampler: "known", scheduler: "known", model: "known",
|
||||
});
|
||||
});
|
||||
|
||||
test("wide workflow lookups retain selected core sampler facts", () => {
|
||||
const nodes = [{ id: "text", type: "PrimitiveString", widgets_values_named: { value: "unrelated text" } }], links = [];
|
||||
let linkId = 1;
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||