Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8bf461f2f | ||
|
|
689e40f405 | ||
|
|
9e5742842b | ||
|
|
13fe1d32f1 | ||
|
|
21d6fe4356 | ||
|
|
46e0f6cf79 | ||
|
|
48f23499d3 | ||
|
|
d565100284 | ||
|
|
8d09b51e27 | ||
|
|
c7003f1589 | ||
|
|
c49b9e171a | ||
|
|
7bdbc3635a | ||
|
|
be49ffa5ec | ||
|
|
bdd6a5cbe4 | ||
|
|
73d7b9490b | ||
|
|
d2b5b25a42 | ||
|
|
b6158b95bf | ||
|
|
780961dd34 | ||
|
|
811b63894d | ||
|
|
7c469510d9 | ||
|
|
b5c2925468 | ||
|
|
bc373bfbed | ||
|
|
47b91b83a1 | ||
|
|
b8a4309d7f | ||
|
|
9d398f9862 | ||
|
|
a44cd27c2e |
@@ -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
|
||||
|
||||
+253
@@ -125,6 +125,37 @@ not certify every existing feature, arbitrary host versions,
|
||||
GPU/model changes, cross-pack installation or visual design changes. Those still
|
||||
need targeted validation.
|
||||
|
||||
## Performance budgets
|
||||
|
||||
A spec performance budget gets two tests:
|
||||
|
||||
- A structural test that always runs, on every OS/Python job and under the
|
||||
coverage tracer. It asserts counts, never time: SELECTs per request, the
|
||||
`EXPLAIN QUERY PLAN` of the hot query, cache builds on cold and warm requests,
|
||||
and calls of the sort key per cursor page. Example:
|
||||
`test_filter_work_is_bounded` in `tests/test_api_workspace_images.py`.
|
||||
- A wall-clock test that runs only with `PROMPTMANAGER_PERF_BUDGET=1` and
|
||||
without a tracer (`sys.gettrace()` or a `sys.monitoring` tool). It skips
|
||||
otherwise. It warms up once, then measures three rounds and asserts the best
|
||||
round's p95; the failure message lists every timing. Example:
|
||||
`test_filter_budget` (Images filters, spec D12: p95 < 150 ms at 50,000 files
|
||||
and 20,000 links).
|
||||
|
||||
The `perf budget` job in `.github/workflows/test.yml` is the reference
|
||||
measurement: `ubuntu-latest`, one Python, no coverage, only the wall-clock tests.
|
||||
It runs on every push to `main` and on `workflow_dispatch`, never on pull
|
||||
requests, because shared-runner timing must not block a merge. Dispatch it on a
|
||||
branch to measure before merging.
|
||||
Shared Windows and macOS runners do not run them; the structural tests cover
|
||||
portability. Run it locally with
|
||||
`PROMPTMANAGER_PERF_BUDGET=1 python -m unittest tests.test_api_workspace_images.TestWorkspaceImages.test_filter_budget`
|
||||
and quote the printed p95 in the PR body.
|
||||
|
||||
A red `perf budget` run on `main` means the code got slower: fix the code (or
|
||||
change the budget in the spec with a reason) in a follow-up PR. Never rerun it to
|
||||
green. It is not a required check, and it cannot be one while it skips pull
|
||||
requests (`.github/branch-protection.json`).
|
||||
|
||||
## Windows portability preflight
|
||||
|
||||
Before changing paths, SQLite, streams, restore or cleanup:
|
||||
@@ -305,6 +336,228 @@ Use the disposable fixture and managed-host prerequisites above. A maintainer
|
||||
live check needs a ComfyUI restart after adding the Python route, then opening
|
||||
`/prompt_manager/workspace`; the mockup preview server is a separate design tool.
|
||||
|
||||
### Concept shell and comparison method (#249)
|
||||
|
||||
**CSS base and drift check.** `web/js/workspace/concept.css` is a byte-identical copy
|
||||
of the approved concept's `docs/concepts/workspace/style.css`. Never edit the copy;
|
||||
it is only rewritten from the concept with `npm run --prefix tools/vendor sync:concept`.
|
||||
`npm run --prefix tools/vendor check` runs `sync-concept-css.mjs --check`, which fails
|
||||
on a missing copy or names the first differing byte. `workspace.html` loads, in order,
|
||||
the toolkit controls, Viewer.js, `concept.css`, the page stylesheets that their own
|
||||
PRs retire, and the production overlay `workspace.css` last.
|
||||
`tests/vendor/concept-css.test.mjs` asserts that order, the drift check, and that no
|
||||
other page or canvas script loads workspace CSS.
|
||||
|
||||
**Overlay decision-marker lint.** `lintOverlay` in `tools/vendor/sync-concept-css.mjs`
|
||||
fails any `workspace.css` rule whose selector is also a concept selector unless the
|
||||
rule carries a `/* D<n> */` comment, before its selector or inside its block. The
|
||||
D-number must be one of this spec's (#249): a comment that cites any other issue, such
|
||||
as `D5 (#245)`, is that issue's ruling and is not a marker, so write `#245 decision 5`
|
||||
for those. The lint test fails if `workspace.css` is missing. The match is by exact
|
||||
selector, so a narrower selector over a concept rule (for example
|
||||
`.ca-tools dialog.prompt-sender`) passes the lint. Such rules, and the page-stylesheet
|
||||
leftovers such as `library.css` `.row-actions`, name the PR that retires them, by
|
||||
convention.
|
||||
|
||||
The lint covers `workspace.css` only. The page stylesheets that load before it
|
||||
(`images.css`, `library.css`, `inspector.css`) are not linted yet, and `images.css`
|
||||
still has unmarked concept-selector overrides (`.image-grid`, `.prompt-text`,
|
||||
`.prompt-actions`, `.ca-tools .prompt-actions button`). PRs C and D retire those
|
||||
sheets: each one runs `lintOverlay` over every page sheet it keeps, and adds that
|
||||
sheet to the real-file lint test.
|
||||
|
||||
**Comparison method.** Production must equal the concept except for the recorded
|
||||
deviations in the #249 design (spec §8, D-numbers). It is never compared against
|
||||
blessed production screenshots.
|
||||
- The fixture is `tests/browser/workspace_concept_fixture.py`, reset through
|
||||
`POST /__test__/concept/reset`. It holds the concept's six prompts and pairs, and
|
||||
24 PNGs with real links, runs and the concept's inspector facts. The sidebar counts
|
||||
are therefore the concept's own: 24, 6 and 15.
|
||||
- The shared inventory is `tests/browser/concept-inventory.mjs`. One self-contained
|
||||
in-page measurer runs the same concept selectors in the immutable concept
|
||||
(`/__test__/canonical/workspace`) and in production. An absent element becomes
|
||||
`null`, so the diff names its path (for example `chrome.breadcrumbs`) instead of
|
||||
failing the measurement.
|
||||
- Each page's `map<Page>Concept(reference)` applies only the recorded deviations, each
|
||||
commented with its D-number. `diffInventory` must then report nothing.
|
||||
- Sizes, offsets and heights that follow from a removed element are derived from the
|
||||
concept's own measurements, never copied from production.
|
||||
- `mutate(page, {css, remove})` appends a stylesheet and detaches elements, and returns
|
||||
an undo. Each comparison has a mutation check proving it fails. For the shell, a
|
||||
`.sidebar nav a { min-height: 30px }` rule must fail at `chrome.navs.0.rows.0.minHeight`
|
||||
and `.height`, and removing `.breadcrumbs` must fail at `chrome.breadcrumbs`.
|
||||
- Paired concept/production captures are attached as test artifacts. There are no PNG
|
||||
baselines.
|
||||
|
||||
`tests/browser/shell-concept.spec.mjs` compares the shell: the sidebar, topbar, page
|
||||
heading and footer, at 1600x1100 and 390x844, in dark and light, under normal and
|
||||
reduced motion, on Images, Prompt library and Tags. The heading is compared in full on
|
||||
Tags; on Images and Library only its frame and h1 are compared, because their copy and
|
||||
actions belong to their own PRs. The spec also compares the hover state of the first
|
||||
non-current nav row, the Theme select and the ComfyUI link, and the keyboard rings of
|
||||
the first nav link and heading button.
|
||||
|
||||
Recorded shell deviations:
|
||||
- **D4**: a "Theme" select with Dark, Light and System. System follows the ComfyUI
|
||||
palette, then the OS.
|
||||
- **D5**: no Activity button or Activity row until an activity center exists.
|
||||
- **D28**: no SAVED VIEWS or COLLECTIONS, and no Design review. The bottom nav is
|
||||
System plus the production Overview hub (its own outline home glyph, not the concept's
|
||||
`activity` pulse, and no count).
|
||||
The connection reads "Your library, on your server" with a neutral muted dot.
|
||||
- **D29**: no prototype copy. There is no "Interactive concept" badge and no
|
||||
"What changes & why" button. The footer reads "ComfyAssets · Local tools. Your
|
||||
library.", and the title has no " concept" suffix.
|
||||
- **D31**: at `max-width: 950px` the overlay caps the one-column shell at
|
||||
`minmax(0, 1fr)`, so extreme zoom cannot widen it to the sidebar's min-content.
|
||||
- **#245 D2**: no "Auto-tag images" on the Tags heading until the image-annotation
|
||||
slice.
|
||||
- **Brand name**: not a deviation. The brand link's accessible name matches the
|
||||
concept's, mark included, because production's `.brand-mark` is not `aria-hidden`
|
||||
either. The inventory compares `brand.name` and `brand.mark.hidden`.
|
||||
|
||||
```sh
|
||||
npm run --prefix tools/vendor check
|
||||
npm test --prefix tools/vendor
|
||||
npm run --prefix tools/vendor test:workflows -- shell-concept.spec.mjs \
|
||||
--project chromium --project touch --project firefox --project webkit
|
||||
```
|
||||
|
||||
## Workspace data fields for the concept pages (#249)
|
||||
|
||||
These fields are additive. The legacy `/prompt_manager/images/output` listing (no
|
||||
`mode=workspace`) and the legacy pages do not change, and existing workspace pages
|
||||
ignore the new keys.
|
||||
|
||||
Workspace image items (`/prompt_manager/images/output?mode=workspace`, and Library
|
||||
image galleries) gain three card facts. Library list previews carry none of them
|
||||
and skip the parse.
|
||||
|
||||
- `linked: {status, positive_id, title, title_source, category, rating}`. One
|
||||
`get_image_link_summaries` SELECT per 200 paths, matched exactly as the
|
||||
inspection route matches (exact or normalized `file_path`, no basename
|
||||
fallback). `status` is `linked`, `ambiguous` (several owners), `none`, or
|
||||
`unavailable` when the database read failed (logged, no error text). Treat
|
||||
anything but `linked` as the filename fallback. In pair scope (the image has
|
||||
exactly one distinct pair), `positive_id` is the pair's positive, and the title
|
||||
comes from the pair title, then the positive's Library title, then the text's
|
||||
first line. In positive scope it comes from the Library title, then the first
|
||||
line. A first line longer than 80 code points is cut to 80 plus `…`, like
|
||||
`library.mjs` `title()`. `title_source` is `stored`, `text` or null. `category`
|
||||
and `rating` come from the linked positive (D13), not from the pair. The title
|
||||
gate is shared with the inspector (`_verified_title`): a `stored` title always
|
||||
shows. A `text` title shows only when the file's own positive, parsed from its
|
||||
records by the shared parser, gives the same display title. Otherwise the title
|
||||
is null on the card and in the inspector header. Videos and unreadable files
|
||||
have no parsed positive, so their `text` titles are withheld.
|
||||
- `seed`: a decimal string, or null. Always a string, because `JSON.parse` rounds
|
||||
numbers past 2**53 and ComfyUI's randomized seeds are often that large. It is
|
||||
`extract_generation_params` from `utils/generation_params.py` (the inspector's
|
||||
legacy parser, #251), run on the `prompt`/`workflow` records read with the
|
||||
dimensions. A PNG is read by the inspector's own reader
|
||||
(`utils.prompt_inspector.read_inspection_file`): text after IDAT counts, the
|
||||
shared byte budgets apply, and a file with conflicting duplicate `prompt` or
|
||||
`workflow` chunks gets no card seed, as the inspector refuses it (the card
|
||||
keeps its dimensions). Other formats use Pillow's header `info`, as the
|
||||
inspector's header read does. The records go through the inspection
|
||||
projection first (`utils/inspection_response.project_graph`), which carries
|
||||
integers past 2**53 as their exact digits, so the card and the inspector read
|
||||
the same digits. The parse runs after the file handle closes. A record over
|
||||
2 MiB (UTF-8), a parser error, a recursion error, a placeholder, or a
|
||||
non-integral, boolean or non-finite seed gives null. A card never shows a seed
|
||||
that differs from the inspector's
|
||||
(`test_card_seed_equals_inspector_seed_when_both_present`, which `JSON.parse`s
|
||||
the listing in Node and compares `String(seed)` with the inspector's seed,
|
||||
including one past 2**53). The card shows no seed for A1111 `parameters` files
|
||||
or for a BasicGuider custom sampler, where the inspector does.
|
||||
- Header facts are cached. Dimensions, `seed` and the parsed positive's display
|
||||
title are read once per `(path, mtime)` of the current gallery scan and kept
|
||||
beside the scan's per-file values. A monitor invalidation or TTL rescan keeps
|
||||
the facts of unchanged files and drops the rest, and a roots change drops them
|
||||
all. A header that could not be read is not stored. Links and favorites are
|
||||
never cached (`test_warm_page_reuses_card_facts_per_scan`: a warm page reads
|
||||
and parses no header). Measured cold cost is about 3.4 ms per 50 KB record set
|
||||
and 25 ms per 400 KB workflow.
|
||||
- `favorite: bool`. One favorites SELECT per root per page. A read failure logs a
|
||||
warning and shows false.
|
||||
|
||||
Filters (`mode=workspace`; anything invalid is 400 "Invalid gallery filters"):
|
||||
|
||||
- `q` (at most 500 characters). An entry passes when the case-folded relative path
|
||||
contains it, or when its linked positive's text, Library title, a tag name, or
|
||||
its pair title contains it (SQLite `LIKE` with escaping, ASCII case folding
|
||||
only). Search does not apply the card title gate, and does not search pair tags.
|
||||
- `category` (exact and case-sensitive, at most 200 characters, stripped; blank
|
||||
means none) and `tags` (a comma list, blanks dropped, order-free, AND, at most 30
|
||||
tags of at most 200 characters) admit linked images only, through the linked
|
||||
positive.
|
||||
- Ambiguous files never match a linked filter, and they rank as unlinked.
|
||||
- `sort` is `newest | oldest | name | name-desc | rating`. `rating` orders by
|
||||
rating 5 to 1, then linked but unrated, then unlinked and ambiguous; ties break
|
||||
newest first. A signed cursor covers every filter and the sort, so reusing it
|
||||
with a change is 400.
|
||||
- One link read (`get_image_link_matches`, streamed and bounded to the listed
|
||||
paths) serves the filters, the rating sort and the count.
|
||||
|
||||
`positive_count` is the number of distinct linked positives over the full matching
|
||||
set, not the page:
|
||||
|
||||
- Present on every first page, and on every page of a filtered (`q`, `category`,
|
||||
`tags`) or `sort=rating` listing.
|
||||
- Absent on a plain listing's cursor pages, which read no links. The frontend
|
||||
keeps the first page's value.
|
||||
- Null when a plain first page's link read failed. The listing still returns 200,
|
||||
and consumers then hide "· K positive prompts". A `q` search alone also
|
||||
survives the failure: it matches filenames only, as before linked search, and
|
||||
every page's count is null (logged with the traceback). A failure under
|
||||
`category`, `tags` or the rating sort stays a safe 500.
|
||||
|
||||
Library parents (list, detail and command receipts) gain `run_count` (an int; the
|
||||
D23 migration estimate for rows from before 3.2.4) and `last_used_at`, a UTC ISO
|
||||
string. A naive value reads as UTC, and an unreadable or out-of-range value is
|
||||
null. The edit token ignores both. `variant_total` has two meanings. On the list
|
||||
response it counts the active variants over all filtered parents (the same
|
||||
snapshot as `total`, the same on every cursor page). On the detail response it
|
||||
counts one positive's variants in the chosen archive scope. The Library caption
|
||||
reads it from the list state.
|
||||
|
||||
Favorites store (D7 = A): `PUT` and `DELETE /prompt_manager/images/favorite` with
|
||||
`{root_index, path}`. Both are idempotent and answer
|
||||
`{success, favorite, root_index, path}`. The boundary returns 400 for an unknown
|
||||
root or a malformed body, and 403 for traversal, an absolute or drive path, NUL, a
|
||||
non-media name, or a key over the stored bound. PUT needs a listed file (404
|
||||
otherwise). DELETE also clears a path that is no longer listed. Send the DELETE
|
||||
body as `application/json`.
|
||||
|
||||
- The table is `image_favorites(root_key, relative_path, created_at)`. `root_key`
|
||||
is the root's real path (`os.path.realpath`), case-normalized with `/`
|
||||
separators. It is stored server-side only and never returned. Folder labels are
|
||||
not unique across roots, so they are not used as keys.
|
||||
- `relative_path` is `normcase(path)` with `/`. On Windows a case-only rename
|
||||
keeps the star. On POSIX the key is the exact spelling, so `A.png` and `a.png`
|
||||
stay separate (tests swap in `ntpath` through `py.api.images._path_flavor`).
|
||||
Moving the install or changing a root's real path orphans its rows, which are
|
||||
harmless and never shown.
|
||||
- Schema validation (`image_favorites_schema_error`) runs at startup and on
|
||||
restore. Old backups without the table restore, and backup plus restore keeps
|
||||
WAL-resident favorites and closes every handle (`tests/test_database_backup.py`).
|
||||
|
||||
Budgets: `test_filter_work_is_bounded` always runs. It asserts that a plain first
|
||||
page makes 1 link SELECT and a plain cursor page none, that every filter shape
|
||||
makes exactly 1, the covering-index plan, and that the per-file values are built
|
||||
once per scan and only for new files after a monitor invalidation. The opt-in
|
||||
`test_filter_budget` (`PROMPTMANAGER_PERF_BUDGET=1`, the `perf budget` job) times
|
||||
the D12 p95 < 150 ms budget; see "Performance budgets".
|
||||
|
||||
```sh
|
||||
python -m unittest tests.test_api_workspace_images tests.test_card_seed \
|
||||
tests.test_image_favorites tests.test_library tests.test_api_library \
|
||||
tests.test_database_backup -q
|
||||
```
|
||||
|
||||
Installing these fields needs a maintainer ComfyUI restart (Python, routes and
|
||||
schema). Automated tests use disposable databases and output folders.
|
||||
|
||||
## Prompt sending and cross-pack compatibility
|
||||
|
||||
`tests/js/prompt-bridge-contracts.test.js` runs the Toolkit's shared protocol
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""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
|
||||
+88
-7
@@ -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,
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ 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 .execution_history_schema import (
|
||||
history_schema_error,
|
||||
migrate_execution_history,
|
||||
@@ -298,6 +299,7 @@ class PromptModel:
|
||||
self._migrate_prompt_pairs(conn)
|
||||
migrate_execution_history(conn)
|
||||
migrate_library_state(conn)
|
||||
migrate_image_favorites(conn)
|
||||
|
||||
def _migrate_prompt_pairs(self, conn: sqlite3.Connection) -> None:
|
||||
conn.execute("SAVEPOINT prompt_pair_schema")
|
||||
@@ -1016,6 +1018,9 @@ class PromptModel:
|
||||
|
||||
Args:
|
||||
path: File to inspect
|
||||
require_library_state: Also require the tables every current
|
||||
database has (Library state and image favorites), 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 +1075,11 @@ 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
|
||||
return True, "ok"
|
||||
except sqlite3.Error as e:
|
||||
return False, f"Invalid SQLite database: {e}"
|
||||
|
||||
+211
-2
@@ -6,11 +6,12 @@ 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 .library_commands import LibraryCommandOperations
|
||||
from .vocabulary import VocabularyOperations
|
||||
from .execution_history import ExecutionHistoryOperations
|
||||
@@ -179,6 +180,119 @@ AND p.id NOT IN (SELECT prompt_id FROM live_hint_prompts)
|
||||
"""
|
||||
|
||||
|
||||
# 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, title and metadata then describe the card.
|
||||
_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, pp.title, 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 positive's text, its Library title or one of its tag names, or the pair
|
||||
# title, contains :pattern (LIKE, so ASCII case is ignored as in Library search).
|
||||
_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 '\\'))
|
||||
OR pp.title 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[9], row[10], row[11]
|
||||
titles = (row[8], row[12])
|
||||
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 +302,7 @@ class PromptDatabase(
|
||||
ExecutionContextOperations,
|
||||
ExecutionOutputOperations,
|
||||
HistoryReadOperations,
|
||||
ImageFavoriteOperations,
|
||||
):
|
||||
"""Database operations class for managing prompts."""
|
||||
|
||||
@@ -2160,6 +2275,100 @@ class PromptDatabase(
|
||||
"parameters": row[8] if len(row) > 8 else None,
|
||||
}
|
||||
|
||||
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, or in the pair title.
|
||||
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_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:
|
||||
|
||||
+24
-1
@@ -283,6 +283,16 @@ class PromptManagerAPI(
|
||||
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.
|
||||
@@ -348,9 +358,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.
|
||||
|
||||
+1
-1
@@ -897,7 +897,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):
|
||||
|
||||
+689
-95
@@ -11,8 +11,10 @@ import re
|
||||
import threading
|
||||
import time as _time
|
||||
import urllib.parse
|
||||
from bisect import bisect_left, bisect_right
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
|
||||
from aiohttp import web
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
@@ -29,14 +31,123 @@ from .inspection_service import (
|
||||
)
|
||||
|
||||
try:
|
||||
from ...database.image_favorites import MAX_KEY_LENGTH
|
||||
from ...database.library import display_title
|
||||
from ...database.models import normalize_image_path
|
||||
from ...utils.card_seed import card_seed, header_params, parsed_positive
|
||||
from ...utils.parallel import map_parallel
|
||||
from ...utils.inspection_response import encode_inspection_response
|
||||
from ...utils.prompt_inspector import MetadataReadError, read_inspection_file
|
||||
except ImportError:
|
||||
from database.image_favorites import MAX_KEY_LENGTH
|
||||
from database.library import display_title
|
||||
from database.models import normalize_image_path
|
||||
from utils.card_seed import card_seed, header_params, parsed_positive
|
||||
from utils.parallel import map_parallel
|
||||
from utils.inspection_response import encode_inspection_response
|
||||
from utils.prompt_inspector import MetadataReadError, read_inspection_file
|
||||
|
||||
|
||||
IMAGE_EXTENSIONS = _IMAGE_EXTENSIONS
|
||||
# The header records the card parses (see utils.card_seed.header_params).
|
||||
_CARD_RECORDS = ("prompt", "workflow")
|
||||
_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
||||
|
||||
|
||||
def _card_header(path: Path) -> tuple:
|
||||
"""``(width, height, records)`` of one image card, every handle closed.
|
||||
|
||||
A PNG's records come from the inspector's own reader
|
||||
(``read_inspection_file``: text after IDAT, the shared byte budgets, and
|
||||
the refusal of conflicting duplicate prompt or workflow chunks), so the
|
||||
card reads exactly what the inspector reads. When that reader refuses the
|
||||
file, the card keeps its dimensions and gets no records. Other formats
|
||||
keep Pillow's header ``info``, as the inspector's header read does.
|
||||
Raises OSError or ValueError for a file that cannot be read at all.
|
||||
"""
|
||||
with path.open("rb") as stream:
|
||||
if stream.read(len(_PNG_SIGNATURE)) != _PNG_SIGNATURE:
|
||||
stream.seek(0)
|
||||
with Image.open(stream) as source:
|
||||
width, height = source.size
|
||||
return width, height, {k: source.info.get(k) for k in _CARD_RECORDS}
|
||||
try:
|
||||
inspection = read_inspection_file(path)
|
||||
except MetadataReadError:
|
||||
with path.open("rb") as stream, Image.open(stream) as source:
|
||||
width, height = source.size
|
||||
return width, height, None
|
||||
records = {k: inspection.metadata.get(k) for k in _CARD_RECORDS}
|
||||
return inspection.width, inspection.height, records
|
||||
|
||||
|
||||
class _CardFacts(NamedTuple):
|
||||
"""One image file's card facts, derived from its header alone.
|
||||
|
||||
Cached per gallery scan by ``(path, mtime)`` (see ``_card_facts``); links
|
||||
and favorites are read fresh on every page.
|
||||
"""
|
||||
|
||||
width: int | None
|
||||
height: int | None
|
||||
seed: str | None
|
||||
positive_title: str | None # _positive_title of the parsed positive
|
||||
|
||||
|
||||
_NO_CARD_FACTS = _CardFacts(None, None, None, None)
|
||||
|
||||
|
||||
def _unavailable_link() -> dict:
|
||||
"""A card link summary when the database could not be read."""
|
||||
return {
|
||||
"status": "unavailable",
|
||||
"positive_id": None,
|
||||
"title": None,
|
||||
"title_source": None,
|
||||
"category": None,
|
||||
"rating": None,
|
||||
}
|
||||
|
||||
|
||||
def _positive_title(positive: str | None) -> str | None:
|
||||
"""The display title of a file's parsed positive (None without one)."""
|
||||
return None if positive is None else display_title((), positive)
|
||||
|
||||
|
||||
def _verified_title(summary: dict | None, positive_title: str | None) -> str | None:
|
||||
"""A linked file's Library title, withholding unconfirmed saved prompt text.
|
||||
|
||||
The one rule for the card and the inspector. A stored title is user data and
|
||||
always shows. A title taken from the saved positive's first line shows only
|
||||
when ``positive_title`` (``_positive_title`` of the file's own positive as
|
||||
the shared parser reads it from the file's records) is the same title: a
|
||||
saved association never replaces file evidence.
|
||||
"""
|
||||
if not isinstance(summary, dict) or summary.get("status") != "linked":
|
||||
return None
|
||||
title = summary.get("title")
|
||||
if summary.get("title_source") != "text":
|
||||
return title
|
||||
confirmed = positive_title is not None and positive_title == title
|
||||
return title if confirmed else None
|
||||
|
||||
|
||||
def _card_link(summary: dict, positive_title: str | None) -> dict:
|
||||
"""The card's link summary with an unconfirmed first-line title withheld."""
|
||||
if summary.get("title_source") != "text":
|
||||
return summary
|
||||
return {**summary, "title": _verified_title(summary, positive_title)}
|
||||
|
||||
|
||||
def _inspected_positive(observed: dict) -> str | None:
|
||||
"""The positive the shared parser reads from the inspected file's records.
|
||||
|
||||
Videos give None, as on the card, which reads no video metadata.
|
||||
"""
|
||||
inspection = observed.get("inspection")
|
||||
if inspection is None or observed["file"]["media_type"] == "video":
|
||||
return None
|
||||
return parsed_positive(header_params(inspection.metadata))
|
||||
|
||||
|
||||
def _public_path(path):
|
||||
@@ -238,14 +349,66 @@ def _workspace_root(query: dict, roots: list[Path]) -> int | None:
|
||||
return index
|
||||
|
||||
|
||||
WORKSPACE_ORDERS = ("newest", "oldest", "name", "name-desc", "rating")
|
||||
# Library-compatible bounds on the linked-positive filters (database/library.py).
|
||||
MAX_FILTER_CATEGORY = 200
|
||||
MAX_FILTER_TAGS = 30
|
||||
MAX_FILTER_TAG = 200
|
||||
|
||||
|
||||
class _Listed(NamedTuple):
|
||||
"""One listed file plus the derived values every filter and sort reads.
|
||||
|
||||
Built once per gallery scan (see ``_workspace_orders``); the first four
|
||||
fields are the ``(path, mtime, root, index)`` entry the page formatter takes.
|
||||
"""
|
||||
|
||||
path: Path
|
||||
mtime: float
|
||||
root: Path
|
||||
index: int
|
||||
folded: str # relative POSIX path, case-folded (filename search)
|
||||
folder: str # str(relative.parent), native separators (subfolder filter)
|
||||
name: str # case-folded basename (name sorts)
|
||||
tie: str # sha256 of the relative POSIX path (bounded tie-break)
|
||||
video: bool
|
||||
normalized: str # normalize_image_path, compared with linked rows
|
||||
|
||||
|
||||
def _listed(path: Path, mtime: float, root: Path, index: int) -> _Listed:
|
||||
text, prefix = str(path), os.path.join(str(root), "")
|
||||
relative = (
|
||||
text[len(prefix) :] if text.startswith(prefix) else str(path.relative_to(root))
|
||||
)
|
||||
posix = relative if os.sep == "/" else relative.replace(os.sep, "/")
|
||||
name = relative.rpartition(os.sep)[2]
|
||||
return _Listed(
|
||||
path,
|
||||
mtime,
|
||||
root,
|
||||
index,
|
||||
posix.casefold(),
|
||||
os.path.dirname(relative) or ".",
|
||||
name.casefold(),
|
||||
hashlib.sha256(posix.encode("utf-8")).hexdigest(),
|
||||
os.path.splitext(name)[1].lower() in VIDEO_EXTENSIONS,
|
||||
normalize_image_path(text),
|
||||
)
|
||||
|
||||
|
||||
def _workspace_key(entry: tuple, order: str) -> tuple:
|
||||
path, mtime, root, index = entry
|
||||
relative = path.relative_to(root).as_posix()
|
||||
tie = hashlib.sha256(relative.encode("utf-8")).hexdigest()
|
||||
name = path.name.casefold()
|
||||
"""The keyset sort key of *entry* (a _Listed or a 4-tuple entry)."""
|
||||
if not isinstance(entry, _Listed):
|
||||
entry = _listed(*entry)
|
||||
if order in ("name", "name-desc"):
|
||||
return (name, tie, index, mtime)
|
||||
return ((-mtime if order == "newest" else mtime), name, tie, index)
|
||||
return (entry.name, entry.tie, entry.index, entry.mtime)
|
||||
mtime = -entry.mtime if order in ("newest", "rating") else entry.mtime
|
||||
return (mtime, entry.name, entry.tie, entry.index)
|
||||
|
||||
|
||||
def _rated_key(entry: _Listed, rank: int) -> tuple:
|
||||
"""Highest rated: rating 5..1, linked unrated (0), unlinked (-1); then newest."""
|
||||
return (-rank, *_workspace_key(entry, "rating"))
|
||||
|
||||
|
||||
def _workspace_cursor(key: tuple, scope: str) -> str:
|
||||
@@ -276,6 +439,8 @@ def _workspace_cursor_key(token: str, scope: str, order: str) -> tuple:
|
||||
if order in ("name", "name-desc")
|
||||
else ((float, int), str, str, int)
|
||||
)
|
||||
if order == "rating":
|
||||
types = (int, *types)
|
||||
if len(key) != len(types) or not all(
|
||||
type(value) in (kind if isinstance(kind, tuple) else (kind,))
|
||||
for value, kind in zip(key, types)
|
||||
@@ -328,6 +493,57 @@ async def _json_body_or_error(request):
|
||||
return None, _json_error("Invalid JSON body", 400)
|
||||
|
||||
|
||||
MAX_FAVORITE_PATH = 4096
|
||||
|
||||
|
||||
def _favorite_target(data: Mapping, roots: list[Path]):
|
||||
"""``((index, relative), None)`` for a favorite body, else ``(None, error)``.
|
||||
|
||||
Rejects unknown roots, traversal, absolute or drive paths and non-media
|
||||
names before any filesystem or database access.
|
||||
"""
|
||||
index, relative = data.get("root_index"), data.get("path")
|
||||
if type(index) is not int or not 0 <= index < len(roots):
|
||||
return None, bad_request("Invalid gallery root")
|
||||
if (
|
||||
not isinstance(relative, str)
|
||||
or not relative
|
||||
or len(relative) > MAX_FAVORITE_PATH
|
||||
):
|
||||
return None, bad_request("path must be a relative media path")
|
||||
if (
|
||||
_has_traversal(relative)
|
||||
or "\0" in relative
|
||||
or re.match(r"^[a-zA-Z]:", relative)
|
||||
):
|
||||
return None, _forbidden()
|
||||
if Path(relative).suffix.lower() not in MEDIA_EXTENSIONS:
|
||||
return None, _forbidden("Only media files can be favorites")
|
||||
return (index, relative), None
|
||||
|
||||
|
||||
# The path module whose case rules key favorites (tests swap in ntpath).
|
||||
_path_flavor = os.path
|
||||
|
||||
|
||||
def _favorite_relative_key(relative: str) -> str:
|
||||
"""Case-normalized POSIX key of a root-relative path.
|
||||
|
||||
Lowercased with "/" separators where the platform folds case (Windows),
|
||||
identity on POSIX, so a case-only rename keeps its favorite on Windows
|
||||
and distinct Linux files stay distinct.
|
||||
"""
|
||||
return _path_flavor.normcase(relative).replace(_path_flavor.sep, "/")
|
||||
|
||||
|
||||
def _favorite_root_key(root) -> str:
|
||||
"""Server-side identity of a gallery root: its real, case-normalized path.
|
||||
|
||||
Never sent in a response; folder names alone are not unique across roots.
|
||||
"""
|
||||
return _favorite_relative_key(os.path.realpath(str(root)))
|
||||
|
||||
|
||||
def _iter_media_files(output_path, max_files):
|
||||
"""Yield media files under *output_path* with bounded effort.
|
||||
|
||||
@@ -407,6 +623,87 @@ def _output_image_entry(
|
||||
return entry
|
||||
|
||||
|
||||
def _workspace_filters(query: Mapping) -> dict:
|
||||
"""Validate the workspace listing's filter and sort parameters.
|
||||
|
||||
``q`` (at most 500 characters) matches the relative path, case-folded, and
|
||||
the linked positive's text, title and tag names. ``category`` is exact,
|
||||
``tags`` is a comma list that must all be present, ``sort=rating`` ranks by
|
||||
the linked positive. Raises ValueError for any unknown or oversized value.
|
||||
"""
|
||||
text = query.get("q", "").strip()
|
||||
folded = text.casefold()
|
||||
media = query.get("media", "all")
|
||||
order = query.get("sort", "newest")
|
||||
folder = query.get("subfolder", "").strip().replace("\\", "/")
|
||||
category = query.get("category", "").strip() or None
|
||||
tags = sorted(
|
||||
{tag.strip() for tag in query.get("tags", "").split(",") if tag.strip()}
|
||||
)
|
||||
if (
|
||||
len(text) > 500
|
||||
or len(folded) > 500
|
||||
or media not in ("all", "image", "video")
|
||||
or order not in WORKSPACE_ORDERS
|
||||
or (category is not None and len(category) > MAX_FILTER_CATEGORY)
|
||||
or len(tags) > MAX_FILTER_TAGS
|
||||
or any(len(tag) > MAX_FILTER_TAG for tag in tags)
|
||||
):
|
||||
raise ValueError("Invalid gallery filters")
|
||||
if folder and (_has_traversal(folder) or len(folder) > 1024):
|
||||
raise ValueError("Invalid gallery folder")
|
||||
return {
|
||||
"text": text,
|
||||
"query": folded,
|
||||
"media": media,
|
||||
"order": order,
|
||||
"folder": folder,
|
||||
"category": category,
|
||||
"tags": tags,
|
||||
}
|
||||
|
||||
|
||||
# A plain listing's cursor page leaves positive_count out (see
|
||||
# _select_workspace_page); None instead means the count could not be read.
|
||||
_COUNT_OMITTED = object()
|
||||
|
||||
|
||||
def _filtered_page(
|
||||
entries: list, start: int, size: int, filters: dict, links: dict | None
|
||||
) -> tuple[int, list, int | None]:
|
||||
"""Count the *entries* the filters admit and take *size* from *start* on.
|
||||
|
||||
*links* maps normalized paths to (hit, rating, positive id) for every
|
||||
unambiguously linked image (see PromptDatabase.get_image_link_matches); a
|
||||
category or tag filter admits only those, and ``q`` admits a filename match
|
||||
or a linked-positive hit. Returns the total, the page and the number of
|
||||
distinct linked positives among all admitted entries (None without links).
|
||||
"""
|
||||
query, media, folder = filters["query"], filters["media"], filters["folder"]
|
||||
members_only = filters["category"] is not None or bool(filters["tags"])
|
||||
native_folder = folder.replace("/", os.sep)
|
||||
nested = native_folder + os.sep
|
||||
total, page, positives = 0, [], set()
|
||||
for position, entry in enumerate(entries):
|
||||
if folder and not (
|
||||
entry.folder == native_folder or entry.folder.startswith(nested)
|
||||
):
|
||||
continue
|
||||
if media != "all" and (media == "video") != entry.video:
|
||||
continue
|
||||
match = None if links is None else links.get(entry.normalized)
|
||||
if members_only and match is None:
|
||||
continue
|
||||
if query and query not in entry.folded and not (match and match[0]):
|
||||
continue
|
||||
total += 1
|
||||
if match is not None:
|
||||
positives.add(match[2])
|
||||
if position >= start and len(page) < size:
|
||||
page.append(entry)
|
||||
return total, page, None if links is None else len(positives)
|
||||
|
||||
|
||||
def _in_subfolder(rel_dir, subfolder):
|
||||
return rel_dir == subfolder or rel_dir.startswith(subfolder + os.sep)
|
||||
|
||||
@@ -523,6 +820,14 @@ class ImageRoutesMixin:
|
||||
async def get_gallery_subfolders_route(request):
|
||||
return await self.get_gallery_subfolders(request)
|
||||
|
||||
@routes.put("/prompt_manager/images/favorite")
|
||||
async def set_image_favorite_route(request):
|
||||
return await self.update_image_favorite(request, favorite=True)
|
||||
|
||||
@routes.delete("/prompt_manager/images/favorite")
|
||||
async def clear_image_favorite_route(request):
|
||||
return await self.update_image_favorite(request, favorite=False)
|
||||
|
||||
# POST: drops the server-side listing cache and rewalks the output roots.
|
||||
@routes.post("/prompt_manager/gallery/rescan")
|
||||
async def rescan_gallery_route(request):
|
||||
@@ -756,32 +1061,26 @@ class ImageRoutesMixin:
|
||||
return bad_request("limit and offset must be integers")
|
||||
try:
|
||||
root_index = _workspace_root(request.query, output_dirs)
|
||||
query = request.query.get("q", "").strip().casefold()
|
||||
media = request.query.get("media", "all")
|
||||
order = request.query.get("sort", "newest")
|
||||
folder = request.query.get("subfolder", "").strip().replace("\\", "/")
|
||||
if (
|
||||
len(query) > 500
|
||||
or media not in ("all", "image", "video")
|
||||
or order not in ("newest", "oldest", "name", "name-desc")
|
||||
):
|
||||
raise ValueError("Invalid gallery filters")
|
||||
if folder and (_has_traversal(folder) or len(folder) > 1024):
|
||||
raise ValueError("Invalid gallery folder")
|
||||
filters = _workspace_filters(request.query)
|
||||
scope = hashlib.sha256(
|
||||
json.dumps(
|
||||
[
|
||||
query,
|
||||
media,
|
||||
order,
|
||||
folder,
|
||||
filters["query"],
|
||||
filters["text"],
|
||||
filters["media"],
|
||||
filters["order"],
|
||||
filters["folder"],
|
||||
root_index,
|
||||
[str(root) for root in output_dirs],
|
||||
filters["category"],
|
||||
filters["tags"],
|
||||
]
|
||||
).encode()
|
||||
).hexdigest()
|
||||
token = request.query.get("cursor")
|
||||
after = _workspace_cursor_key(token, scope, order) if token else None
|
||||
after = (
|
||||
_workspace_cursor_key(token, scope, filters["order"]) if token else None
|
||||
)
|
||||
except ValueError as exc:
|
||||
return bad_request(str(exc))
|
||||
|
||||
@@ -790,75 +1089,193 @@ class ImageRoutesMixin:
|
||||
for index, root in enumerate(output_dirs)
|
||||
if root_index is None or index == root_index
|
||||
]
|
||||
page, total, has_more, next_cursor = await self._run_in_executor(
|
||||
self._select_workspace_page,
|
||||
listings,
|
||||
folder,
|
||||
query,
|
||||
media,
|
||||
order,
|
||||
after,
|
||||
limit,
|
||||
scope,
|
||||
page, total, has_more, next_cursor, positive_count = (
|
||||
await self._run_in_executor(
|
||||
self._select_workspace_page, listings, filters, after, limit, scope
|
||||
)
|
||||
)
|
||||
images = await self._run_in_executor(self._format_workspace_page, page)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"images": images,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"has_more": has_more,
|
||||
"next_cursor": next_cursor,
|
||||
}
|
||||
result = {
|
||||
"success": True,
|
||||
"images": images,
|
||||
"total": total,
|
||||
"positive_count": positive_count,
|
||||
"limit": limit,
|
||||
"has_more": has_more,
|
||||
"next_cursor": next_cursor,
|
||||
}
|
||||
if positive_count is _COUNT_OMITTED:
|
||||
del result["positive_count"]
|
||||
return web.json_response(result)
|
||||
|
||||
def _workspace_listed(self, index: int, root: Path, files: list) -> list:
|
||||
"""The _Listed values of one root's scan, rebuilt only where it changed.
|
||||
|
||||
The gallery cache hands out the same list object until it rescans.
|
||||
A rescan reuses the value of every unchanged ``(path, mtime)`` and,
|
||||
when nothing changed, the previous list itself, so the cached sort
|
||||
orders stay valid. Only a gallery-roots change drops these values.
|
||||
"""
|
||||
key = (index, str(root))
|
||||
cached = self._workspace_listed_cache.get(key)
|
||||
if cached is not None and cached[0] is files:
|
||||
return cached[1]
|
||||
previous = cached[2] if cached is not None else {}
|
||||
listed = []
|
||||
for path, mtime in files:
|
||||
entry = previous.get((path, mtime))
|
||||
listed.append(entry or _listed(path, mtime, root, index))
|
||||
if (
|
||||
cached is not None
|
||||
and len(listed) == len(cached[1])
|
||||
and all(new is old for new, old in zip(listed, cached[1]))
|
||||
):
|
||||
listed, lookup = cached[1], previous
|
||||
else:
|
||||
lookup = {(entry.path, entry.mtime): entry for entry in listed}
|
||||
# Card facts follow the per-file values: keep unchanged files only.
|
||||
cards = self._workspace_card_cache.get(key)
|
||||
if cards:
|
||||
self._workspace_card_cache[key] = {
|
||||
file: facts for file, facts in cards.items() if file in lookup
|
||||
}
|
||||
self._workspace_listed_cache[key] = (files, listed, lookup)
|
||||
return listed
|
||||
|
||||
def _workspace_orders(self, listings: list[tuple]) -> dict:
|
||||
"""The selected roots' files in newest order, plus lazily added orders."""
|
||||
parts = tuple(
|
||||
self._workspace_listed(index, root, files)
|
||||
for index, root, files in listings
|
||||
)
|
||||
key = tuple((index, str(root)) for index, root, _files in listings)
|
||||
cached = self._workspace_order_cache.get(key)
|
||||
if cached is not None and all(
|
||||
old is new for old, new in zip(cached[0], parts, strict=True)
|
||||
):
|
||||
return cached[1]
|
||||
newest = sorted(
|
||||
(entry for part in parts for entry in part),
|
||||
key=lambda entry: _workspace_key(entry, "newest"),
|
||||
)
|
||||
orders = {"newest": newest}
|
||||
self._workspace_order_cache[key] = (parts, orders)
|
||||
return orders
|
||||
|
||||
def _workspace_wanted(self, listings: list[tuple]) -> frozenset:
|
||||
"""The selected listing's normalized paths (cached with its orders)."""
|
||||
orders = self._workspace_orders(listings)
|
||||
if "wanted" not in orders:
|
||||
orders["wanted"] = frozenset(entry.normalized for entry in orders["newest"])
|
||||
return orders["wanted"]
|
||||
|
||||
def _workspace_order(self, listings: list[tuple], order: str) -> list:
|
||||
"""The listing sorted by a filesystem order (built lazily, cached)."""
|
||||
orders = self._workspace_orders(listings)
|
||||
if order not in orders:
|
||||
if order == "name-desc":
|
||||
orders[order] = self._workspace_order(listings, "name")[::-1]
|
||||
else:
|
||||
orders[order] = sorted(
|
||||
orders["newest"], key=lambda entry: _workspace_key(entry, order)
|
||||
)
|
||||
return orders[order]
|
||||
|
||||
@staticmethod
|
||||
def _select_workspace_page(
|
||||
self,
|
||||
listings: list[tuple],
|
||||
folder: str,
|
||||
query: str,
|
||||
media: str,
|
||||
order: str,
|
||||
filters: dict,
|
||||
after: tuple | None,
|
||||
limit: int,
|
||||
scope: str,
|
||||
) -> tuple:
|
||||
entries = []
|
||||
for index, root, files in listings:
|
||||
for path, mtime in files:
|
||||
relative = path.relative_to(root)
|
||||
if folder and not _in_subfolder(
|
||||
str(relative.parent), folder.replace("/", os.sep)
|
||||
):
|
||||
continue
|
||||
if query and query not in relative.as_posix().casefold():
|
||||
continue
|
||||
kind = "video" if path.suffix.lower() in VIDEO_EXTENSIONS else "image"
|
||||
if media != "all" and media != kind:
|
||||
continue
|
||||
entries.append((path, mtime, root, index))
|
||||
total = len(entries)
|
||||
reverse = order == "name-desc"
|
||||
entries.sort(key=lambda entry: _workspace_key(entry, order), reverse=reverse)
|
||||
"""One keyset page of the filtered listing (blocking; run in executor).
|
||||
|
||||
One SELECT (get_image_link_matches) reads every linked positive of the
|
||||
listing: the filters, the rating sort and the positive count share it.
|
||||
A plain listing (no ``q``, category, tags or rating sort) reads it only
|
||||
for its first page; its cursor pages read no links and the returned
|
||||
count is _COUNT_OMITTED, so the response leaves ``positive_count`` out
|
||||
and the frontend keeps the first page's value. On that plain first
|
||||
page a failed read leaves the listing usable and the count unknown
|
||||
(None); so does a failed read under ``q`` alone, whose search then
|
||||
matches filenames only (as before the linked search existed).
|
||||
"""
|
||||
order, category, tags = filters["order"], filters["category"], filters["tags"]
|
||||
needed = (
|
||||
bool(filters["query"])
|
||||
or category is not None
|
||||
or bool(tags)
|
||||
or order == "rating"
|
||||
)
|
||||
links = None
|
||||
if needed or after is None:
|
||||
try:
|
||||
links = self.db.get_image_link_matches(
|
||||
filters["text"],
|
||||
category,
|
||||
tags,
|
||||
every_link=True,
|
||||
wanted=self._workspace_wanted(listings),
|
||||
)
|
||||
except Exception:
|
||||
# Category, tags and the rating sort cannot be served without
|
||||
# the links. A plain listing and a filename search can: the
|
||||
# search then matches filenames only, and the count is None.
|
||||
if category is not None or tags or order == "rating":
|
||||
raise
|
||||
self.logger.warning(
|
||||
"Workspace prompt links could not be read; %s",
|
||||
(
|
||||
"searching filenames only"
|
||||
if filters["query"]
|
||||
else "positive count unknown"
|
||||
),
|
||||
exc_info=True,
|
||||
)
|
||||
if order == "rating":
|
||||
|
||||
def rank(entry):
|
||||
match = links.get(entry.normalized)
|
||||
return -1 if match is None else (match[1] or 0)
|
||||
|
||||
# Stable: within one rank the newest order (the tie-break) stays.
|
||||
entries = sorted(
|
||||
self._workspace_order(listings, "newest"),
|
||||
key=lambda entry: -rank(entry),
|
||||
)
|
||||
|
||||
def key(entry):
|
||||
return _rated_key(entry, rank(entry))
|
||||
|
||||
else:
|
||||
entries = self._workspace_order(listings, order)
|
||||
|
||||
def key(entry):
|
||||
return _workspace_key(entry, order)
|
||||
|
||||
start = 0
|
||||
if after is not None:
|
||||
entries = [
|
||||
entry
|
||||
for entry in entries
|
||||
if (
|
||||
_workspace_key(entry, order) < after
|
||||
if reverse
|
||||
else _workspace_key(entry, order) > after
|
||||
)
|
||||
]
|
||||
page = entries[:limit]
|
||||
has_more = len(entries) > limit
|
||||
next_cursor = (
|
||||
_workspace_cursor(_workspace_key(page[-1], order), scope)
|
||||
if has_more
|
||||
else None
|
||||
if order == "name-desc":
|
||||
ascending = self._workspace_order(listings, "name")
|
||||
start = len(entries) - bisect_left(ascending, after, key=key)
|
||||
else:
|
||||
start = bisect_right(entries, after, key=key)
|
||||
total, page, positive_count = _filtered_page(
|
||||
entries, start, limit + 1, filters, links
|
||||
)
|
||||
if not needed and after is not None:
|
||||
positive_count = _COUNT_OMITTED
|
||||
has_more = len(page) > limit
|
||||
page = page[:limit]
|
||||
next_cursor = _workspace_cursor(key(page[-1]), scope) if has_more else None
|
||||
return (
|
||||
[(entry.path, entry.mtime, entry.root, entry.index) for entry in page],
|
||||
total,
|
||||
has_more,
|
||||
next_cursor,
|
||||
positive_count,
|
||||
)
|
||||
return page, total, has_more, next_cursor
|
||||
|
||||
async def serve_image(self, request):
|
||||
"""Serve the actual image file using streamed FileResponse."""
|
||||
@@ -1679,6 +2096,91 @@ class ImageRoutesMixin:
|
||||
{"success": False, "error": _safe_error(e)}, status=500
|
||||
)
|
||||
|
||||
async def update_image_favorite(self, request, *, favorite: bool):
|
||||
"""PUT/DELETE one favorite, named by ``{root_index, path}``.
|
||||
|
||||
A favorite is set only on a file in the root's listing; clearing also
|
||||
accepts a well-formed path that is no longer listed, so a deleted
|
||||
file's favorite can still be removed. Both are idempotent.
|
||||
"""
|
||||
data, error = await _json_body_or_error(request)
|
||||
if error is not None:
|
||||
return error
|
||||
roots = list(self._get_all_output_dirs())
|
||||
target, error = _favorite_target(data, roots)
|
||||
if error is not None:
|
||||
return error
|
||||
index, relative = target
|
||||
root = roots[index]
|
||||
relative = Path(relative).as_posix()
|
||||
try:
|
||||
files = await self._get_gallery_files(root)
|
||||
listing = await self._run_in_executor(self._favorite_listing, root, files)
|
||||
key = _favorite_relative_key(relative)
|
||||
listed = listing.get(key)
|
||||
if listed is None and favorite:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Image file not found"}, status=404
|
||||
)
|
||||
root_key = _favorite_root_key(root)
|
||||
if favorite and max(len(root_key), len(key)) > MAX_KEY_LENGTH:
|
||||
# A stored key past the schema bound would fail the next
|
||||
# startup or restore validation.
|
||||
return _forbidden("Path is too long to favorite")
|
||||
update = (
|
||||
self.db.set_image_favorite if favorite else self.db.clear_image_favorite
|
||||
)
|
||||
await self._run_in_executor(update, root_key, key)
|
||||
except Exception:
|
||||
self.logger.exception("Favorite update failed")
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Favorite update failed"}, status=500
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"favorite": favorite,
|
||||
"root_index": index,
|
||||
"path": listed or relative,
|
||||
}
|
||||
)
|
||||
|
||||
def _favorite_listing(self, root: Path, files: list) -> dict[str, str]:
|
||||
"""``{relative key: listing POSIX spelling}`` for one root's scan.
|
||||
|
||||
Built once per scan (the gallery cache hands out the same list until
|
||||
it rescans), so a toggle is a dictionary lookup.
|
||||
"""
|
||||
cache_key = str(root)
|
||||
cached = self._favorite_listing_cache.get(cache_key)
|
||||
if cached is not None and cached[0] is files:
|
||||
return cached[1]
|
||||
listing = {}
|
||||
for path, _mtime in files:
|
||||
posix = path.relative_to(root).as_posix()
|
||||
listing.setdefault(_favorite_relative_key(posix), posix)
|
||||
self._favorite_listing_cache[cache_key] = (files, listing)
|
||||
return listing
|
||||
|
||||
def _workspace_favorites(self, page: list[tuple]) -> set[tuple[int, str]]:
|
||||
"""``(root index, listing path)`` of the favorites among *page* entries."""
|
||||
by_root: dict[tuple[int, Path], dict[str, str]] = {}
|
||||
for path, _mtime, root, index in page:
|
||||
posix = path.relative_to(root).as_posix()
|
||||
by_root.setdefault((index, root), {})[_favorite_relative_key(posix)] = posix
|
||||
try:
|
||||
found = set()
|
||||
for (index, root), keys in by_root.items():
|
||||
for key in self.db.get_image_favorites(
|
||||
_favorite_root_key(root), list(keys)
|
||||
):
|
||||
found.add((index, keys[key]))
|
||||
return found
|
||||
except Exception:
|
||||
# The listing stays usable; stars show as not favorited.
|
||||
self.logger.warning("Image favorites could not be read")
|
||||
return set()
|
||||
|
||||
async def inspect_image(self, request):
|
||||
"""Read prompt evidence for one explicitly rooted physical media file."""
|
||||
roots = list(self._get_all_output_dirs())
|
||||
@@ -1712,7 +2214,7 @@ class ImageRoutesMixin:
|
||||
try:
|
||||
|
||||
def inspect() -> bytes | None:
|
||||
saved, unavailable = None, False
|
||||
saved, unavailable, summary = None, False, None
|
||||
try:
|
||||
saved = self.db.get_image_prompt_info(
|
||||
str(path), False, report_ambiguity=True
|
||||
@@ -1720,6 +2222,13 @@ class ImageRoutesMixin:
|
||||
except Exception:
|
||||
unavailable = True
|
||||
self.logger.warning("Saved inspection display could not be read")
|
||||
if saved and saved.get("association_status") != "ambiguous":
|
||||
try:
|
||||
summary = self.db.get_image_link_summaries([str(path)], False)
|
||||
summary = summary[str(path)]
|
||||
except Exception:
|
||||
# The link stays known; only its title is withheld.
|
||||
self.logger.warning("Inspection link title could not be read")
|
||||
try:
|
||||
observed = inspect_current_file(
|
||||
path,
|
||||
@@ -1731,6 +2240,9 @@ class ImageRoutesMixin:
|
||||
)
|
||||
except CurrentFileChanged:
|
||||
return None
|
||||
observed["library"]["title"] = _verified_title(
|
||||
summary, _positive_title(_inspected_positive(observed))
|
||||
)
|
||||
return encode_inspection_response(
|
||||
image={
|
||||
"root_index": index,
|
||||
@@ -1764,6 +2276,7 @@ class ImageRoutesMixin:
|
||||
)
|
||||
|
||||
def _inspection_library(self, saved: dict | None, *, unavailable: bool) -> dict:
|
||||
"""The inspector's Library facts; the route sets the title afterwards."""
|
||||
result = {
|
||||
"status": "unavailable" if unavailable else "none",
|
||||
"scope": "saved-library",
|
||||
@@ -1785,7 +2298,6 @@ class ImageRoutesMixin:
|
||||
status="linked",
|
||||
positive_id=pair.get("positive_prompt_id"),
|
||||
pair_id=pair.get("id"),
|
||||
title=pair.get("title"),
|
||||
category=pair.get("category"),
|
||||
tags=pair.get("tags"),
|
||||
metadata_scope="pair",
|
||||
@@ -1897,24 +2409,106 @@ class ImageRoutesMixin:
|
||||
if _in_subfolder(str(entry[0].relative_to(entry[2]).parent), subfolder)
|
||||
]
|
||||
|
||||
def _format_workspace_page(self, page: list[tuple]) -> list[dict]:
|
||||
def _workspace_link_summaries(self, paths: list[str]) -> dict[str, dict]:
|
||||
"""Card link summaries, matched exactly as the inspection route does."""
|
||||
try:
|
||||
return self.db.get_image_link_summaries(paths, False)
|
||||
except Exception:
|
||||
# The listing stays usable; cards fall back to their filenames.
|
||||
self.logger.warning("Workspace link summaries could not be read")
|
||||
return {}
|
||||
|
||||
def _card_parse(self, path: Path, records: dict | None) -> tuple:
|
||||
"""One card's ``(seed, positive title)``; ``(None, None)`` on any error.
|
||||
|
||||
One file's unexpected parse failure must not fail the whole page: its
|
||||
card shows no seed and withholds a first-line title.
|
||||
"""
|
||||
try:
|
||||
params = header_params(records)
|
||||
return card_seed(params), _positive_title(parsed_positive(params))
|
||||
except Exception:
|
||||
self.logger.warning(
|
||||
"Workspace card facts could not be parsed for %s",
|
||||
path.name,
|
||||
exc_info=True,
|
||||
)
|
||||
return None, None
|
||||
|
||||
def _read_card_facts(self, path: Path, *, parse: bool) -> _CardFacts | None:
|
||||
"""Read one image's header; ``parse`` adds the seed and positive title.
|
||||
|
||||
None when the file cannot be read at all (corrupt, locked, vanishing).
|
||||
"""
|
||||
try:
|
||||
width, height, records = _card_header(path)
|
||||
except (OSError, ValueError, Image.DecompressionBombError):
|
||||
# A corrupt or disappearing preview must not hide the listing.
|
||||
return None
|
||||
if not parse:
|
||||
return _CardFacts(width, height, None, None)
|
||||
# Parsed after the handle closes, so a concurrent rename or delete
|
||||
# (which Windows refuses on open files) is not held up by it.
|
||||
return _CardFacts(width, height, *self._card_parse(path, records))
|
||||
|
||||
def _card_facts(self, entry: tuple, *, parse: bool) -> _CardFacts:
|
||||
"""One listed image's card facts, read once per ``(path, mtime)``.
|
||||
|
||||
Kept beside the scan's per-file values (``_workspace_listed``) and
|
||||
invalidated the same way: a rescan keeps the facts of unchanged files
|
||||
and drops the rest, and a roots change drops them all. Only files of
|
||||
the current scan are stored, so the cache is bounded by the listing.
|
||||
"""
|
||||
path, mtime, root, index = entry[:4]
|
||||
root_key, key = (index, str(root)), (path, mtime)
|
||||
cached = self._workspace_card_cache.get(root_key, {}).get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
facts = self._read_card_facts(path, parse=parse)
|
||||
if facts is None:
|
||||
# Not stored: a locked or half-written file may read next time.
|
||||
return _NO_CARD_FACTS
|
||||
listed = self._workspace_listed_cache.get(root_key)
|
||||
if parse and listed is not None and key in listed[2]:
|
||||
self._workspace_card_cache.setdefault(root_key, {})[key] = facts
|
||||
return facts
|
||||
|
||||
def _format_workspace_page(
|
||||
self, page: list[tuple], *, linked: bool = True
|
||||
) -> list[dict]:
|
||||
"""Workspace descriptors; ``linked`` adds card facts (link, seed, favorite)."""
|
||||
images = self._format_output_page(page, scoped=True)
|
||||
paths = {
|
||||
(entry[3], entry[0].relative_to(entry[2]).as_posix()): entry[0]
|
||||
entries = {
|
||||
(entry[3], entry[0].relative_to(entry[2]).as_posix()): entry
|
||||
for entry in page
|
||||
}
|
||||
summaries = (
|
||||
self._workspace_link_summaries(
|
||||
[str(entry[0]) for entry in entries.values()]
|
||||
)
|
||||
if linked
|
||||
else {}
|
||||
)
|
||||
for image in images:
|
||||
image["width"] = None
|
||||
image["height"] = None
|
||||
if image["is_video"]:
|
||||
continue
|
||||
try:
|
||||
with paths[(image["root_index"], image["path"])].open("rb") as stream:
|
||||
with Image.open(stream) as source:
|
||||
image["width"], image["height"] = source.size
|
||||
except (OSError, ValueError, Image.DecompressionBombError):
|
||||
# A corrupt or disappearing preview must not hide the listing.
|
||||
entry = entries[(image["root_index"], image["path"])]
|
||||
# Videos: the card reads no video metadata. Library previews show
|
||||
# no card facts, so they skip the parse (``parse=linked``).
|
||||
facts = (
|
||||
_NO_CARD_FACTS
|
||||
if image["is_video"]
|
||||
else self._card_facts(entry, parse=linked)
|
||||
)
|
||||
image["width"], image["height"] = facts.width, facts.height
|
||||
if not linked:
|
||||
continue
|
||||
image["seed"] = facts.seed
|
||||
summary = summaries.get(str(entry[0])) or _unavailable_link()
|
||||
image["linked"] = _card_link(summary, facts.positive_title)
|
||||
if linked:
|
||||
# Image cards carry the star; Library previews show none.
|
||||
favorites = self._workspace_favorites(page)
|
||||
for image in images:
|
||||
image["favorite"] = (image["root_index"], image["path"]) in favorites
|
||||
return images
|
||||
|
||||
def _format_output_page(self, page, *, scoped=False):
|
||||
|
||||
+8
-3
@@ -233,7 +233,9 @@ class LibraryRoutesMixin:
|
||||
self.add_library_negative
|
||||
)
|
||||
|
||||
def _library_media(self, records: list[dict], roots: list[Path]) -> list[dict]:
|
||||
def _library_media(
|
||||
self, records: list[dict], roots: list[Path], *, linked: bool = True
|
||||
) -> list[dict]:
|
||||
page = []
|
||||
for record in records:
|
||||
try:
|
||||
@@ -251,7 +253,7 @@ class LibraryRoutesMixin:
|
||||
break
|
||||
except (OSError, ValueError, RuntimeError):
|
||||
continue
|
||||
return self._format_workspace_page(page)
|
||||
return self._format_workspace_page(page, linked=linked)
|
||||
|
||||
@pair_response
|
||||
async def get_library_options(self, request: web.Request) -> web.Response:
|
||||
@@ -296,7 +298,10 @@ class LibraryRoutesMixin:
|
||||
|
||||
def previews() -> None:
|
||||
for parent in result["items"]:
|
||||
parent["previews"] = self._library_media(records[parent["id"]], roots)
|
||||
# Previews belong to their parent; a per-parent link read is N+1.
|
||||
parent["previews"] = self._library_media(
|
||||
records[parent["id"]], roots, linked=False
|
||||
)
|
||||
|
||||
await self._run_in_executor(previews)
|
||||
return web.json_response({"success": True, **result})
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
// 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 '../../tools/vendor/node_modules/@playwright/test/index.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']);
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
/** 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, caption, grid, cards, panel, library, history,
|
||||
* noPageScroll}. A section is null when its element is absent. Sections whose page PR has not landed yet
|
||||
* (toolbar … history, PRs C–F) are null until that PR adds its measurer here.
|
||||
*/
|
||||
export function inventoryOf(page,scope='main') {
|
||||
return page.evaluate(measureInventory,scope);
|
||||
}
|
||||
|
||||
/** 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)}))}))};
|
||||
}
|
||||
return {chrome:chromeOf(),heading:headingOf(),toolbar:null,filterRow:null,caption:null,grid:null,cards:null,panel:null,
|
||||
library:null,history:null,noPageScroll:document.documentElement.scrollWidth<=innerWidth+1};
|
||||
}
|
||||
@@ -295,7 +295,8 @@ test('collection viewer follows filtered loaded order beyond mounted cards',asyn
|
||||
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');
|
||||
@@ -506,7 +507,7 @@ 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 page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
await selectFile(page,'complete.png');await expect(inspector(page).locator('.generation-facts dd').first()).toHaveText('9007199254740993');
|
||||
if(width===1600)await page.evaluate(()=>scrollTo(0,0));else await inspector(page).locator('.file-cover').evaluate(node=>node.scrollIntoView({block:'start'}));
|
||||
await capture(`inline-${label}`);
|
||||
@@ -576,7 +577,7 @@ test('collection viewer matches approved responsive frame',async({page,request},
|
||||
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);
|
||||
await page.evaluate(theme=>{const preference=document.getElementById('theme');preference.value=theme;preference.dispatchEvent(new Event('change'));},theme);
|
||||
const geometry=await modal.evaluate(node=>{const style=getComputedStyle(node),box=node.getBoundingClientRect(),rail=node.querySelector('.image-preview-details').getBoundingClientRect();return{width:box.width,height:box.height,padding:style.padding,radius:style.borderRadius,rail:rail.width,gap:getComputedStyle(node.querySelector('.image-preview-body')).columnGap};});
|
||||
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`)});
|
||||
|
||||
@@ -18,7 +18,7 @@ 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);
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
const shot=name=>captureCanonicalState(page,testInfo,`production-${name}-${width}-${theme}`,name.startsWith('editor-archived')?'.library-variants':'dialog.library-editor');
|
||||
|
||||
await variants(page).getByRole('button',{name:'Select negative variant 2',exact:true}).click();
|
||||
|
||||
@@ -111,7 +111,7 @@ 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();
|
||||
@@ -122,6 +122,38 @@ test('sender requires explicit destination, previews full changes and acknowledg
|
||||
} 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',exact:true})).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 {
|
||||
|
||||
@@ -14,6 +14,10 @@ from aiohttp import web
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
from tests.browser.workspace_concept_fixture import (
|
||||
MAX_EXTRA_IMAGES as CONCEPT_MAX_EXTRA_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 (
|
||||
@@ -379,6 +383,30 @@ 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 {}
|
||||
if (
|
||||
request.query
|
||||
or not isinstance(data, dict)
|
||||
or set(data) - {"extra_images"}
|
||||
or isinstance(data.get("extra_images", 0), bool)
|
||||
or not isinstance(data.get("extra_images", 0), int)
|
||||
or not 0 <= data.get("extra_images", 0) <= CONCEPT_MAX_EXTRA_IMAGES
|
||||
):
|
||||
raise web.HTTPBadRequest()
|
||||
history_commands.reset()
|
||||
library_editor_commands.reset()
|
||||
history_fixture = None
|
||||
manifest = await api._run_in_executor(
|
||||
lambda: seed_workspace_concept(
|
||||
api, root, extra_images=data.get("extra_images", 0)
|
||||
)
|
||||
)
|
||||
return web.json_response({"success": True, **manifest})
|
||||
|
||||
@routes.post("/__test__/add-nested-image")
|
||||
async def add_nested_image(request: web.Request) -> web.Response:
|
||||
destination = root / "output" / "project" / "day" / "new-output.png"
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,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')||(item.tag==='button'&&item.text.startsWith('Activity'));
|
||||
|
||||
/** 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 three items with `space-between`, so its Theme select sits in the middle; with
|
||||
// Activity removed (D5) the same rule puts Theme at the row's start. Offsets come from the concept's own sizes.
|
||||
const lefts=new Map(shown.map((item,index)=>[item,packedLefts(shown,width,actions.justifyContent,gap)[index]]));
|
||||
return {...actions,height,width,
|
||||
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 stays reserved for the Activity row D5 omits, so the two entries never share one glyph.
|
||||
const OVERVIEW_GLYPH='M3 11 12 4l9 7M5 10v10h14V10M10 20v-6h4v6';
|
||||
|
||||
/** Recorded shell deviations only (spec §8). Everything else in the concept inventory must equal production. */
|
||||
function mapShellConcept(reference) {
|
||||
const {chrome}=reference;
|
||||
const navs=chrome.navs
|
||||
// D28: SAVED VIEWS and COLLECTIONS are proposals with no persisted store, so their navs are not rendered.
|
||||
.filter(nav=>nav.id!=='saved-nav'&&nav.id!=='collections-nav')
|
||||
.map(nav=>{
|
||||
if(nav.id!=='system-nav')return nav;
|
||||
const system=rowNamed(nav,'System');
|
||||
return {...nav,rows:[
|
||||
// D5: no Activity row until an activity center exists. D28: no Design review (prototype page).
|
||||
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: only the WORKSPACE group label remains.
|
||||
labels:chrome.labels.filter(label=>label.text==='WORKSPACE'),
|
||||
navs,
|
||||
connection:{...chrome.connection,
|
||||
// D28: truthful copy instead of "Demo connection · Classic / Nodes 2.0".
|
||||
small:'Your library, on your server',
|
||||
// D28: no fake connection state, so the dot is the neutral muted token (the concept's label color).
|
||||
dot:{...chrome.connection.dot,backgroundColor:chrome.labels[0].color}},
|
||||
// D29: no "Interactive concept" badge. D5: no Activity button. The row's size and the remaining items'
|
||||
// centered offsets follow from the concept's own item sizes.
|
||||
topActions:remainingTopActions(chrome.topActions),
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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 .generation-facts');
|
||||
await expect(facts).toBeVisible();
|
||||
const pairs=await facts.evaluate(list=>Object.fromEntries([...list.querySelectorAll('dt')].map(term=>[term.textContent.trim(),term.nextElementSibling.textContent.trim()])));
|
||||
// app.js:404-408: the concept's facts for alpine_00001.png.
|
||||
expect(pairs).toEqual({Seed:'42001',Steps:'28',CFG:'6.5',Sampler:'dpmpp_2m',Scheduler:'karras',Model:'illustrative-model.safetensors'});
|
||||
// D21 maps execution evidence to "Execution recorded" in PR D; today's panel names the same source this way.
|
||||
await expect(page.locator('#workspace-main').getByText('Execution prompt evidence',{exact:true})).toBeVisible();
|
||||
});
|
||||
|
||||
// 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')];
|
||||
|
||||
@@ -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);}
|
||||
|
||||
@@ -353,7 +353,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();
|
||||
|
||||
@@ -3,6 +3,8 @@ import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/i
|
||||
|
||||
const workspace = '/prompt_manager/workspace#/images';
|
||||
const cards = page => page.locator('article[data-image-key]');
|
||||
// Grid pages only: the sidebar's Images count is its own `limit=1` read of the same listing.
|
||||
const imagePages = url => url.pathname === '/prompt_manager/images/output' && url.search !== '' && url.searchParams.get('limit') !== '1';
|
||||
const details = page => page.getByRole('region', { name: 'Image details', exact: true });
|
||||
|
||||
test.beforeEach(async ({ page, request, browserName }) => {
|
||||
@@ -54,7 +56,7 @@ test('workspace defaults to the native Images collection without a hash route',
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
await expect(page.getByRole('navigation', { name: 'Workspace', exact: true })
|
||||
.getByRole('link', { name: 'Images', exact: true })).toHaveAttribute('aria-current', 'page');
|
||||
.getByRole('link', { name: /^Images(?:\s*[\d,]+)?$/ })).toHaveAttribute('aria-current', 'page');
|
||||
});
|
||||
|
||||
test('image selection is announced on the selected image button', async ({ page }) => {
|
||||
@@ -83,7 +85,7 @@ test('closing details preview restores its trigger focus', async ({ page }) => {
|
||||
test('a short visible batch automatically continues until the sentinel is filled or exhausted', async ({ page, request }) => {
|
||||
const real = await (await request.get('/prompt_manager/images/output?limit=40')).json();
|
||||
let reads = 0;
|
||||
await page.route('**/prompt_manager/images/output?*', async route => {
|
||||
await page.route(imagePages, async route => {
|
||||
const current = reads++;
|
||||
await route.fulfill({ json: {
|
||||
success: true, images: [real.images[current]], total: 2,
|
||||
@@ -435,7 +437,7 @@ test('collection viewer describes video transforms and releases native playback
|
||||
});
|
||||
|
||||
test('failed image requests offer retry while stale results cannot replace a newer route', async ({ page }) => {
|
||||
await page.route('**/prompt_manager/images/output?*', route => route.fulfill({
|
||||
await page.route(imagePages, route => route.fulfill({
|
||||
status: 503, json: { success: false, error: 'Images unavailable' },
|
||||
}), { times: 1 });
|
||||
await page.goto(workspace);
|
||||
@@ -446,7 +448,7 @@ test('failed image requests offer retry while stale results cannot replace a new
|
||||
const pending = new Promise(resolve => { release = resolve; });
|
||||
let arrived;
|
||||
const received = new Promise(resolve => { arrived = resolve; });
|
||||
await page.route('**/prompt_manager/images/output?*', async route => {
|
||||
await page.route(imagePages, async route => {
|
||||
const response = await route.fetch();
|
||||
arrived();
|
||||
await pending;
|
||||
@@ -454,7 +456,7 @@ test('failed image requests offer retry while stale results cannot replace a new
|
||||
});
|
||||
await page.getByRole('searchbox', { name: 'Search filenames', exact: true }).fill('nothing');
|
||||
await received;
|
||||
await page.getByRole('navigation', { name: 'Workspace', exact: true })
|
||||
await page.getByRole('navigation', { name: 'Tools and system', exact: true })
|
||||
.getByRole('link', { name: 'Overview', exact: true }).click();
|
||||
release();
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
|
||||
@@ -492,7 +494,7 @@ async function staleSourceRecovery(page, request, roots) {
|
||||
await page.getByRole('combobox', { name: 'Media', exact: true }).selectOption('image');await page.getByRole('combobox', { name: 'Sort', exact: true }).selectOption('name');
|
||||
const initial = await (await configured).json();await expect(page.locator('.image-toolbar')).toContainText('40 of 80');
|
||||
let oldPage;
|
||||
await page.route('**/prompt_manager/images/output?*', async route => {
|
||||
await page.route(imagePages, async route => {
|
||||
if (new URL(route.request().url()).searchParams.has('cursor') && !oldPage) {
|
||||
const response = await route.fetch();oldPage = { route, data: await response.json() };
|
||||
} else await route.continue();
|
||||
@@ -557,7 +559,7 @@ test('source-change recovery ignores superseded discovery and route disposal', a
|
||||
await recovery.options[0].fulfill({ json: { roots: [{ index: 0, label: 'Obsolete source label' }], subfolders: ['obsolete'] } });
|
||||
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
expect(recovery.pages).toEqual([]);await expect(recovery.source.locator('option')).toHaveText(['All sources']);
|
||||
await page.getByRole('navigation', { name: 'Workspace', exact: true }).getByRole('link', { name: 'Overview', exact: true }).click();
|
||||
await page.getByRole('navigation', { name: 'Tools and system', exact: true }).getByRole('link', { name: 'Overview', exact: true }).click();
|
||||
await recovery.options[1].fulfill({ json: { roots: [{ index: 0, label: 'Late source label' }], subfolders: ['late'] } });
|
||||
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');expect(recovery.pages).toEqual([]);expect(recovery.inspections).toEqual([]);expect(recovery.mutations).toEqual([]);
|
||||
@@ -588,7 +590,7 @@ test('a delayed search cannot replace results for a newer filename query', async
|
||||
const received = new Promise(resolve => { arrived = resolve; });
|
||||
const completed = new Promise(resolve => { complete = resolve; });
|
||||
let delayed = false;
|
||||
await page.route('**/prompt_manager/images/output?*', async route => {
|
||||
await page.route(imagePages, async route => {
|
||||
if (delayed) return route.continue();
|
||||
delayed = true;
|
||||
const response = await route.fetch();
|
||||
@@ -613,7 +615,7 @@ test('selected layout stays blue with readable text on hover in either theme', a
|
||||
await page.goto(workspace);
|
||||
const layout = page.getByRole('group', { name: 'Image layout' }).getByRole('button', { name: 'Masonry', exact: true });
|
||||
for (const theme of ['light', 'dark']) {
|
||||
await page.getByRole('combobox', { name: 'Appearance', exact: true }).selectOption(theme);
|
||||
await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption(theme);
|
||||
await layout.click();
|
||||
await layout.hover();
|
||||
await expect(layout).toHaveCSS('background-color', 'rgb(8, 119, 187)');
|
||||
@@ -672,7 +674,7 @@ test('leaving during Refresh cancels it without replacing the next workspace', a
|
||||
const requested = page.waitForRequest('**/prompt_manager/gallery/rescan');
|
||||
await page.getByRole('button', { name: 'Refresh', exact: true }).click();
|
||||
await requested;
|
||||
await page.getByRole('navigation', { name: 'Workspace', exact: true })
|
||||
await page.getByRole('navigation', { name: 'Tools and system', exact: true })
|
||||
.getByRole('link', { name: 'Overview', exact: true }).click();
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
|
||||
release();
|
||||
@@ -686,7 +688,7 @@ test('Images uses approved geometry while keeping real controls', async ({page,r
|
||||
await page.setViewportSize({width:1600,height:1100});
|
||||
await page.goto(workspace);
|
||||
await expect(cards(page)).toHaveCount(24);
|
||||
await expect(page.locator('.workspace-heading-copy > .intro')).toHaveCount(1);
|
||||
await expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('Browse your images, explore the details, keep your creative context.');
|
||||
await expect(details(page)).toHaveCSS('width','310px');
|
||||
await expect(page.locator('.image-workspace')).toHaveCSS('column-gap','22px');
|
||||
for (const label of ['Source','Folder','Media','Sort','Thumbnail size']) await expect(page.getByRole('combobox',{name:label,exact:true})).toBeVisible();
|
||||
|
||||
@@ -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,7 +433,7 @@ 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');
|
||||
@@ -866,3 +866,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/images.css','js/workspace/library.css','js/workspace/inspector.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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -129,7 +129,7 @@ test('returning from history restores library context and mobile content stays i
|
||||
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 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);
|
||||
});
|
||||
|
||||
@@ -140,9 +140,9 @@ test('empty shelf visits restore saved scroll without inventing a parent anchor'
|
||||
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 page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name: /^Prompt library(?:\s*[\d,]+)?$/}).evaluate(node=>node.click());
|
||||
await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('No matching saved positive');
|
||||
await expect(cards(page)).toHaveCount(0);
|
||||
await expect.poll(()=>page.evaluate(expected=>Math.abs(scrollY-expected),position)).toBeLessThanOrEqual(1);
|
||||
@@ -188,7 +188,7 @@ test('history shares real image metadata, vendored Viewer and the acknowledged p
|
||||
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();
|
||||
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'});
|
||||
@@ -203,7 +203,7 @@ test('history shares real image metadata, vendored Viewer and the acknowledged p
|
||||
await expect(inspector.locator('summary')).toHaveText(['File & provenance','Workflow & raw metadata']);
|
||||
await inspector.getByRole('button',{name:'Open full preview',exact:true}).click();await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible();
|
||||
await page.keyboard.press('Escape');await expect(page.locator('dialog[open]')).toHaveCount(0);
|
||||
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Overview',exact:true}).click();await expect(page.locator('.viewer-container')).toHaveCount(0);
|
||||
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();}
|
||||
});
|
||||
|
||||
@@ -279,10 +279,10 @@ test('Library natural rows retain long prompt content without overflow',async({p
|
||||
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 page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
await expect(page.locator('.prompt-positive h3').first()).toHaveCSS('color',theme==='dark'?'rgb(115, 221, 169)':'rgb(23, 107, 70)');
|
||||
}
|
||||
await expect(page.locator('.library-card-art').first()).toHaveCSS('height',width===1600?'170px':'150px');
|
||||
|
||||
@@ -8,14 +8,14 @@ import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/i
|
||||
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 = ['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', '.image-card', '.image-details', '.prompt-block-heading', '.library-toolbar', '.library-grid', '.library-card', '.library-card-art', '.library-card-content'].map(selector => {
|
||||
const node = document.querySelector(selector);
|
||||
if (!node) return [selector, null];
|
||||
const box = node.getBoundingClientRect(), style = getComputedStyle(node);
|
||||
@@ -92,7 +92,7 @@ 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 });
|
||||
@@ -104,7 +104,7 @@ for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of ['
|
||||
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();
|
||||
// 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('#workspace-main > .heading button.send')).toBeEnabled();
|
||||
await expect(page.locator('.library-shared-gallery article[data-image-key]')).toHaveCount(2);
|
||||
await page.locator('.library-shared-gallery .image-open').first().click();
|
||||
await expect(page.getByRole('region', { name: 'Image details', exact: true })).toContainText('canonical-alt.png');
|
||||
@@ -197,26 +197,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,8 +236,8 @@ 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);
|
||||
@@ -257,17 +257,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 +277,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();
|
||||
@@ -514,15 +514,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 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('One positive prompt. Reusable negative variants. 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 +533,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', '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 +549,12 @@ 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, not the concept's `activity` pulse ('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);
|
||||
});
|
||||
|
||||
|
||||
@@ -582,7 +584,7 @@ test('Images visual layouts retain real delayed media long filenames and loaded
|
||||
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.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
await page.locator('.image-card .image-open').first().click();
|
||||
await expect(page.locator('.image-details h2')).toHaveText('study-01.png');
|
||||
for (const layout of ['Grid','List','Masonry']) {
|
||||
@@ -618,7 +620,7 @@ test('embedded history gallery keeps the history heading and disposes its layout
|
||||
for (const layout of ['List','Masonry','Grid']) {
|
||||
await page.getByRole('button',{name:layout,exact:true}).click();await orderedMedia(page);
|
||||
await expect(page.locator('h1')).toHaveText(title);
|
||||
await expect(page.locator('.workspace-heading-copy > .intro')).toHaveCount(0);
|
||||
await expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('One positive prompt. Reusable negative variants. One shared gallery.');
|
||||
}
|
||||
await attachEvidence(page,testInfo,`after-images-embedded-history-${width}`,false);
|
||||
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
|
||||
|
||||
@@ -43,8 +43,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);
|
||||
@@ -89,7 +89,7 @@ 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});
|
||||
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 +127,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 +150,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 +162,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 +177,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 +195,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});
|
||||
});
|
||||
@@ -482,7 +484,8 @@ 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});
|
||||
// Concept nav links end in their real `.nav-count` once it loads.
|
||||
const nav=(page,name)=>page.locator('aside.sidebar nav').getByRole('link',{name:new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')}(?:\\s*[\\d,]+)?$`)});
|
||||
const chips=(page,name)=>main(page).locator('article[data-prompt-id] .library-chip').filter({hasText:new RegExp(`^${name}$`)});
|
||||
/** 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) {
|
||||
@@ -666,10 +669,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 +794,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/images.css','js/workspace/library.css','js/workspace/inspector.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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -4,6 +4,11 @@ import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/i
|
||||
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'],
|
||||
@@ -13,12 +18,12 @@ const routes = [
|
||||
['system', 'System', '/prompt_manager/admin'],
|
||||
];
|
||||
|
||||
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);
|
||||
};
|
||||
});
|
||||
@@ -66,8 +71,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();
|
||||
@@ -81,11 +85,11 @@ test('all shell destinations deep-link, reload and expose working existing appli
|
||||
test('keyboard navigation, browser history and unknown routes remain usable', async ({ page }) => {
|
||||
await page.goto(`${workspace}#/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 +97,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 +105,144 @@ 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, D5): the concept topbar (index.html 45-63, style.css 225-277) without the prototype badge or Activity.
|
||||
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: ['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/ })).toHaveCount(0);
|
||||
await expect(page.locator('.concept-badge, #activity-open')).toHaveCount(0);
|
||||
await page.locator('nav [data-route="library"]').click();
|
||||
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Prompt library');
|
||||
await expect(topbar.locator('.top-actions > *')).toHaveCount(2);
|
||||
});
|
||||
|
||||
// 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,
|
||||
labelLeft: box(node.querySelector('.theme-label')).left - bar.left,
|
||||
linkRight: bar.right - box(node.querySelector('.canvas-link')).right,
|
||||
};
|
||||
});
|
||||
expect(layout).toMatchObject({ padding: '14px', actions: { justify: 'space-between', gap: '8px' }, label: '0px', select: '11px' });
|
||||
expect(Math.abs(layout.actions.width - layout.expected)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(layout.labelLeft - 14)).toBeLessThanOrEqual(1);
|
||||
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 +274,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 +299,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('#primary-nav')).toHaveCount(1);
|
||||
await expect(page.locator('#system-nav')).toHaveCount(1);
|
||||
await expect(page.getByRole('combobox', { name: 'Theme', exact: true })).toHaveCount(1);
|
||||
await expect(page.locator('#workspace-main a[href="/prompt_manager/gallery"]')).toBeVisible();
|
||||
await countAppearanceWrites(page);
|
||||
await page.getByRole('combobox', { name: 'Appearance', exact: true }).selectOption('light');
|
||||
expect(await page.evaluate(() => window.__appearanceChanges)).toBe(1);
|
||||
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 +329,384 @@ 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-bottom']);
|
||||
await expect(sidebar.locator('.brand')).toHaveAttribute('href', '#/images');
|
||||
await expect(sidebar.locator('.sidebar-label')).toHaveText(['WORKSPACE']);
|
||||
await expect(page.locator('#saved-nav, #collections-nav, .new-label')).toHaveCount(0);
|
||||
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(2);
|
||||
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 System and Overview with a neutral connection', async ({ page }) => {
|
||||
await page.goto(`${workspace}#/system`);
|
||||
const bottom = page.locator('aside.sidebar > .sidebar-bottom');
|
||||
expect(await childShape(bottom)).toEqual(['nav#system-nav', 'div.connection']);
|
||||
const system = page.locator('nav#system-nav');
|
||||
await expect(system).toHaveAttribute('aria-label', 'Tools and system');
|
||||
expect(await system.locator(':scope > a').evaluateAll(nodes => nodes.map(node => [node.getAttribute('href'), node.textContent]))).toEqual([
|
||||
['#/system', 'System'], ['#/overview', 'Overview'],
|
||||
]);
|
||||
await expect(system.locator('.nav-count')).toHaveCount(0);
|
||||
await expect(system.locator('a[href="#/system"]')).toHaveAttribute('aria-current', 'page');
|
||||
await expect(system.locator('svg.icon')).toHaveCount(2);
|
||||
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);
|
||||
const response = await route.fetch({ url: url.href });
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
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,423 @@
|
||||
"""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
|
||||
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",
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
"prompt_pairs",
|
||||
"generated_images",
|
||||
"prompt_tags",
|
||||
"tags",
|
||||
"prompt_library_state",
|
||||
"prompts",
|
||||
)
|
||||
|
||||
|
||||
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 seed_workspace_concept(
|
||||
api: PromptManagerAPI, root: Path, *, extra_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.
|
||||
"""
|
||||
if isinstance(extra_images, bool) or not isinstance(extra_images, int):
|
||||
raise TypeError("extra_images must be an integer")
|
||||
if not 0 <= extra_images <= MAX_EXTRA_IMAGES:
|
||||
raise ValueError("extra_images is out of range")
|
||||
_clear(api, root)
|
||||
db = api.db
|
||||
epoch = db.get_execution_epoch()
|
||||
prompts = []
|
||||
for prompt in 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.append(
|
||||
{"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"))
|
||||
total = len(CONCEPT_PROMPTS) * CONCEPT_IMAGE_ROUNDS + extra_images
|
||||
rounds = math.ceil(total / len(CONCEPT_PROMPTS))
|
||||
now = time.time()
|
||||
images, runs = [], []
|
||||
for round_index in range(rounds):
|
||||
for prompt_index, prompt in enumerate(CONCEPT_PROMPTS):
|
||||
index = round_index * len(CONCEPT_PROMPTS) + prompt_index
|
||||
if index >= total:
|
||||
break
|
||||
name = concept_filename(prompt, round_index)
|
||||
path = folder / name
|
||||
facts = write_concept_png(path, prompt_index, round_index)
|
||||
variant, negative, seed = facts["variant"], facts["negative"], facts["seed"]
|
||||
# The concept lists images newest first in this order.
|
||||
stamp = now - 60 - index * 60
|
||||
os.utime(path, (stamp, stamp))
|
||||
owner = prompts[prompt_index]
|
||||
image_id = db.link_image_to_prompt(owner["positive_id"], str(path))
|
||||
pair_id = owner["pair_ids"][variant]
|
||||
db.link_image_to_pair(pair_id, image_id, {"source": "concept fixture"})
|
||||
context = RecipeContext(
|
||||
"3", _snapshot(prompt.positive), _snapshot(negative), "inferred"
|
||||
)
|
||||
reference = OutputReference(
|
||||
"4", 0, "output", "concepts", name, "posix", roots.output_key
|
||||
)
|
||||
runs.append(
|
||||
_record_run(
|
||||
api,
|
||||
epoch=epoch,
|
||||
item=index,
|
||||
context=context,
|
||||
output=reference,
|
||||
observed_at=int(stamp * 1000),
|
||||
)
|
||||
)
|
||||
images.append(
|
||||
{
|
||||
"filename": name,
|
||||
"image_id": image_id,
|
||||
"pair_id": pair_id,
|
||||
"prompt": prompt.id,
|
||||
"variant": variant,
|
||||
"seed": seed,
|
||||
"width": facts["width"],
|
||||
"height": facts["height"],
|
||||
}
|
||||
)
|
||||
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,
|
||||
"prompts": prompts,
|
||||
"images": images,
|
||||
"runs": runs,
|
||||
"tags": tags,
|
||||
}
|
||||
@@ -28,7 +28,7 @@ test('native library sends the selected saved pair into a real ComfyUI destinati
|
||||
await history.getByRole('button',{name:'Select negative variant 1',exact:true}).click();
|
||||
await expect(history.getByRole('region',{name:'Saved prompt texts',exact:true}).getByRole('region',{name:'Negative prompt',exact:true})).toContainText(negative);
|
||||
// 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,7 +60,7 @@ 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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
+17
File diff suppressed because one or more lines are too long
@@ -244,6 +244,14 @@ def _workflow_only():
|
||||
}
|
||||
|
||||
|
||||
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"
|
||||
@@ -440,5 +448,14 @@ CASES = {
|
||||
"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,39 @@
|
||||
// The seed the workspace inspector shows for encoded inspection responses, through
|
||||
// its own path: projectInspectionPayload with the server facts and the legacy
|
||||
// parser fill (comfy-metadata.js, passed explicitly; Node has no window global).
|
||||
// Used by tests/test_api_workspace_images.py to compare card and inspector seeds.
|
||||
// stdin: {listing, inspections: [{name, body}]}; each body is the exact response
|
||||
// JSON text, and listing (optional) is the exact workspace listing response text.
|
||||
// stdout: {inspector: {name: {status, value}}, card: {path: String(seed) | null}}.
|
||||
// Inspector status is 'error' where the page cannot project the record. The card
|
||||
// values come from JSON.parse of the listing, as the browser reads them, so a
|
||||
// seed sent as a JSON number past 2**53 shows up rounded here.
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const web = path.join(here, '..', '..', '..', 'web', 'js');
|
||||
const require = createRequire(import.meta.url);
|
||||
const parser = require(path.join(web, 'comfy-metadata.js'));
|
||||
const { projectInspectionPayload } = await import(pathToFileURL(path.join(web, 'workspace', 'inspection-state.mjs')).href);
|
||||
|
||||
const chunks = [];
|
||||
for await (const chunk of process.stdin) chunks.push(chunk);
|
||||
const { listing = null, inspections } = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
||||
const inspector = {};
|
||||
for (const { name, body } of inspections) {
|
||||
const identity = { kind: 'rooted-file', key: `0:${name}`, root_index: 0, path: name, root_scope: null };
|
||||
try {
|
||||
const record = projectInspectionPayload(JSON.parse(body), identity, null, parser.extractGenerationFacts, false, parser);
|
||||
inspector[name] = { status: record.generation.seed.status, value: record.generation.seed.value };
|
||||
} catch (error) {
|
||||
// The page shows no record here, so no seed either.
|
||||
inspector[name] = { status: 'error', value: String(error && error.name) };
|
||||
}
|
||||
}
|
||||
const card = {};
|
||||
for (const item of listing === null ? [] : JSON.parse(listing).images) {
|
||||
card[item.path] = item.seed === null || item.seed === undefined ? null : String(item.seed);
|
||||
}
|
||||
process.stdout.write(JSON.stringify({ inspector, card }));
|
||||
@@ -0,0 +1,15 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const { libraryTitle } = await import('../../web/js/workspace/library.mjs').catch(() => ({}));
|
||||
|
||||
test('library title counts UTF-16 units and never splits a surrogate pair', () => {
|
||||
assert.equal(typeof libraryTitle, 'function', 'libraryTitle is exported');
|
||||
assert.equal(libraryTitle(' short line \nsecond'), 'short line');
|
||||
assert.equal(libraryTitle('a'.repeat(80)), 'a'.repeat(80));
|
||||
assert.equal(libraryTitle('a'.repeat(81)), `${'a'.repeat(80)}…`);
|
||||
// 80 emoji are 160 UTF-16 units: 40 fit.
|
||||
assert.equal(libraryTitle('😀'.repeat(80)), `${'😀'.repeat(40)}…`);
|
||||
// 79 units then an emoji: the emoji would straddle the limit, so it is dropped whole.
|
||||
assert.equal(libraryTitle(`${'a'.repeat(79)}😀b`), `${'a'.repeat(79)}…`);
|
||||
});
|
||||
@@ -76,3 +76,74 @@ test('positive library deep links preserve the selected identity without duplica
|
||||
win.location.hash='#/library';win.dispatchEvent(new Event('hashchange'));
|
||||
assert.deepEqual(seen,[['library',12],['library',13],['library',undefined]]);dispose();
|
||||
});
|
||||
|
||||
test('nav counts reject negative, non-integer and missing totals', async () => {
|
||||
const { summarizeNavCounts } = await source;
|
||||
const counts = summarizeNavCounts({images: {total: 24}, library: {total: 0}, tags: {total: 7}});
|
||||
assert.ok(counts instanceof Map);
|
||||
assert.deepEqual([...counts], [['images', 24], ['library', 0], ['tags', 7]]);
|
||||
assert.equal(counts.has('inspect'), false, 'Inspect a file has no count');
|
||||
for (const invalid of [undefined, null, {}, {total: -1}, {total: 1.5}, {total: '3'}, {total: NaN},
|
||||
{total: Infinity}, {total: Number.MAX_SAFE_INTEGER + 1}, {total: null}, [], 'x']) {
|
||||
const result = summarizeNavCounts({images: invalid, library: {total: 2}, tags: invalid});
|
||||
assert.equal(result.get('images'), null, JSON.stringify(invalid));
|
||||
assert.equal(result.get('library'), 2);
|
||||
assert.equal(result.get('tags'), null);
|
||||
}
|
||||
assert.deepEqual([...summarizeNavCounts()], [['images', null], ['library', null], ['tags', null]]);
|
||||
});
|
||||
|
||||
test('nav counts settle independently so a slow images read never delays the others', async () => {
|
||||
const { loadNavCounts } = await source;
|
||||
let releaseImages;
|
||||
const images = new Promise(resolve => { releaseImages = resolve; });
|
||||
const api = {get: async url => {
|
||||
if (url.startsWith('/prompt_manager/images')) return images;
|
||||
if (url.startsWith('/prompt_manager/library')) return {success: true, total: 3};
|
||||
throw new Error('Unavailable');
|
||||
}};
|
||||
const settled = [];
|
||||
const done = loadNavCounts(api, new AbortController().signal, (id, count) => settled.push([id, count]));
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
assert.deepEqual(settled.sort(), [['library', 3], ['tags', null]]);
|
||||
releaseImages({success: true, total: 40});
|
||||
const counts = await done;
|
||||
assert.deepEqual(settled.at(-1), ['images', 40]);
|
||||
assert.deepEqual([...counts], [['images', 40], ['library', 3], ['tags', null]]);
|
||||
});
|
||||
|
||||
test('nav counts issue three bounded requests and drop only the failed count', async () => {
|
||||
const { loadNavCounts } = await source;
|
||||
const calls = [];
|
||||
const signal = new AbortController().signal;
|
||||
const api = {get: async (url, options) => {
|
||||
calls.push([url, options.signal]);
|
||||
if (url.startsWith('/prompt_manager/library')) throw new Error('Unavailable');
|
||||
if (url.startsWith('/prompt_manager/vocabulary/tags')) return {success: true, total: 5};
|
||||
return {success: true, total: 12};
|
||||
}};
|
||||
const counts = await loadNavCounts(api, signal);
|
||||
assert.deepEqual([...counts], [['images', 12], ['library', null], ['tags', 5]]);
|
||||
const urls = calls.map(([url]) => new URL(url, 'http://host'));
|
||||
assert.deepEqual(urls.map(url => url.pathname),
|
||||
['/prompt_manager/images/output', '/prompt_manager/library', '/prompt_manager/vocabulary/tags']);
|
||||
for (const url of urls) assert.equal(url.searchParams.get('limit'), '1');
|
||||
assert.equal(urls[0].searchParams.get('mode'), 'workspace');
|
||||
assert.ok(calls.every(([, used]) => used === signal));
|
||||
});
|
||||
|
||||
test('a Library or vocabulary write re-reads only the Library and Tags counts', async () => {
|
||||
const { loadNavCounts, LIBRARY_WRITE_COUNTS } = await source;
|
||||
assert.deepEqual([...LIBRARY_WRITE_COUNTS], ['library', 'tags']);
|
||||
assert.ok(Object.isFrozen(LIBRARY_WRITE_COUNTS));
|
||||
const calls = [];
|
||||
const api = {get: async url => {
|
||||
calls.push(new URL(url, 'http://host').pathname);
|
||||
return {success: true, total: url.startsWith('/prompt_manager/library') ? 4 : 9};
|
||||
}};
|
||||
const settled = [];
|
||||
const counts = await loadNavCounts(api, new AbortController().signal, (id, count) => settled.push([id, count]), LIBRARY_WRITE_COUNTS);
|
||||
assert.deepEqual(calls, ['/prompt_manager/library', '/prompt_manager/vocabulary/tags']);
|
||||
assert.deepEqual(settled.sort(), [['library', 4], ['tags', 9]]);
|
||||
assert.deepEqual([...counts], [['library', 4], ['tags', 9]]);
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@ function fixture({ dark = false, preference, fetchImpl, storageDenied = false, p
|
||||
const response = value => ({ ok: true, json: async () => value });
|
||||
const tick = () => new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
test('follow uses host palette over OS and preserves reverse proxy prefix', async () => {
|
||||
test('system uses host palette over OS and preserves reverse proxy prefix', async () => {
|
||||
const f = fixture({ pathname: '/proxy/comfy/prompt_manager/workspace' });
|
||||
await f.theme.ready;
|
||||
assert.equal(f.root.dataset.caTheme, 'dark');
|
||||
@@ -41,13 +41,14 @@ test('supported light palettes accept string and wrapped values', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('explicit overrides persist and follow restores host choice', async () => {
|
||||
test('explicit overrides persist and system restores host choice', async () => {
|
||||
const f = fixture({ preference: 'light' }); await f.theme.ready;
|
||||
assert.equal(f.root.dataset.caTheme, 'light');
|
||||
f.theme.setPreference('dark'); assert.equal(f.root.dataset.caTheme, 'dark');
|
||||
assert.equal(f.saved.get('pm_workspace_theme'), 'dark');
|
||||
f.theme.setPreference('follow'); assert.equal(f.root.dataset.caTheme, 'dark');
|
||||
assert.equal(f.saved.get('pm_workspace_theme'), 'follow');
|
||||
f.theme.setPreference('system'); assert.equal(f.root.dataset.caTheme, 'dark');
|
||||
assert.equal(f.saved.get('pm_workspace_theme'), 'system');
|
||||
assert.throws(() => f.theme.setPreference('follow'), /preference/i);
|
||||
assert.throws(() => f.theme.setPreference('orange'), /preference/i);
|
||||
f.scope.dispose();
|
||||
});
|
||||
@@ -132,7 +133,7 @@ test('blocked localStorage getter and missing proxy marker stay usable', async (
|
||||
let url;
|
||||
const theme = mountWorkspaceTheme({ root: f.root, win: f.win, scope,
|
||||
fetchImpl: async value => { url = value; return response(' Obsidian '); } });
|
||||
assert.equal(theme.preference, 'follow');
|
||||
assert.equal(theme.preference, 'system');
|
||||
await theme.ready;
|
||||
assert.equal(url, '/api/settings/Comfy.ColorPalette');
|
||||
assert.equal(f.root.dataset.caTheme, 'dark');
|
||||
@@ -140,3 +141,18 @@ test('blocked localStorage getter and missing proxy marker stay usable', async (
|
||||
assert.equal(theme.preference, 'light');
|
||||
scope.dispose();
|
||||
});
|
||||
|
||||
test('stored follow migrates to system and system follows the host palette', async () => {
|
||||
const f = fixture({ preference: 'follow', fetchImpl: async () => response('light') });
|
||||
assert.equal(f.theme.preference, 'system');
|
||||
await f.theme.ready;
|
||||
assert.equal(f.root.dataset.caTheme, 'light');
|
||||
f.media.matches = true; f.media.dispatchEvent(new Event('change'));
|
||||
assert.equal(f.root.dataset.caTheme, 'light', 'a known host palette wins over the OS');
|
||||
f.scope.dispose();
|
||||
const unknown = fixture({ preference: 'follow', dark: true, fetchImpl: async () => response('custom') });
|
||||
await unknown.theme.ready;
|
||||
assert.equal(unknown.theme.preference, 'system');
|
||||
assert.equal(unknown.root.dataset.caTheme, 'dark', 'an unknown host palette falls back to the OS');
|
||||
unknown.scope.dispose();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { canvasRoot, canvasHref, bindCanvasLink } from '../../web/js/workspace/topbar.mjs';
|
||||
|
||||
// The shared mount-aware resolver (#164), as api-client.js implements it.
|
||||
const resolverAt = pathname => url => {
|
||||
if (!url.startsWith('/prompt_manager/')) return url;
|
||||
const match = pathname.match(/^(.*)\/prompt_manager(?:\/|$)/);
|
||||
return match && match[1] ? match[1] + url : url;
|
||||
};
|
||||
|
||||
function link() {
|
||||
const node = new EventTarget();
|
||||
node.href = '';
|
||||
return node;
|
||||
}
|
||||
function click(node, init = {}) {
|
||||
const event = new Event('click', { cancelable: true });
|
||||
Object.assign(event, { button: 0, metaKey: false, ctrlKey: false, shiftKey: false, altKey: false, ...init });
|
||||
node.dispatchEvent(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
test('the canvas root is the ComfyUI root under the page mount', () => {
|
||||
assert.equal(canvasRoot(resolverAt('/prompt_manager/workspace')), '/');
|
||||
assert.equal(canvasRoot(resolverAt('/proxy/comfy/prompt_manager/workspace')), '/proxy/comfy/');
|
||||
assert.throws(() => canvasRoot(() => 'https://elsewhere.example/'), /canvas/i);
|
||||
});
|
||||
|
||||
test('the canvas root follows a non-standard proxy mount', () => {
|
||||
assert.equal(canvasRoot(resolverAt('/ComfyBackendDirect/prompt_manager/workspace')), '/ComfyBackendDirect/');
|
||||
assert.equal(canvasHref(resolverAt('/ComfyBackendDirect/prompt_manager/workspace'), '/ComfyBackendDirect/prompt_manager/workspace'),
|
||||
'/ComfyBackendDirect/');
|
||||
});
|
||||
|
||||
// A4 review: a resolver failure must never stop the shell from mounting, and the fallback stays on the page's mount.
|
||||
test('a resolver failure falls back to the page mount and never to another origin', () => {
|
||||
const warn = console.warn;
|
||||
const warnings = [];
|
||||
console.warn = (...args) => warnings.push(args);
|
||||
try {
|
||||
const broken = () => { throw new Error('resolver unavailable'); };
|
||||
assert.equal(canvasHref(broken, '/ComfyBackendDirect/prompt_manager/workspace'), '/ComfyBackendDirect/');
|
||||
assert.equal(canvasHref(broken, '/ComfyBackendDirect/prompt_manager/workspace/'), '/ComfyBackendDirect/');
|
||||
assert.equal(canvasHref(broken, '/prompt_manager/workspace'), '/');
|
||||
assert.equal(canvasHref(() => 'https://elsewhere.example/prompt_manager/', '/a/prompt_manager/workspace'), '/a/');
|
||||
assert.equal(canvasHref(broken, '//elsewhere.example/prompt_manager/workspace'), '/');
|
||||
assert.equal(canvasHref(broken, '/workspace'), '/');
|
||||
assert.equal(canvasHref(broken, undefined), '/');
|
||||
assert.equal(canvasHref(undefined, '/a/prompt_manager/workspace'), '/a/');
|
||||
assert.equal(warnings.length, 8);
|
||||
} finally {
|
||||
console.warn = warn;
|
||||
}
|
||||
});
|
||||
|
||||
test('ComfyUI link follows its href unless a resolved pair is selected', () => {
|
||||
const node = link();
|
||||
const opened = [];
|
||||
let pair = null;
|
||||
const sender = { open: (roles, trigger) => opened.push([roles, trigger]) };
|
||||
const unbind = bindCanvasLink(node, { getSelectedPair: () => pair, sender, href: '/proxy/' });
|
||||
assert.equal(node.href, '/proxy/');
|
||||
assert.equal(click(node).defaultPrevented, false);
|
||||
pair = { positive: 'a castle', negative: '' };
|
||||
const event = click(node);
|
||||
assert.equal(event.defaultPrevented, true);
|
||||
assert.deepEqual(opened, [[{ positive: 'a castle', negative: '' }, node]]);
|
||||
// A modified or secondary click keeps the browser's link behavior.
|
||||
for (const init of [{ metaKey: true }, { ctrlKey: true }, { shiftKey: true }, { altKey: true }, { button: 1 }]) {
|
||||
assert.equal(click(node, init).defaultPrevented, false);
|
||||
}
|
||||
// One role alone is not a pair.
|
||||
pair = { positive: 'a castle' };
|
||||
assert.equal(click(node).defaultPrevented, false);
|
||||
assert.equal(opened.length, 1);
|
||||
unbind();
|
||||
pair = { positive: 'a castle', negative: '' };
|
||||
assert.equal(click(node).defaultPrevented, false);
|
||||
assert.equal(opened.length, 1);
|
||||
});
|
||||
|
||||
test('ComfyUI link without a pair source is a plain link', () => {
|
||||
const node = link();
|
||||
const unbind = bindCanvasLink(node, { href: '/' });
|
||||
assert.equal(click(node).defaultPrevented, false);
|
||||
unbind();
|
||||
});
|
||||
@@ -188,6 +188,10 @@ class TestLibraryAPI(ImageAPITestCase):
|
||||
async def test_maximum_parent_page_uses_bounded_sql_queries_for_previews(self):
|
||||
for index in range(200):
|
||||
positive = self._save_prompt(f"Parent {index}", tags=[f"tag {index}"])
|
||||
if index % 3 == 0:
|
||||
self.api.db.save_prompt_pair(f"Parent {index}", f"Negative {index}")
|
||||
if index % 4 == 0:
|
||||
self.api.db.record_prompt_use(positive, times=index + 1)
|
||||
make_png(self.output_dir / f"{index}.png")
|
||||
self.api.db.link_image_to_prompt(
|
||||
positive, str(self.output_dir / f"{index}.png")
|
||||
@@ -232,6 +236,32 @@ class TestLibraryAPI(ImageAPITestCase):
|
||||
for item in result["items"]
|
||||
)
|
||||
)
|
||||
# Usage fields and the variant total ride on the same five SELECTs.
|
||||
self.assertEqual(result["variant_total"], 67)
|
||||
self.assertEqual(
|
||||
result["variant_total"],
|
||||
sum(item["variant_count"] for item in result["items"]),
|
||||
)
|
||||
used = {
|
||||
int(item["text"].split()[-1]): item
|
||||
for item in result["items"]
|
||||
if item["last_used_at"] is not None
|
||||
}
|
||||
self.assertEqual(sorted(used), list(range(0, 200, 4)))
|
||||
self.assertTrue(
|
||||
all(
|
||||
item["run_count"] == index + 1
|
||||
and item["last_used_at"].endswith("+00:00")
|
||||
for index, item in used.items()
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
all(
|
||||
type(item["run_count"]) is int
|
||||
for item in result["items"]
|
||||
if item["last_used_at"] is None
|
||||
)
|
||||
)
|
||||
|
||||
async def test_long_name_cursor_stays_small_and_preserves_full_sorting(self):
|
||||
prefix = "猫" * 10_000
|
||||
@@ -438,6 +468,38 @@ class TestLibraryAPI(ImageAPITestCase):
|
||||
)
|
||||
return response, await response.json()
|
||||
|
||||
async def test_unconvertible_last_use_does_not_fail_list_detail_or_receipt(self):
|
||||
positive = self._save_prompt("Cat")
|
||||
other = self._save_prompt("Dog")
|
||||
with self.api.db.model.get_connection() as connection:
|
||||
connection.execute(
|
||||
"UPDATE prompts SET last_used_at=? WHERE id=?",
|
||||
("9999-12-31T23:59:59-01:00", positive),
|
||||
)
|
||||
connection.execute(
|
||||
"UPDATE prompts SET last_used_at=? WHERE id=?",
|
||||
("0001-01-01T00:00:00+01:00", other),
|
||||
)
|
||||
response, listed = await self.get()
|
||||
self.assertEqual(response.status, 200, listed)
|
||||
self.assertEqual(
|
||||
[item["last_used_at"] for item in listed["items"]], [None, None]
|
||||
)
|
||||
response, detail = await self.get(f"/{positive}")
|
||||
self.assertEqual(response.status, 200, detail)
|
||||
self.assertIsNone(detail["positive"]["last_used_at"])
|
||||
response, receipt = await self.patch_parent(
|
||||
positive,
|
||||
{
|
||||
"expected_epoch": detail["library_epoch"],
|
||||
"if_match": detail["positive"]["edit_token"],
|
||||
"metadata": {"title": "Portrait"},
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status, 200, receipt)
|
||||
self.assertEqual(receipt["positive"]["title"], "Portrait")
|
||||
self.assertIsNone(receipt["positive"]["last_used_at"])
|
||||
|
||||
async def test_native_parent_patch_strict_validation(self):
|
||||
from database.execution_history_schema import rotate_history_epoch
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,236 @@
|
||||
"""Card facts from header records: the shared legacy parser, bounded and exact."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from utils.card_seed import ( # noqa: E402
|
||||
MAX_RECORD_BYTES,
|
||||
card_seed,
|
||||
header_params,
|
||||
parsed_positive,
|
||||
)
|
||||
from utils.generation_params import ( # noqa: E402
|
||||
GenerationParamsError,
|
||||
extract_from_records,
|
||||
extract_generation_params,
|
||||
)
|
||||
from utils.inspection_response import ( # noqa: E402
|
||||
MAX_PROJECTION_DEPTH,
|
||||
project_graph,
|
||||
)
|
||||
|
||||
try:
|
||||
from tests.generation_params_cases import CASES, encoder, ksampler # noqa: E402
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from generation_params_cases import CASES, encoder, ksampler # noqa: E402
|
||||
|
||||
|
||||
def graph(*samplers, extra=None):
|
||||
nodes = {"6": encoder("a lantern at dusk"), "7": encoder("blurry")}
|
||||
for index, sampler in enumerate(samplers):
|
||||
nodes[str(10 + index)] = sampler
|
||||
nodes.update(extra or {})
|
||||
return {"prompt": json.dumps(nodes)}
|
||||
|
||||
|
||||
def seeded(seed, **settings):
|
||||
return ksampler(("6", 0), ("7", 0), seed=seed, **settings)
|
||||
|
||||
|
||||
def nested_workflow(depth=900):
|
||||
"""A small workflow whose node and link ids nest ``depth`` arrays deep."""
|
||||
nested = "[" * depth + "1" + "]" * depth
|
||||
return (
|
||||
'{"nodes":[{"id":%s,"type":"KSampler","widgets_values":[1]}],'
|
||||
'"links":[[%s,1,0,2,0]]}' % (nested, nested)
|
||||
)
|
||||
|
||||
|
||||
def info_seed(info):
|
||||
return card_seed(header_params(info))
|
||||
|
||||
|
||||
class CardSeed(unittest.TestCase):
|
||||
def test_card_seed_single_sampler(self):
|
||||
info = graph(seeded(42001))
|
||||
self.assertEqual(info_seed(info), "42001")
|
||||
self.assertEqual(parsed_positive(header_params(info)), "a lantern at dusk")
|
||||
|
||||
def test_card_seed_two_different_seeds_follows_shared_parser(self):
|
||||
# Ruling: no separate heuristic; the card shows what the legacy parser
|
||||
# (and so the inspector) shows for several samplers.
|
||||
info = graph(seeded(111), seeded(222))
|
||||
expected = extract_from_records(info["prompt"], None)["seed"]
|
||||
self.assertIn(expected, (111, 222))
|
||||
self.assertEqual(info_seed(info), str(expected))
|
||||
|
||||
def test_card_seed_linked_input_follows_shared_parser(self):
|
||||
primitive = {"class_type": "PrimitiveNode", "inputs": {"value": 555}}
|
||||
info = graph(seeded(["20", 0]), extra={"20": primitive})
|
||||
self.assertEqual(info_seed(info), "555")
|
||||
dangling = graph(seeded(["99", 0]))
|
||||
self.assertEqual(
|
||||
extract_from_records(dangling["prompt"], None)["seed"], "Unknown"
|
||||
)
|
||||
self.assertIsNone(info_seed(dangling))
|
||||
|
||||
def test_card_seed_advanced_sampler_noise_seed(self):
|
||||
advanced = seeded(None)
|
||||
advanced["class_type"] = "KSamplerAdvanced"
|
||||
del advanced["inputs"]["seed"]
|
||||
advanced["inputs"]["noise_seed"] = 77
|
||||
self.assertEqual(info_seed(graph(advanced)), "77")
|
||||
|
||||
def test_card_seed_oversized_record_is_none(self):
|
||||
padding = "x" * (MAX_RECORD_BYTES + 1)
|
||||
big_prompt = graph(seeded(1), extra={"30": encoder(padding)})
|
||||
self.assertGreater(len(big_prompt["prompt"]), MAX_RECORD_BYTES)
|
||||
self.assertIsNone(header_params(big_prompt))
|
||||
big_workflow = {**graph(seeded(1)), "workflow": json.dumps({"x": padding})}
|
||||
self.assertIsNone(header_params(big_workflow))
|
||||
# Bytes, not characters: two-byte text over the budget is oversized.
|
||||
wide = "é" * (MAX_RECORD_BYTES // 2 + 1)
|
||||
self.assertLessEqual(len(wide), MAX_RECORD_BYTES)
|
||||
self.assertIsNone(header_params({"prompt": json.dumps({"a": wide})}))
|
||||
self.assertEqual(info_seed(graph(seeded(1))), "1")
|
||||
|
||||
def test_records_past_the_inspection_projection_bound_give_nothing(self):
|
||||
# The inspector hands the browser parser only graphs its projection
|
||||
# parsed; a deeper graph is "limited" there, so the card skips it too.
|
||||
self.assertEqual(
|
||||
project_graph(nested_workflow(), "workflow")["status"], "limited"
|
||||
)
|
||||
self.assertIsNone(header_params({"workflow": nested_workflow()}))
|
||||
# A limited workflow beside a parsed prompt leaves the prompt's facts.
|
||||
both = header_params({**graph(seeded(1)), "workflow": nested_workflow()})
|
||||
self.assertEqual(card_seed(both), "1")
|
||||
|
||||
def test_depth_bound_is_the_inspectors(self):
|
||||
def deep(levels):
|
||||
nested = "[" * levels + "1" + "]" * levels
|
||||
extra = '{"class_type":"Note","inputs":{"v":%s}}' % nested
|
||||
text = graph(seeded(3))["prompt"]
|
||||
return {"prompt": text[:-1] + ',"30":' + extra + "}"}
|
||||
|
||||
# root object, node object, inputs object: three levels before "v".
|
||||
inside = MAX_PROJECTION_DEPTH - 3
|
||||
self.assertEqual(info_seed(deep(inside)), "3")
|
||||
self.assertIsNone(header_params(deep(inside + 1)))
|
||||
|
||||
def test_strict_projection_rules_apply(self):
|
||||
duplicate = '{"10": {"class_type": "KSampler", "inputs": {}}, "10": {}}'
|
||||
self.assertEqual(project_graph(duplicate, "prompt")["status"], "malformed")
|
||||
self.assertIsNone(header_params({"prompt": duplicate}))
|
||||
|
||||
def test_record_size_is_utf8_bytes_at_both_bounds(self):
|
||||
frame = '{"a":"%s"}'
|
||||
ascii_at = frame % ("x" * (MAX_RECORD_BYTES - 8))
|
||||
self.assertEqual(len(ascii_at), MAX_RECORD_BYTES)
|
||||
self.assertIsNotNone(header_params({"prompt": ascii_at}))
|
||||
four_byte = frame % ("\U0001f600" * ((MAX_RECORD_BYTES - 8) // 4))
|
||||
self.assertLessEqual(len(four_byte.encode()), MAX_RECORD_BYTES)
|
||||
self.assertIsNotNone(header_params({"prompt": four_byte}))
|
||||
padded = four_byte + " " * (MAX_RECORD_BYTES - len(four_byte.encode()) + 1)
|
||||
self.assertIsNone(header_params({"prompt": padded}))
|
||||
|
||||
def test_placeholder_and_non_integer_seeds_are_none(self):
|
||||
for value in ("randomize", " 12", True, 1.5, 7.0, 1e21, None):
|
||||
with self.subTest(seed=value):
|
||||
self.assertIsNone(info_seed(graph(seeded(value))))
|
||||
self.assertIsNone(card_seed({"seed": "Unknown"}))
|
||||
self.assertIsNone(card_seed({"seed": 7.0}))
|
||||
self.assertEqual(info_seed(graph(seeded(-3))), "-3")
|
||||
|
||||
def test_digit_string_seed_is_what_the_inspector_reads(self):
|
||||
# The projection carries big integers as text and keeps text as text;
|
||||
# the inspector shows a digit string as the seed, and so does the card.
|
||||
self.assertEqual(info_seed(graph(seeded("123"))), "123")
|
||||
|
||||
def test_seed_beyond_double_precision_is_the_recorded_integer(self):
|
||||
# JSON.parse would round it; the projection carries the record's digits.
|
||||
case = CASES["seed-beyond-double-precision"]
|
||||
rounded = extract_from_records(case["prompt"], None)["seed"]
|
||||
self.assertEqual(rounded, float(4974052037430000001))
|
||||
self.assertEqual(info_seed({"prompt": case["prompt"]}), "4974052037430000001")
|
||||
self.assertEqual(info_seed(graph(seeded(-(2**60) - 1))), str(-(2**60) - 1))
|
||||
|
||||
def test_seed_is_always_a_decimal_string(self):
|
||||
# JSON.parse rounds numbers past 2**53, so every seed travels as text.
|
||||
for value in (0, 7, 2**53, 2**53 + 1, 2**64, -(2**63)):
|
||||
with self.subTest(seed=value):
|
||||
self.assertEqual(card_seed({"seed": value}), str(value))
|
||||
self.assertEqual(card_seed({"seed": "18446744073709551616"}), str(2**64))
|
||||
|
||||
def test_digit_string_seed_is_bounded(self):
|
||||
# 2**64 has 20 digits; a longer string is no seed, and an unbounded one
|
||||
# must not reach int() (Python's int max-str-digits raises ValueError).
|
||||
self.assertEqual(card_seed({"seed": "9" * 20}), "9" * 20)
|
||||
self.assertEqual(card_seed({"seed": "-" + "9" * 20}), "-" + "9" * 20)
|
||||
self.assertIsNone(card_seed({"seed": "1" * 21}))
|
||||
self.assertIsNone(card_seed({"seed": "9" * 5000}))
|
||||
self.assertIsNone(info_seed(graph(seeded("9" * 5000))))
|
||||
|
||||
def test_seed_conversion_error_gives_none(self):
|
||||
class Huge(int):
|
||||
def __str__(self):
|
||||
raise ValueError("Exceeds the limit for integer string conversion")
|
||||
|
||||
self.assertIsNone(card_seed({"seed": Huge(5)}))
|
||||
|
||||
def test_workflow_only_record(self):
|
||||
case = next(
|
||||
case
|
||||
for case in CASES.values()
|
||||
if case["prompt"] is None
|
||||
and case["workflow"] is not None
|
||||
and isinstance(extract_from_records(None, case["workflow"])["seed"], int)
|
||||
)
|
||||
expected = extract_from_records(None, case["workflow"])["seed"]
|
||||
self.assertEqual(info_seed({"workflow": case["workflow"]}), str(expected))
|
||||
|
||||
def test_records_the_parser_cannot_use_give_nothing(self):
|
||||
self.assertIsNone(header_params({}))
|
||||
self.assertIsNone(header_params(None))
|
||||
self.assertIsNone(header_params({"parameters": "a cat\nSteps: 20, Seed: 9"}))
|
||||
self.assertIsNone(header_params({"prompt": b"{}"}))
|
||||
self.assertIsNone(card_seed(None))
|
||||
self.assertIsNone(parsed_positive(None))
|
||||
# The browser parser throws here; the card shows nothing.
|
||||
thrown = CASES["null-workflow-node"]
|
||||
self.assertIsNone(header_params(thrown))
|
||||
unparsable = header_params(CASES["unparsable-records"])
|
||||
self.assertIsNone(card_seed(unparsable))
|
||||
self.assertIsNone(parsed_positive(unparsable))
|
||||
|
||||
def test_every_shared_case_reads_the_projected_graphs(self):
|
||||
for name, case in CASES.items():
|
||||
with self.subTest(case=name):
|
||||
info = {k: v for k, v in case.items() if v is not None}
|
||||
params = header_params(info)
|
||||
graphs = {key: project_graph(text, key) for key, text in info.items()}
|
||||
values = {
|
||||
key: graph["value"]
|
||||
for key, graph in graphs.items()
|
||||
if graph["status"] == "parsed"
|
||||
}
|
||||
if not values:
|
||||
self.assertIsNone(params)
|
||||
continue
|
||||
try:
|
||||
expected = extract_generation_params(values)
|
||||
except GenerationParamsError:
|
||||
self.assertIsNone(params)
|
||||
continue
|
||||
self.assertEqual(params, expected)
|
||||
# Prompt text is the same as from a plain parse of the records.
|
||||
if len(values) == len(info):
|
||||
raw = extract_from_records(case["prompt"], case["workflow"])
|
||||
self.assertEqual(params["positivePrompt"], raw["positivePrompt"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1432,3 +1432,167 @@ class TestImagePaging(DatabaseTestCase):
|
||||
self.assertEqual(len(second), 2)
|
||||
self.assertNotEqual({img["id"] for img in first}, {img["id"] for img in second})
|
||||
self.assertEqual(len(self.db.search_images_by_prompt("paged")), 5)
|
||||
|
||||
|
||||
class TestImageLinkSummaries(DatabaseTestCase):
|
||||
"""Batched card summaries follow get_image_prompt_info's matching rules."""
|
||||
|
||||
def _statements(self, call):
|
||||
statements = []
|
||||
original = self.db.model.get_connection
|
||||
|
||||
def traced():
|
||||
connection = original()
|
||||
connection.set_trace_callback(statements.append)
|
||||
return connection
|
||||
|
||||
try:
|
||||
with patch.object(self.db.model, "get_connection", side_effect=traced):
|
||||
result = call()
|
||||
finally:
|
||||
original().set_trace_callback(None)
|
||||
selects = [s for s in statements if s.lstrip().upper().startswith("SELECT")]
|
||||
return result, selects
|
||||
|
||||
def test_summary_status_matches_image_prompt_info_for_every_rule(self):
|
||||
owner = self._save("Owner prompt\nsecond line", category="Cat", rating=4)
|
||||
rival = self._save("Rival prompt")
|
||||
exact = os.path.join("out", "one", "same.png")
|
||||
other = os.path.join("out", "two", "same.png")
|
||||
unique = os.path.join("out", "three", "unique.png")
|
||||
shared = os.path.join("out", "shared.png")
|
||||
self.db.link_image_to_prompt(owner, exact)
|
||||
self.db.link_image_to_prompt(rival, other)
|
||||
self.db.link_image_to_prompt(owner, unique)
|
||||
self.db.link_image_to_prompt(owner, shared)
|
||||
self.db.link_image_to_prompt(rival, shared)
|
||||
probes = [
|
||||
exact,
|
||||
os.path.join("out", "x", "..", "one", "same.png"),
|
||||
os.path.join("elsewhere", "same.png"),
|
||||
os.path.join("elsewhere", "unique.png"),
|
||||
shared,
|
||||
"missing.png",
|
||||
]
|
||||
for fallback in (True, False):
|
||||
summaries = self.db.get_image_link_summaries(probes, fallback)
|
||||
self.assertEqual(set(summaries), set(probes))
|
||||
for probe in probes:
|
||||
with self.subTest(probe=probe, fallback=fallback):
|
||||
info = self.db.get_image_prompt_info(
|
||||
probe, fallback, report_ambiguity=True
|
||||
)
|
||||
summary = summaries[probe]
|
||||
if info is None:
|
||||
expected = "none"
|
||||
elif info.get("association_status") == "ambiguous":
|
||||
expected = "ambiguous"
|
||||
else:
|
||||
expected = "linked"
|
||||
self.assertEqual(summary["status"], expected)
|
||||
if expected == "linked":
|
||||
self.assertEqual(summary["positive_id"], info["prompt_id"])
|
||||
else:
|
||||
self.assertEqual(
|
||||
summary,
|
||||
{
|
||||
"status": expected,
|
||||
"positive_id": None,
|
||||
"title": None,
|
||||
"title_source": None,
|
||||
"category": None,
|
||||
"rating": None,
|
||||
},
|
||||
)
|
||||
linked = self.db.get_image_link_summaries([unique])[unique]
|
||||
self.assertEqual(
|
||||
linked,
|
||||
{
|
||||
"status": "linked",
|
||||
"positive_id": owner,
|
||||
"title": "Owner prompt",
|
||||
"title_source": "text",
|
||||
"category": "Cat",
|
||||
"rating": 4,
|
||||
},
|
||||
)
|
||||
|
||||
def test_summary_title_prefers_stored_title_then_first_line(self):
|
||||
long_line = "word " * 30
|
||||
titled = self._save("Untitled text")
|
||||
plain = self._save(f" {long_line}\nsecond")
|
||||
blank = self._save("Blank title text")
|
||||
with self.db.model.get_connection() as conn:
|
||||
conn.executemany(
|
||||
"INSERT INTO prompt_library_state(prompt_id,title) VALUES(?,?)",
|
||||
[(titled, "Stored title"), (blank, " \u3000 ")],
|
||||
)
|
||||
paths = {}
|
||||
for name, prompt in (("titled", titled), ("plain", plain), ("blank", blank)):
|
||||
paths[name] = os.path.join("out", f"{name}.png")
|
||||
self.db.link_image_to_prompt(prompt, paths[name])
|
||||
summaries = self.db.get_image_link_summaries(list(paths.values()))
|
||||
sources = {
|
||||
name: (summaries[path]["title"], summaries[path]["title_source"])
|
||||
for name, path in paths.items()
|
||||
}
|
||||
self.assertEqual(sources["titled"], ("Stored title", "stored"))
|
||||
# Cut like the Library's title(): 80 characters, then an ellipsis.
|
||||
self.assertEqual(sources["plain"], (long_line.strip()[:80] + "\u2026", "text"))
|
||||
self.assertEqual(sources["blank"], ("Blank title text", "text"))
|
||||
|
||||
def test_display_title_matches_library_title_cut(self):
|
||||
from database.library import display_title
|
||||
|
||||
exact = "x" * 80
|
||||
self.assertEqual(display_title((), exact), exact)
|
||||
self.assertEqual(display_title((), exact + "y"), exact + "\u2026")
|
||||
self.assertEqual(display_title((), " short \nnext"), "short")
|
||||
self.assertIsNone(display_title((" ",), " \n"))
|
||||
self.assertEqual(display_title(("Kept ",), "text"), "Kept ")
|
||||
|
||||
def test_summary_uses_one_select_per_200_paths(self):
|
||||
prompt = self._save("Batch")
|
||||
paths = [os.path.join("out", f"{index}.png") for index in range(201)]
|
||||
for path in paths[:5]:
|
||||
self.db.link_image_to_prompt(prompt, path)
|
||||
result, selects = self._statements(
|
||||
lambda: self.db.get_image_link_summaries(paths[:200])
|
||||
)
|
||||
self.assertEqual(len(selects), 1)
|
||||
self.assertEqual(len(result), 200)
|
||||
self.assertEqual(
|
||||
[result[path]["status"] for path in paths[:6]], ["linked"] * 5 + ["none"]
|
||||
)
|
||||
_, selects = self._statements(lambda: self.db.get_image_link_summaries(paths))
|
||||
self.assertEqual(len(selects), 2)
|
||||
_, selects = self._statements(lambda: self.db.get_image_link_summaries([]))
|
||||
self.assertEqual(selects, [])
|
||||
|
||||
def test_windows_style_paths_normalize(self):
|
||||
import ntpath
|
||||
|
||||
def windows_normalize(path):
|
||||
return ntpath.normcase(ntpath.normpath(path))
|
||||
|
||||
prompt = self._save("Windows prompt")
|
||||
stored = r"C:\ComfyUI\Output\Sub\Image.PNG"
|
||||
with patch("database.operations.normalize_image_path", windows_normalize):
|
||||
self.db.link_image_to_prompt(prompt, stored)
|
||||
probes = [
|
||||
stored,
|
||||
r"c:\comfyui\output\SUB\image.png",
|
||||
r"C:\ComfyUI\Output\x\..\Sub\Image.PNG",
|
||||
"C:/ComfyUI/Output/Sub/Image.PNG",
|
||||
r"D:\ComfyUI\Output\Sub\Other.png",
|
||||
]
|
||||
summaries = self.db.get_image_link_summaries(probes, False)
|
||||
for probe in probes:
|
||||
with self.subTest(probe=probe):
|
||||
info = self.db.get_image_prompt_info(probe, False)
|
||||
expected = "linked" if info else "none"
|
||||
self.assertEqual(summaries[probe]["status"], expected)
|
||||
self.assertEqual(
|
||||
[summaries[probe]["status"] for probe in probes],
|
||||
["linked"] * 4 + ["none"],
|
||||
)
|
||||
|
||||
@@ -428,6 +428,72 @@ class TestRestoreFromFile(BackupTestCase):
|
||||
PromptModel.verify_database_file(dst, require_library_state=True)[0]
|
||||
)
|
||||
|
||||
def test_restore_old_backup_without_favorites(self):
|
||||
self.db.set_image_favorite("output", "live.png")
|
||||
src = self._make_source_db(["predates favorites"])
|
||||
with closing(sqlite3.connect(src)) as conn:
|
||||
conn.execute("DROP TABLE image_favorites")
|
||||
conn.commit()
|
||||
self.assertTrue(PromptModel.verify_database_file(src)[0])
|
||||
|
||||
self.db.model.restore_from_file(src)
|
||||
|
||||
self.assertEqual(_texts(self.path), ["predates favorites"])
|
||||
self.assertEqual(self.db.get_image_favorites("output", ["live.png"]), set())
|
||||
self.assertTrue(self.db.set_image_favorite("output", "new.png"))
|
||||
ok, reason = PromptModel.verify_database_file(
|
||||
self.path, require_library_state=True
|
||||
)
|
||||
self.assertTrue(ok, reason)
|
||||
with closing(sqlite3.connect(src)) as conn: # the source is untouched
|
||||
self.assertIsNone(
|
||||
conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE name='image_favorites'"
|
||||
).fetchone()
|
||||
)
|
||||
|
||||
def test_restore_preserves_favorites_and_closes_handles(self):
|
||||
self.db.set_image_favorite("output", "live.png")
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
src_path = os.path.join(directory, "source.db")
|
||||
src = PromptDatabase(src_path)
|
||||
try:
|
||||
self._save(src, "with favorites")
|
||||
src.set_image_favorite("output", "sub/kept.png")
|
||||
src.set_image_favorite("extra/root", "kept.png")
|
||||
finally:
|
||||
src.close_all()
|
||||
# The favorites still sit in the WAL; a backup must carry them.
|
||||
backup = os.path.join(directory, "backup.db")
|
||||
reopened = PromptDatabase(src_path)
|
||||
try:
|
||||
self.assertTrue(reopened.model.backup_database(backup))
|
||||
finally:
|
||||
reopened.close_all()
|
||||
|
||||
self.db.model.restore_from_file(backup)
|
||||
|
||||
self.assertEqual(
|
||||
self.db.get_image_favorites("output", ["sub/kept.png", "live.png"]),
|
||||
{"sub/kept.png"},
|
||||
)
|
||||
self.assertEqual(
|
||||
self.db.get_image_favorites("extra/root", ["kept.png"]), {"kept.png"}
|
||||
)
|
||||
PromptModel.close_all_instances()
|
||||
# Windows refuses to delete a file a restore left open.
|
||||
assert_closed(self, directory)
|
||||
backups = list(Path(self.tmpdir).glob("prompts.db.backup_*"))
|
||||
self.assertEqual(len(backups), 1)
|
||||
with closing(sqlite3.connect(str(backups[0]))) as conn:
|
||||
self.assertEqual(
|
||||
conn.execute(
|
||||
"SELECT root_key, relative_path FROM image_favorites"
|
||||
).fetchall(),
|
||||
[("output", "live.png")],
|
||||
)
|
||||
assert_closed(self, str(backups[0]))
|
||||
|
||||
def test_restore_without_a_live_database_returns_empty_backup_path(self):
|
||||
src = self._make_source_db(["fresh"])
|
||||
self.db.close_all()
|
||||
|
||||
@@ -176,6 +176,12 @@ class StaysBounded(unittest.TestCase):
|
||||
self.assertEqual(result["negativePrompt"], "t")
|
||||
|
||||
|
||||
class DeeplyNestedValues(unittest.TestCase):
|
||||
def test_deeply_nested_values_do_not_exhaust_the_python_stack(self):
|
||||
result = run("deeply-nested-values")
|
||||
self.assertEqual(result["positivePrompt"], "nested neighbours")
|
||||
|
||||
|
||||
class Placeholders(unittest.TestCase):
|
||||
def test_blank_text_is_a_placeholder_and_zero_seed_is_kept(self):
|
||||
result = run("blank-text-and-zero-seed")
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
"""Persisted image favorites (spec D7): store, schema and rooted API boundary."""
|
||||
|
||||
import json
|
||||
import ntpath
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
try:
|
||||
from test_api_images import ImageAPITestCase, make_png
|
||||
from open_handles import assert_closed
|
||||
except ImportError:
|
||||
from tests.test_api_images import ImageAPITestCase, make_png
|
||||
from tests.open_handles import assert_closed
|
||||
|
||||
from database.image_favorites import (
|
||||
MAX_KEY_LENGTH,
|
||||
TABLE,
|
||||
image_favorites_schema_error,
|
||||
migrate_image_favorites,
|
||||
)
|
||||
from database.models import PromptModel
|
||||
from database.operations import PromptDatabase
|
||||
|
||||
FAVORITE = "/prompt_manager/images/favorite"
|
||||
|
||||
|
||||
class TestImageFavoriteApi(ImageAPITestCase):
|
||||
async def tearDownAsync(self):
|
||||
self.api.db.close_all()
|
||||
assert_closed(self, self._temp_db.name, self._output_tmp.name)
|
||||
await super().tearDownAsync()
|
||||
|
||||
async def send(self, method, body):
|
||||
response = await self.client.request(method, FAVORITE, json=body)
|
||||
return response, await response.json()
|
||||
|
||||
async def workspace(self, **params):
|
||||
response = await self.client.get(
|
||||
"/prompt_manager/images/output", params={"mode": "workspace", **params}
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
data = await response.json()
|
||||
return {image["path"]: image for image in data["images"]}
|
||||
|
||||
async def test_favorite_round_trip_and_idempotent(self):
|
||||
make_png(self.output_dir / "sub" / "a.png")
|
||||
make_png(self.output_dir / "b.png")
|
||||
items = await self.workspace()
|
||||
self.assertIs(items["sub/a.png"]["favorite"], False)
|
||||
self.assertIs(items["b.png"]["favorite"], False)
|
||||
|
||||
body = {"root_index": 0, "path": "sub/a.png"}
|
||||
for _ in range(2): # a repeated PUT is a no-op, not an error
|
||||
response, data = await self.send("PUT", body)
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual(
|
||||
data,
|
||||
{
|
||||
"success": True,
|
||||
"favorite": True,
|
||||
"root_index": 0,
|
||||
"path": "sub/a.png",
|
||||
},
|
||||
)
|
||||
items = await self.workspace()
|
||||
self.assertIs(items["sub/a.png"]["favorite"], True)
|
||||
self.assertIs(items["b.png"]["favorite"], False)
|
||||
count = self.api.db.model.get_connection().execute(
|
||||
f"SELECT COUNT(*) FROM {TABLE}"
|
||||
)
|
||||
self.assertEqual(count.fetchone()[0], 1)
|
||||
|
||||
for _ in range(2):
|
||||
response, data = await self.send("DELETE", body)
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertTrue(data["success"])
|
||||
self.assertIs(data["favorite"], False)
|
||||
items = await self.workspace()
|
||||
self.assertIs(items["sub/a.png"]["favorite"], False)
|
||||
|
||||
def rows(self):
|
||||
return [
|
||||
tuple(row)
|
||||
for row in self.api.db.model.get_connection().execute(
|
||||
f"SELECT root_key, relative_path FROM {TABLE} ORDER BY 1, 2"
|
||||
)
|
||||
]
|
||||
|
||||
async def test_favorite_is_keyed_by_root_not_index(self):
|
||||
second = self.comfy_dir / "second"
|
||||
make_png(second / "a.png")
|
||||
make_png(self.output_dir / "a.png")
|
||||
self.api._get_all_output_dirs = lambda: [self.output_dir, second]
|
||||
response, data = await self.send("PUT", {"root_index": 1, "path": "a.png"})
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertNotIn(str(self.comfy_dir), json.dumps(data))
|
||||
root_key = Path(os.path.normcase(os.path.realpath(second))).as_posix()
|
||||
self.assertEqual(self.rows(), [(root_key, "a.png")])
|
||||
# The same root at another index keeps its favorite; the other root
|
||||
# with the same relative path does not inherit it.
|
||||
self.api._get_all_output_dirs = lambda: [second, self.output_dir]
|
||||
self.api.invalidate_gallery_cache()
|
||||
self.assertIs((await self.workspace(root=0))["a.png"]["favorite"], True)
|
||||
self.assertIs((await self.workspace(root=1))["a.png"]["favorite"], False)
|
||||
|
||||
async def test_roots_sharing_a_folder_name_keep_separate_favorites(self):
|
||||
# Two roots under different parents, both shown as "output".
|
||||
with tempfile.TemporaryDirectory() as other_parent:
|
||||
other = Path(os.path.realpath(other_parent)) / "output"
|
||||
make_png(other / "a.png")
|
||||
make_png(self.output_dir / "a.png")
|
||||
self.api._get_all_output_dirs = lambda: [self.output_dir, other]
|
||||
response = await self.client.get(
|
||||
"/prompt_manager/images/output", params={"mode": "workspace"}
|
||||
)
|
||||
labels = {
|
||||
item["root_index"]: item["root_dir"]
|
||||
for item in (await response.json())["images"]
|
||||
}
|
||||
self.assertEqual(labels, {0: "output", 1: "output"})
|
||||
|
||||
await self.send("PUT", {"root_index": 0, "path": "a.png"})
|
||||
self.assertIs((await self.workspace(root=0))["a.png"]["favorite"], True)
|
||||
self.assertIs((await self.workspace(root=1))["a.png"]["favorite"], False)
|
||||
|
||||
await self.send("PUT", {"root_index": 1, "path": "a.png"})
|
||||
response, data = await self.send(
|
||||
"DELETE", {"root_index": 0, "path": "a.png"}
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertNotIn(other_parent, json.dumps(data))
|
||||
self.assertIs((await self.workspace(root=0))["a.png"]["favorite"], False)
|
||||
self.assertIs((await self.workspace(root=1))["a.png"]["favorite"], True)
|
||||
listing = await self.client.get(
|
||||
"/prompt_manager/images/output", params={"mode": "workspace"}
|
||||
)
|
||||
self.assertNotIn(other_parent, await listing.text())
|
||||
|
||||
async def test_windows_case_only_rename_keeps_the_star(self):
|
||||
make_png(self.output_dir / "Sub" / "A.png")
|
||||
with patch("py.api.images._path_flavor", ntpath):
|
||||
response, data = await self.send(
|
||||
"PUT", {"root_index": 0, "path": "Sub/A.png"}
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual(data["path"], "Sub/A.png") # the listing's spelling
|
||||
(self.output_dir / "Sub" / "A.png").rename(
|
||||
self.output_dir / "Sub" / "a.png"
|
||||
)
|
||||
(self.output_dir / "Sub").rename(self.output_dir / "sub")
|
||||
self.api.invalidate_gallery_cache()
|
||||
self.assertIs((await self.workspace())["sub/a.png"]["favorite"], True)
|
||||
response, data = await self.send(
|
||||
"DELETE", {"root_index": 0, "path": "SUB/A.PNG"}
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual(data["path"], "sub/a.png")
|
||||
self.assertIs((await self.workspace())["sub/a.png"]["favorite"], False)
|
||||
self.assertEqual(self.rows(), [])
|
||||
|
||||
async def test_posix_case_distinct_files_keep_independent_stars(self):
|
||||
make_png(self.output_dir / "A.png")
|
||||
make_png(self.output_dir / "a.png")
|
||||
if len(list(self.output_dir.iterdir())) != 2:
|
||||
self.skipTest("case-insensitive filesystem")
|
||||
await self.send("PUT", {"root_index": 0, "path": "A.png"})
|
||||
items = await self.workspace()
|
||||
self.assertIs(items["A.png"]["favorite"], True)
|
||||
self.assertIs(items["a.png"]["favorite"], False)
|
||||
|
||||
async def test_favorite_rejects_traversal_and_unknown_root(self):
|
||||
make_png(self.output_dir / "a.png")
|
||||
make_png(self.comfy_dir / "outside.png")
|
||||
(self.output_dir / "notes.txt").write_text("not media")
|
||||
make_png(self.output_dir / "thumbnails" / "a_thumb.png")
|
||||
cases = [
|
||||
({"root_index": 0, "path": "../outside.png"}, 403),
|
||||
({"root_index": 0, "path": "sub/../../outside.png"}, 403),
|
||||
({"root_index": 0, "path": str(self.comfy_dir / "outside.png")}, 403),
|
||||
({"root_index": 0, "path": "/a.png"}, 403),
|
||||
({"root_index": 0, "path": "C:/a.png"}, 403),
|
||||
({"root_index": 0, "path": "a.png\0.png"}, 403),
|
||||
({"root_index": 0, "path": "notes.txt"}, 403),
|
||||
({"root_index": 1, "path": "a.png"}, 400),
|
||||
({"root_index": -1, "path": "a.png"}, 400),
|
||||
({"root_index": "0", "path": "a.png"}, 400),
|
||||
({"root_index": True, "path": "a.png"}, 400),
|
||||
({"root_index": 0}, 400),
|
||||
({"root_index": 0, "path": ""}, 400),
|
||||
({"root_index": 0, "path": 7}, 400),
|
||||
({"root_index": 0, "path": "x" * 5000 + ".png"}, 400),
|
||||
({"root_index": 0, "path": "missing.png"}, 404),
|
||||
({"root_index": 0, "path": "thumbnails/a_thumb.png"}, 404),
|
||||
]
|
||||
for method in ("PUT", "DELETE"):
|
||||
for body, status in cases:
|
||||
if method == "DELETE" and status == 404:
|
||||
continue # unlisting a stale favorite is allowed (below)
|
||||
with self.subTest(method=method, body=str(body)[:60]):
|
||||
response, data = await self.send(method, body)
|
||||
self.assertEqual(response.status, status)
|
||||
self.assertIs(data["success"], False)
|
||||
self.assertNotIn(str(self.comfy_dir), data["error"])
|
||||
response = await self.client.put(FAVORITE, data=b"[1]")
|
||||
self.assertEqual(response.status, 400)
|
||||
response = await self.client.put(FAVORITE, data=b"{not json")
|
||||
self.assertEqual(response.status, 400)
|
||||
count = self.api.db.model.get_connection().execute(
|
||||
f"SELECT COUNT(*) FROM {TABLE}"
|
||||
)
|
||||
self.assertEqual(count.fetchone()[0], 0)
|
||||
|
||||
async def test_delete_clears_a_favorite_whose_file_is_gone(self):
|
||||
target = make_png(self.output_dir / "gone.png")
|
||||
await self.send("PUT", {"root_index": 0, "path": "gone.png"})
|
||||
target.unlink()
|
||||
self.api.invalidate_gallery_cache()
|
||||
response, data = await self.send(
|
||||
"DELETE", {"root_index": 0, "path": "gone.png"}
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertIs(data["favorite"], False)
|
||||
count = self.api.db.model.get_connection().execute(
|
||||
f"SELECT COUNT(*) FROM {TABLE}"
|
||||
)
|
||||
self.assertEqual(count.fetchone()[0], 0)
|
||||
|
||||
async def test_legacy_listing_is_unchanged_by_favorites(self):
|
||||
make_png(self.output_dir / "a.png")
|
||||
await self.send("PUT", {"root_index": 0, "path": "a.png"})
|
||||
response = await self.client.get("/prompt_manager/images/output")
|
||||
data = await response.json()
|
||||
self.assertEqual(len(data["images"]), 1)
|
||||
self.assertNotIn("favorite", data["images"][0])
|
||||
|
||||
async def test_toggle_lookup_is_built_once_per_scan(self):
|
||||
make_png(self.output_dir / "a.png")
|
||||
files = await self.api._get_gallery_files(self.output_dir)
|
||||
first = self.api._favorite_listing(self.output_dir, files)
|
||||
self.assertEqual(first, {"a.png": "a.png"})
|
||||
self.assertIs(self.api._favorite_listing(self.output_dir, files), first)
|
||||
make_png(self.output_dir / "b.png")
|
||||
self.api.invalidate_gallery_cache()
|
||||
rescanned = await self.api._get_gallery_files(self.output_dir)
|
||||
self.assertEqual(
|
||||
self.api._favorite_listing(self.output_dir, rescanned),
|
||||
{"a.png": "a.png", "b.png": "b.png"},
|
||||
)
|
||||
self.api.reset_gallery_roots_cache()
|
||||
self.assertEqual(self.api._favorite_listing_cache, {})
|
||||
|
||||
async def test_monitor_invalidation_drops_the_favorites_lookup(self):
|
||||
make_png(self.output_dir / "a.png")
|
||||
await self.send("PUT", {"root_index": 0, "path": "a.png"})
|
||||
self.assertNotEqual(self.api._favorite_listing_cache, {})
|
||||
# The image monitor's invalidation must not keep the old scan alive.
|
||||
self.api.invalidate_gallery_cache()
|
||||
self.assertEqual(self.api._favorite_listing_cache, {})
|
||||
self.assertIs((await self.workspace())["a.png"]["favorite"], True)
|
||||
|
||||
async def test_put_rejects_a_key_beyond_the_stored_bound(self):
|
||||
make_png(self.output_dir / "a.png")
|
||||
too_long = "/" + "r" * MAX_KEY_LENGTH
|
||||
with patch("py.api.images._favorite_root_key", return_value=too_long):
|
||||
response, data = await self.send("PUT", {"root_index": 0, "path": "a.png"})
|
||||
self.assertEqual(response.status, 403)
|
||||
self.assertFalse(data["success"])
|
||||
self.assertNotIn(too_long, json.dumps(data))
|
||||
self.assertEqual(self.rows(), [])
|
||||
# The live database still validates (a stored over-long key would
|
||||
# fail startup and restore).
|
||||
conn = self.api.db.model.get_connection()
|
||||
self.assertIsNone(image_favorites_schema_error(conn))
|
||||
|
||||
async def test_favorite_store_failure_keeps_the_listing(self):
|
||||
make_png(self.output_dir / "a.png")
|
||||
|
||||
def broken(*_args, **_kwargs):
|
||||
raise sqlite3.OperationalError("database is locked")
|
||||
|
||||
self.api.db.get_image_favorites = broken
|
||||
items = await self.workspace()
|
||||
self.assertIs(items["a.png"]["favorite"], False)
|
||||
self.api.db.set_image_favorite = broken
|
||||
response, data = await self.send("PUT", {"root_index": 0, "path": "a.png"})
|
||||
self.assertEqual(response.status, 500)
|
||||
self.assertEqual(data, {"success": False, "error": "Favorite update failed"})
|
||||
|
||||
|
||||
class TestImageFavoriteSchema(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmpdir = tempfile.mkdtemp()
|
||||
self.path = os.path.join(self.tmpdir, "favorites.db")
|
||||
|
||||
def tearDown(self):
|
||||
PromptModel.close_all_instances()
|
||||
assert_closed(self, self.tmpdir)
|
||||
for name in os.listdir(self.tmpdir):
|
||||
os.unlink(os.path.join(self.tmpdir, name))
|
||||
os.rmdir(self.tmpdir)
|
||||
|
||||
def test_store_round_trip_and_bounded_lookup(self):
|
||||
db = PromptDatabase(self.path)
|
||||
try:
|
||||
self.assertTrue(db.set_image_favorite("output", "a.png"))
|
||||
self.assertTrue(db.set_image_favorite("output", "a.png"))
|
||||
db.set_image_favorite("other", "a.png")
|
||||
self.assertEqual(
|
||||
db.get_image_favorites("output", ["a.png", "b.png"]), {"a.png"}
|
||||
)
|
||||
self.assertEqual(db.get_image_favorites("output", []), set())
|
||||
many = [f"{n}.png" for n in range(2000)] + ["a.png"]
|
||||
self.assertEqual(db.get_image_favorites("output", many), {"a.png"})
|
||||
self.assertTrue(db.clear_image_favorite("output", "a.png"))
|
||||
self.assertFalse(db.clear_image_favorite("output", "a.png"))
|
||||
self.assertEqual(db.get_image_favorites("output", ["a.png"]), set())
|
||||
created = db.model.get_connection().execute(
|
||||
f"SELECT created_at FROM {TABLE}"
|
||||
)
|
||||
self.assertRegex(created.fetchone()[0], r"^\d{4}-\d\d-\d\dT")
|
||||
finally:
|
||||
db.close_all()
|
||||
|
||||
def test_schema_rejects_unsupported_definitions_and_values(self):
|
||||
variants = {
|
||||
"missing": None,
|
||||
"wrong table": f"CREATE TABLE {TABLE} (x TEXT)",
|
||||
"view": f"CREATE VIEW {TABLE} AS SELECT 1",
|
||||
"nullable key": (
|
||||
f"CREATE TABLE {TABLE} (root_key TEXT, relative_path TEXT, "
|
||||
"created_at TEXT, PRIMARY KEY(root_key, relative_path))"
|
||||
),
|
||||
}
|
||||
for label, ddl in variants.items():
|
||||
with self.subTest(label):
|
||||
with closing(sqlite3.connect(":memory:")) as conn:
|
||||
if ddl:
|
||||
conn.execute(ddl)
|
||||
error = image_favorites_schema_error(conn)
|
||||
if ddl is None:
|
||||
self.assertIsNone(error)
|
||||
self.assertIsNotNone(
|
||||
image_favorites_schema_error(conn, allow_missing=False)
|
||||
)
|
||||
else:
|
||||
self.assertIsNotNone(error)
|
||||
with self.assertRaises(sqlite3.DatabaseError):
|
||||
migrate_image_favorites(conn)
|
||||
with closing(sqlite3.connect(":memory:")) as conn:
|
||||
migrate_image_favorites(conn)
|
||||
migrate_image_favorites(conn) # idempotent
|
||||
self.assertIsNone(image_favorites_schema_error(conn, allow_missing=False))
|
||||
conn.execute(f"INSERT INTO {TABLE} VALUES ('r', 'a.png', 'now')")
|
||||
self.assertIsNone(image_favorites_schema_error(conn))
|
||||
conn.execute(f"INSERT INTO {TABLE} VALUES ('r', x'00ff', 'now')")
|
||||
self.assertIsNotNone(image_favorites_schema_error(conn))
|
||||
conn.execute(f"DELETE FROM {TABLE} WHERE typeof(relative_path)='blob'")
|
||||
conn.execute(f"INSERT INTO {TABLE} VALUES ('r', ?, 'now')", ("x" * 5000,))
|
||||
self.assertIsNotNone(image_favorites_schema_error(conn))
|
||||
conn.execute(f"DELETE FROM {TABLE}")
|
||||
conn.execute(f"CREATE INDEX extra ON {TABLE}(created_at)")
|
||||
self.assertIsNotNone(image_favorites_schema_error(conn))
|
||||
|
||||
def test_verify_rejects_a_backup_with_an_invalid_favorites_table(self):
|
||||
db = PromptDatabase(self.path)
|
||||
db.close_all()
|
||||
PromptModel.close_all_instances()
|
||||
with closing(sqlite3.connect(self.path)) as conn:
|
||||
conn.execute(f"DROP TABLE {TABLE}")
|
||||
conn.execute(f"CREATE TABLE {TABLE} (anything TEXT)")
|
||||
conn.commit()
|
||||
ok, reason = PromptModel.verify_database_file(self.path)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("favorites", reason.lower())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -487,6 +487,115 @@ class TestLibrary(unittest.TestCase):
|
||||
rotate_history_epoch(self.db.model.get_connection())
|
||||
self.assertNotEqual(token(), before)
|
||||
|
||||
def test_library_items_expose_run_count_and_last_used(self):
|
||||
import datetime
|
||||
|
||||
used = self.save("Used")
|
||||
never = self.save("Never")
|
||||
self.assertTrue(self.db.record_prompt_use(used))
|
||||
self.assertTrue(self.db.record_prompt_use(used, times=2))
|
||||
legacy = self.save("Legacy naive")
|
||||
offset = self.save("Offset")
|
||||
broken = self.save("Broken")
|
||||
zulu = self.save("Zulu")
|
||||
# Parse but cannot convert to UTC: past the datetime range either way.
|
||||
floor = self.save("Floor")
|
||||
ceiling = self.save("Ceiling")
|
||||
with self.db.model.get_connection() as connection:
|
||||
for prompt_id, value in (
|
||||
(legacy, "2026-10-07 18:39:06"),
|
||||
(zulu, "2026-10-07T18:39:06Z"),
|
||||
(floor, "0001-01-01T00:00:00+01:00"),
|
||||
(ceiling, "9999-12-31T23:59:59-01:00"),
|
||||
(offset, "2026-10-07T20:39:06.250+02:00"),
|
||||
(broken, "yesterday"),
|
||||
):
|
||||
connection.execute(
|
||||
"UPDATE prompts SET last_used_at=?,run_count=1 WHERE id=?",
|
||||
(value, prompt_id),
|
||||
)
|
||||
items = {row["id"]: row for row in self.db.list_library()["items"]}
|
||||
self.assertEqual(items[used]["run_count"], 3)
|
||||
stamp = datetime.datetime.fromisoformat(items[used]["last_used_at"])
|
||||
self.assertEqual(stamp.utcoffset(), datetime.timedelta(0))
|
||||
self.assertLess(
|
||||
abs(datetime.datetime.now(datetime.timezone.utc) - stamp),
|
||||
datetime.timedelta(minutes=5),
|
||||
)
|
||||
self.assertEqual(items[never]["run_count"], 0)
|
||||
self.assertIsNone(items[never]["last_used_at"])
|
||||
self.assertEqual(items[legacy]["last_used_at"], "2026-10-07T18:39:06+00:00")
|
||||
self.assertEqual(
|
||||
items[offset]["last_used_at"], "2026-10-07T18:39:06.250000+00:00"
|
||||
)
|
||||
self.assertIsNone(items[broken]["last_used_at"])
|
||||
self.assertEqual(items[zulu]["last_used_at"], "2026-10-07T18:39:06+00:00")
|
||||
self.assertIsNone(items[floor]["last_used_at"])
|
||||
self.assertIsNone(items[ceiling]["last_used_at"])
|
||||
self.assertIsNone(
|
||||
self.db.get_library_positive(floor)["positive"]["last_used_at"]
|
||||
)
|
||||
self.assertEqual(type(items[broken]["run_count"]), int)
|
||||
detail = self.db.get_library_positive(used)["positive"]
|
||||
self.assertEqual(detail["run_count"], 3)
|
||||
self.assertEqual(detail["last_used_at"], items[used]["last_used_at"])
|
||||
|
||||
def test_variant_total_matches_filtered_cards(self):
|
||||
archived = self.db.save_prompt_pair("Cat", "Blur", category="A")
|
||||
self.db.save_prompt_pair("Cat", "Noise", category="A")
|
||||
self.db.save_prompt_pair("Cat", "Grain", category="B")
|
||||
self.db.archive_prompt_pair(archived)
|
||||
self.db.save_prompt_pair("Dog", "Blur", category="B")
|
||||
self.save("Legacy")
|
||||
everything = self.db.list_library()
|
||||
self.assertEqual(everything["variant_total"], 3)
|
||||
self.assertEqual(
|
||||
everything["variant_total"],
|
||||
sum(row["variant_count"] for row in everything["items"]),
|
||||
)
|
||||
cats = self.db.list_library(text="Cat")
|
||||
self.assertEqual(
|
||||
(cats["total"], cats["variant_total"]),
|
||||
(1, 2),
|
||||
)
|
||||
self.assertEqual(self.db.list_library(category="B")["variant_total"], 3)
|
||||
self.assertEqual(self.db.list_library(text="nothing")["variant_total"], 0)
|
||||
first = self.db.list_library(limit=1)
|
||||
self.assertEqual(first["variant_total"], 3)
|
||||
# A later parent is past the cursor's ceiling, like the total.
|
||||
self.db.save_prompt_pair("Bird", "Blur")
|
||||
second = self.db.list_library(limit=1, after=first["next_key"])
|
||||
self.assertEqual(
|
||||
(second["total"], second["variant_total"]),
|
||||
(first["total"], 3),
|
||||
)
|
||||
|
||||
def test_edit_token_unchanged_by_usage_fields(self):
|
||||
positive = self.save("Cat", category="A", tags=["a"], rating=3)
|
||||
|
||||
def parent():
|
||||
return self.db.get_library_positive(positive)["positive"]
|
||||
|
||||
before = parent()
|
||||
listed = self.db.list_library()["items"][0]
|
||||
self.assertEqual(listed["edit_token"], before["edit_token"])
|
||||
self.db.record_prompt_use(positive, times=4)
|
||||
with self.db.model.get_connection() as connection:
|
||||
connection.execute(
|
||||
"UPDATE prompts SET last_used_at='2001-01-01T00:00:00+00:00' "
|
||||
"WHERE id=?",
|
||||
(positive,),
|
||||
)
|
||||
after = parent()
|
||||
self.assertNotEqual(
|
||||
(after["run_count"], after["last_used_at"]),
|
||||
(before["run_count"], before["last_used_at"]),
|
||||
)
|
||||
self.assertEqual(after["edit_token"], before["edit_token"])
|
||||
self.assertEqual(
|
||||
self.db.list_library()["items"][0]["edit_token"], before["edit_token"]
|
||||
)
|
||||
|
||||
def test_library_snapshot_reads_epoch_and_ceiling_in_one_select_and_ends(self):
|
||||
self.save("Cat")
|
||||
connection = self.db.model.get_connection()
|
||||
@@ -1584,3 +1693,16 @@ class TestLibrary(unittest.TestCase):
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "does not belong to this positive"):
|
||||
self.db.list_library_images(positive, pair_id=foreign, archive="all")
|
||||
|
||||
|
||||
class DisplayTitleUnits(unittest.TestCase):
|
||||
"""The first-line title is cut like the Library's JavaScript title()."""
|
||||
|
||||
def test_title_counts_utf16_units_and_never_splits_a_surrogate_pair(self):
|
||||
from database.library import display_title
|
||||
|
||||
self.assertEqual(display_title((), " short line \nsecond"), "short line")
|
||||
self.assertEqual(display_title((), "a" * 80), "a" * 80)
|
||||
self.assertEqual(display_title((), "a" * 81), "a" * 80 + "…")
|
||||
self.assertEqual(display_title((), "\U0001f600" * 80), "\U0001f600" * 40 + "…")
|
||||
self.assertEqual(display_title((), "a" * 79 + "\U0001f600b"), "a" * 79 + "…")
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""The concept fixture files carry the concept inspector facts (app.js:400-414)."""
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
try:
|
||||
from tests.open_handles import assert_closed
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from open_handles import assert_closed
|
||||
|
||||
from tests.browser.workspace_concept_fixture import (
|
||||
CONCEPT_MODEL,
|
||||
CONCEPT_PROMPTS,
|
||||
write_concept_png,
|
||||
)
|
||||
from utils.prompt_inspector import inspect_prompt_evidence, read_inspection_file
|
||||
|
||||
|
||||
class TestWorkspaceConceptFixture(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.addCleanup(assert_closed, self, self.tmp.name)
|
||||
|
||||
def write(self, prompt_index, round_index):
|
||||
prompt = CONCEPT_PROMPTS[prompt_index]
|
||||
path = Path(self.tmp.name) / f"{prompt.id}_{round_index + 1:05}.png"
|
||||
image = write_concept_png(path, prompt_index, round_index)
|
||||
return path, image
|
||||
|
||||
def test_each_image_carries_execution_evidence_and_the_concept_facts(self):
|
||||
for prompt_index, round_index in ((0, 0), (0, 2), (3, 1), (5, 3)):
|
||||
with self.subTest(prompt=prompt_index, round=round_index):
|
||||
prompt = CONCEPT_PROMPTS[prompt_index]
|
||||
path, image = self.write(prompt_index, round_index)
|
||||
negative = prompt.negatives[round_index % len(prompt.negatives)][1]
|
||||
inspection = read_inspection_file(path)
|
||||
evidence = inspect_prompt_evidence(inspection.metadata)
|
||||
# D21: "execution" is the source the panel badges "Execution recorded".
|
||||
self.assertEqual(
|
||||
evidence["prompts"]["positive"],
|
||||
{
|
||||
"status": "resolved",
|
||||
"text": prompt.positive,
|
||||
"source": "execution",
|
||||
},
|
||||
)
|
||||
self.assertEqual(evidence["prompts"]["negative"]["text"], negative)
|
||||
self.assertEqual(evidence["prompts"]["negative"]["source"], "execution")
|
||||
graph = json.loads(inspection.metadata["prompt"])
|
||||
sampler = next(
|
||||
node["inputs"]
|
||||
for node in graph.values()
|
||||
if node["class_type"] == "KSampler"
|
||||
)
|
||||
self.assertEqual(
|
||||
sampler["seed"], 42001 + prompt_index * 100 + round_index
|
||||
)
|
||||
self.assertEqual(sampler["seed"], image["seed"])
|
||||
self.assertEqual(
|
||||
(sampler["sampler_name"], sampler["scheduler"]),
|
||||
("dpmpp_2m", "karras"),
|
||||
)
|
||||
self.assertEqual((sampler["steps"], sampler["cfg"]), (28, 6.5))
|
||||
loader = graph[sampler["model"][0]]
|
||||
self.assertEqual(loader["class_type"], "CheckpointLoaderSimple")
|
||||
self.assertEqual(loader["inputs"]["ckpt_name"], CONCEPT_MODEL)
|
||||
self.assertEqual(CONCEPT_MODEL, "illustrative-model.safetensors")
|
||||
with Image.open(path) as source:
|
||||
self.assertEqual(source.size, (image["width"], image["height"]))
|
||||
|
||||
def test_concept_dimensions_follow_the_prompt_index(self):
|
||||
expected = [(1536, 1152), (1024, 1536), (1280, 1280)]
|
||||
for prompt_index, size in enumerate(expected):
|
||||
_, image = self.write(prompt_index, 1)
|
||||
self.assertEqual((image["width"], image["height"]), size)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Vendored
+104
@@ -0,0 +1,104 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync, copyFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import test from 'node:test';
|
||||
import { lintOverlay } from '../../tools/vendor/sync-concept-css.mjs';
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const script = join(root, 'tools/vendor/sync-concept-css.mjs');
|
||||
const concept = join(root, 'docs/concepts/workspace/style.css');
|
||||
const copy = join(root, 'web/js/workspace/concept.css');
|
||||
const sha = path => createHash('sha256').update(readFileSync(path)).digest('hex');
|
||||
|
||||
function walk(dir) {
|
||||
return readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
|
||||
const path = join(dir, entry.name);
|
||||
return entry.isDirectory() ? walk(path) : [path];
|
||||
});
|
||||
}
|
||||
|
||||
test('concept copy is byte-identical', () => {
|
||||
assert.ok(existsSync(copy), 'web/js/workspace/concept.css is missing; run sync-concept-css.mjs --write');
|
||||
assert.equal(sha(copy), sha(concept));
|
||||
const result = spawnSync(process.execPath, [script, '--check'], { cwd: root, encoding: 'utf8' });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
});
|
||||
|
||||
test('check fails on a one-byte drift', () => {
|
||||
const temp = mkdtempSync(join(tmpdir(), 'concept-css-'));
|
||||
try {
|
||||
mkdirSync(join(temp, 'docs/concepts/workspace'), { recursive: true });
|
||||
mkdirSync(join(temp, 'web/js/workspace'), { recursive: true });
|
||||
copyFileSync(concept, join(temp, 'docs/concepts/workspace/style.css'));
|
||||
writeFileSync(join(temp, 'web/js/workspace/concept.css'), Buffer.concat([readFileSync(concept), Buffer.from(' ')]));
|
||||
const run = () => spawnSync(process.execPath, [script, '--check', '--root', temp], { encoding: 'utf8' });
|
||||
const drifted = run();
|
||||
assert.equal(drifted.status, 1);
|
||||
assert.match(drifted.stderr, /drift/i);
|
||||
assert.equal(spawnSync(process.execPath, [script, '--write', '--root', temp]).status, 0);
|
||||
assert.equal(run().status, 0);
|
||||
} finally { rmSync(temp, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('overlay lint requires a decision marker on concept selectors', () => {
|
||||
const conceptCss = '.card, .row { color: red; }\n@media (max-width: 700px) { .card .title { margin: 0; } }\n';
|
||||
assert.deepEqual(lintOverlay(conceptCss, '.error-row { color: red; }').violations, []);
|
||||
assert.equal(lintOverlay(conceptCss, '.x, .card { color: blue; }').violations.length, 1);
|
||||
assert.equal(lintOverlay(conceptCss, '@media (max-width: 700px) { .card .title { margin: 1px; } }').violations.length, 1);
|
||||
assert.deepEqual(lintOverlay(conceptCss, '/* D12 */\n.card { color: blue; }').violations, []);
|
||||
assert.deepEqual(lintOverlay(conceptCss, '.card { /* D3 */ color: blue; }').violations, []);
|
||||
assert.equal(lintOverlay(conceptCss, '/* tweak */ .card { color: blue; }').violations.length, 1);
|
||||
assert.equal(lintOverlay(conceptCss, '/* D1 */ .card { color: blue; }\n.row { gap: 1px; }').violations.length, 1);
|
||||
});
|
||||
|
||||
test('overlay lint accepts only this spec\'s (#249) decision numbers', () => {
|
||||
const conceptCss = '.card { color: red; }\n';
|
||||
assert.deepEqual(lintOverlay(conceptCss, '/* D12 of #249 */ .card { color: blue; }').violations, []);
|
||||
assert.deepEqual(lintOverlay(conceptCss, '/* #249 D3: reason */ .card { color: blue; }').violations, []);
|
||||
// Another issue's decision numbers are not #249 rulings, so they never mark a concept override.
|
||||
assert.equal(lintOverlay(conceptCss, '/* D5 (#245) */ .card { color: blue; }').violations.length, 1);
|
||||
assert.equal(lintOverlay(conceptCss, '/* D2 of #240 and #249 */ .card { color: blue; }').violations.length, 1);
|
||||
assert.equal(lintOverlay(conceptCss, '/* #245 decision 5 */ .card { color: blue; }').violations.length, 1);
|
||||
assert.equal(lintOverlay(conceptCss, '/* AD5 */ .card { color: blue; }').violations.length, 1);
|
||||
});
|
||||
|
||||
test('overlay rules on concept selectors carry a decision marker', () => {
|
||||
const overlay = join(root, 'web/js/workspace/workspace.css');
|
||||
assert.ok(existsSync(overlay), 'web/js/workspace/workspace.css is missing');
|
||||
const { violations } = lintOverlay(readFileSync(concept, 'utf8'), readFileSync(overlay, 'utf8'));
|
||||
assert.deepEqual(violations, []);
|
||||
});
|
||||
|
||||
test('only workspace.html loads workspace CSS', () => {
|
||||
const offenders = walk(join(root, 'web'))
|
||||
.filter(path => /\.(html|js|mjs)$/.test(path) && !path.includes('/lib/') && !path.includes('/shared/'))
|
||||
.filter(path => /\b(concept|workspace)\.css\b/.test(readFileSync(path, 'utf8')))
|
||||
.map(path => path.slice(root.length + 1));
|
||||
assert.ok(offenders.every(path => path === 'web/workspace.html'), `unexpected consumers: ${offenders.join(', ')}`);
|
||||
});
|
||||
|
||||
// Task A2 order: the concept's own order (toolkit, Viewer.js, concept) with the page stylesheets that their own PRs
|
||||
// retire, then the production overlay last.
|
||||
test('workspace.html loads controls, viewer, concept, page CSS and the overlay in order', () => {
|
||||
const html = readFileSync(join(root, 'web/workspace.html'), 'utf8');
|
||||
const hrefs = [...html.matchAll(/<link[^>]+rel="stylesheet"[^>]+href="([^"]+)"/g)].map(match => match[1]);
|
||||
assert.deepEqual(hrefs, [
|
||||
'js/shared/toolkit/controls.css',
|
||||
'lib/viewerjs/viewer.min.css',
|
||||
'js/workspace/concept.css',
|
||||
'js/workspace/images.css',
|
||||
'js/workspace/library.css',
|
||||
'js/workspace/inspector.css',
|
||||
'js/workspace/workspace.css',
|
||||
]);
|
||||
});
|
||||
|
||||
test('the retired shell, vocabulary and editor stylesheets stay deleted', () => {
|
||||
for (const name of ['style.css', 'vocabulary.css', 'library-editor.css']) {
|
||||
assert.equal(existsSync(join(root, 'web/js/workspace', name)), false, `web/js/workspace/${name} must not come back`);
|
||||
}
|
||||
});
|
||||
Vendored
+40
@@ -0,0 +1,40 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import test from 'node:test';
|
||||
|
||||
// Mount-aware URLs (#164): Prompt Manager runs behind reverse proxies with a path prefix, so every src, href or
|
||||
// new URL() the workspace builds from a "/prompt_manager/..." literal must go through resolveUrl (or the api client).
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const SINK = /(\.(?:src|href)\s*=|setAttribute\(\s*['"](?:src|href)['"]\s*,|new URL\()/g;
|
||||
const RAW = /['"`]\/prompt_manager\//;
|
||||
|
||||
export function rawUrlSinks(source) {
|
||||
const found = [];
|
||||
source.split('\n').forEach((line, index) => {
|
||||
for (const match of line.matchAll(SINK)) {
|
||||
const rest = line.slice(match.index + match[0].length);
|
||||
const literal = rest.search(RAW);
|
||||
if (literal !== -1 && !/\bresolve(?:Url)?\s*\(/.test(rest.slice(0, literal))) found.push(`${index + 1}: ${line.trim()}`);
|
||||
}
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
test('the scanner flags raw Prompt Manager URLs in src, href and new URL()', () => {
|
||||
assert.equal(rawUrlSinks("img.src = '/prompt_manager/images/serve/a.png';").length, 1);
|
||||
assert.equal(rawUrlSinks('link.href = `/prompt_manager/admin`;').length, 1);
|
||||
assert.equal(rawUrlSinks("node.setAttribute('href', '/prompt_manager/gallery');").length, 1);
|
||||
assert.equal(rawUrlSinks("const url = new URL('/prompt_manager/x', location.href);").length, 1);
|
||||
assert.deepEqual(rawUrlSinks("img.src = resolveUrl('/prompt_manager/images/serve/a.png');"), []);
|
||||
assert.deepEqual(rawUrlSinks('const u = new URL(resolve(`/prompt_manager/history/${id}`), location.href);'), []);
|
||||
assert.deepEqual(rawUrlSinks("link.href = resolveUrl(route.href);"), []);
|
||||
});
|
||||
|
||||
test('workspace modules build Prompt Manager src, href and URLs only through resolveUrl', () => {
|
||||
const dir = join(root, 'web/js/workspace');
|
||||
const offenders = readdirSync(dir).filter(name => name.endsWith('.mjs'))
|
||||
.flatMap(name => rawUrlSinks(readFileSync(join(dir, name), 'utf8')).map(hit => `${name}:${hit}`));
|
||||
assert.deepEqual(offenders, []);
|
||||
});
|
||||
+13
-1
@@ -161,6 +161,12 @@
|
||||
"functions": 88,
|
||||
"statements": 95
|
||||
},
|
||||
"workspace/heading.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/history-media.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 86,
|
||||
@@ -205,7 +211,7 @@
|
||||
},
|
||||
"workspace/index.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 95,
|
||||
"branches": 96,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
@@ -293,6 +299,12 @@
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/topbar.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 83,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/viewer-state.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 95,
|
||||
|
||||
Vendored
+2
-1
@@ -5,7 +5,8 @@
|
||||
"description": "Maintainer-only tools for reproducible browser dependencies",
|
||||
"scripts": {
|
||||
"sync": "node vendor-viewerjs.mjs",
|
||||
"check": "node vendor-viewerjs.mjs --check && node vendor-toolkit.mjs --check",
|
||||
"check": "node vendor-viewerjs.mjs --check && node vendor-toolkit.mjs --check && node sync-concept-css.mjs --check",
|
||||
"sync:concept": "node sync-concept-css.mjs --write",
|
||||
"sync:toolkit": "node vendor-toolkit.mjs",
|
||||
"update": "node update-viewerjs.mjs",
|
||||
"test": "node --test ../../tests/vendor/*.test.mjs",
|
||||
|
||||
Vendored
+130
@@ -0,0 +1,130 @@
|
||||
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { repositoryRoot } from './vendor-viewerjs.mjs';
|
||||
|
||||
const CONCEPT = 'docs/concepts/workspace/style.css';
|
||||
const COPY = 'web/js/workspace/concept.css';
|
||||
const MARKER = /\/\*[^*]*\bD\d+\b[^*]*\*\//;
|
||||
const SPEC_ISSUE = '#249';
|
||||
|
||||
// A decision marker names one of this spec's (#249) D-numbers. A comment that cites any other issue is that issue's
|
||||
// ruling (for example #245's own D5), so it never marks a concept override here.
|
||||
function isDecisionMarker(comment) {
|
||||
return MARKER.test(comment) && (comment.match(/#\d+\b/g) ?? []).every(issue => issue === SPEC_ISSUE);
|
||||
}
|
||||
|
||||
// Minimal tokenizer: walks top-level rules and the bodies of @media/@supports,
|
||||
// returning each rule's normalized selectors and the comments attached to it
|
||||
// (those before its selector and those inside its block).
|
||||
function parseRules(css) {
|
||||
const rules = [];
|
||||
let index = 0;
|
||||
const length = css.length;
|
||||
function readBlock(start) {
|
||||
let depth = 0;
|
||||
let cursor = start;
|
||||
for (; cursor < length; cursor += 1) {
|
||||
if (css.startsWith('/*', cursor)) { cursor = commentEnd(cursor) - 1; continue; }
|
||||
const char = css[cursor];
|
||||
if (char === '"' || char === "'") { cursor = stringEnd(cursor) - 1; continue; }
|
||||
if (char === '{') depth += 1;
|
||||
else if (char === '}') { depth -= 1; if (depth === 0) return cursor; }
|
||||
}
|
||||
return length - 1;
|
||||
}
|
||||
function commentEnd(at) { const end = css.indexOf('*/', at + 2); return end === -1 ? length : end + 2; }
|
||||
function stringEnd(at) {
|
||||
for (let cursor = at + 1; cursor < length; cursor += 1) {
|
||||
if (css[cursor] === '\\') cursor += 1;
|
||||
else if (css[cursor] === css[at]) return cursor + 1;
|
||||
}
|
||||
return length;
|
||||
}
|
||||
function walk(from, to) {
|
||||
let cursor = from;
|
||||
let comments = [];
|
||||
let prelude = '';
|
||||
while (cursor < to) {
|
||||
if (css.startsWith('/*', cursor)) {
|
||||
const end = commentEnd(cursor);
|
||||
comments.push(css.slice(cursor, end));
|
||||
cursor = end;
|
||||
continue;
|
||||
}
|
||||
const char = css[cursor];
|
||||
if (char === ';') { prelude = ''; comments = []; cursor += 1; continue; }
|
||||
if (char === '{') {
|
||||
const close = readBlock(cursor);
|
||||
const head = prelude.trim();
|
||||
const body = css.slice(cursor + 1, close);
|
||||
if (/^@(media|supports|layer)\b/i.test(head)) walk(cursor + 1, close);
|
||||
else if (!head.startsWith('@')) {
|
||||
rules.push({ selectors: splitSelectors(head), comments: [...comments, ...(body.match(/\/\*[\s\S]*?\*\//g) ?? [])] });
|
||||
}
|
||||
prelude = ''; comments = []; cursor = close + 1;
|
||||
continue;
|
||||
}
|
||||
prelude += char;
|
||||
cursor += 1;
|
||||
}
|
||||
}
|
||||
walk(index, length);
|
||||
return rules;
|
||||
}
|
||||
|
||||
function splitSelectors(head) {
|
||||
const out = [];
|
||||
let depth = 0;
|
||||
let current = '';
|
||||
for (const char of head) {
|
||||
if (char === '(' || char === '[') depth += 1;
|
||||
else if (char === ')' || char === ']') depth -= 1;
|
||||
if (char === ',' && depth === 0) { out.push(current); current = ''; } else current += char;
|
||||
}
|
||||
out.push(current);
|
||||
return out.map(selector => selector.replace(/\s+/g, ' ').trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
export function lintOverlay(conceptCss, overlayCss) {
|
||||
const owned = new Set(parseRules(conceptCss).flatMap(rule => rule.selectors));
|
||||
const violations = [];
|
||||
for (const rule of parseRules(overlayCss)) {
|
||||
const shared = rule.selectors.filter(selector => owned.has(selector));
|
||||
if (shared.length && !rule.comments.some(isDecisionMarker)) {
|
||||
violations.push(`${shared.join(', ')}: overlay rule on a concept selector needs a /* D<n> */ (#249) decision marker`);
|
||||
}
|
||||
}
|
||||
return { violations };
|
||||
}
|
||||
|
||||
export function syncConceptCss({ root = repositoryRoot, check = false } = {}) {
|
||||
const source = readFileSync(join(root, CONCEPT));
|
||||
const target = join(root, COPY);
|
||||
if (check) {
|
||||
let current = null;
|
||||
try { current = readFileSync(target); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
||||
if (!current) throw new Error(`Concept stylesheet drift: ${COPY} is missing; run sync-concept-css.mjs --write`);
|
||||
if (!current.equals(source)) {
|
||||
let offset = 0;
|
||||
while (offset < source.length && offset < current.length && source[offset] === current[offset]) offset += 1;
|
||||
throw new Error(`Concept stylesheet drift: ${COPY} (${current.length} bytes) differs from ${CONCEPT} (${source.length} bytes) at byte ${offset}; run sync-concept-css.mjs --write`);
|
||||
}
|
||||
} else {
|
||||
mkdirSync(join(root, 'web/js/workspace'), { recursive: true });
|
||||
writeFileSync(target, source);
|
||||
}
|
||||
return source.length;
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
||||
try {
|
||||
const args = process.argv.slice(2);
|
||||
const rootAt = args.indexOf('--root');
|
||||
const root = rootAt === -1 ? repositoryRoot : resolve(args[rootAt + 1]);
|
||||
const mode = args.filter((arg, at) => arg.startsWith('--') && arg !== '--root' && !(rootAt !== -1 && at === rootAt + 1));
|
||||
if (mode.length !== 1 || !['--write', '--check'].includes(mode[0])) throw new Error('Usage: sync-concept-css.mjs --write | --check [--root <dir>]');
|
||||
const bytes = syncConceptCss({ root, check: mode[0] === '--check' });
|
||||
console.log(`Concept stylesheet ${mode[0] === '--check' ? 'verified' : 'written'} (${bytes} bytes)`);
|
||||
} catch (error) { console.error(error.message); process.exitCode = 1; }
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Workspace card facts from the metadata records a header open already loaded.
|
||||
|
||||
The card shows what the inspector shows, so it reads exactly what the
|
||||
inspector's browser parser reads: each ``prompt``/``workflow`` record through
|
||||
the inspection projection (``project_graph``: strict JSON, the depth and node
|
||||
bounds, big integers and decimals as text), and only graphs it parsed, through
|
||||
the shared parser (``extract_generation_params``, the Python twin of the
|
||||
browser parser the inspector fills from since #251). No separate seed
|
||||
heuristic, no graph resolution and no extra file reads. One parse feeds both
|
||||
the seed and the positive that confirms a first-line card title.
|
||||
"""
|
||||
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
|
||||
from .generation_params import (
|
||||
PLACEHOLDERS,
|
||||
GenerationParamsError,
|
||||
extract_generation_params,
|
||||
)
|
||||
from .inspection_response import project_graph
|
||||
|
||||
__all__ = [
|
||||
"MAX_RECORD_BYTES",
|
||||
"MAX_SEED_DIGITS",
|
||||
"card_seed",
|
||||
"header_params",
|
||||
"parsed_positive",
|
||||
]
|
||||
|
||||
# Each record is parsed only up to this many UTF-8 bytes; past it, nothing is read.
|
||||
MAX_RECORD_BYTES = 2 * 1024 * 1024
|
||||
_RECORD_KEYS = ("prompt", "workflow")
|
||||
# A digit-string seed has at most this many digits (2**64 has 20). Longer text
|
||||
# is no seed, and never reaches int(), whose max-str-digits guard raises.
|
||||
MAX_SEED_DIGITS = 20
|
||||
_INTEGER = re.compile(r"-?[0-9]{1,%d}\Z" % MAX_SEED_DIGITS)
|
||||
|
||||
|
||||
def _utf8_size_over(text: str, limit: int) -> bool:
|
||||
"""True when ``text`` is over ``limit`` UTF-8 bytes, encoding only if unclear.
|
||||
|
||||
A code point takes one to four bytes, so most records are decided from
|
||||
their length alone without copying them.
|
||||
"""
|
||||
if len(text) > limit:
|
||||
return True
|
||||
if len(text) * 4 <= limit:
|
||||
return False
|
||||
return len(text.encode("utf-8", "surrogatepass")) > limit
|
||||
|
||||
|
||||
def header_params(info) -> dict | None:
|
||||
"""The shared parser's result for the inspector's view of ``info``'s records.
|
||||
|
||||
``info`` is a Pillow ``Image.info`` (or the inspection read's metadata).
|
||||
A record the projection does not parse is left out, as the inspector
|
||||
leaves it out. None when no record parses, a record is not text or is
|
||||
over ``MAX_RECORD_BYTES``, or the parser cannot use the graphs.
|
||||
"""
|
||||
if not isinstance(info, Mapping):
|
||||
return None
|
||||
graphs = {}
|
||||
for key in _RECORD_KEYS:
|
||||
text = info.get(key)
|
||||
if text is None:
|
||||
continue
|
||||
if not isinstance(text, str) or _utf8_size_over(text, MAX_RECORD_BYTES):
|
||||
return None
|
||||
graph = project_graph(text, key)
|
||||
if graph["status"] == "parsed":
|
||||
graphs[key] = graph["value"]
|
||||
if not graphs:
|
||||
return None
|
||||
try:
|
||||
return extract_generation_params(graphs)
|
||||
except (GenerationParamsError, RecursionError):
|
||||
# Defence in depth: a parser failure gives no card facts, never a 500.
|
||||
return None
|
||||
|
||||
|
||||
def card_seed(params: dict | None) -> str | None:
|
||||
"""The parsed seed as a decimal string, else None.
|
||||
|
||||
Always text: ``JSON.parse`` in the browser rounds a number past 2**53, and
|
||||
ComfyUI's randomized seeds are often that large. The projection carries
|
||||
integers beyond 2**53 as their exact digits, so a digit string is the
|
||||
recorded seed (the inspector shows it too), up to ``MAX_SEED_DIGITS``
|
||||
digits. A placeholder, other or longer text, a boolean or a decimal gives
|
||||
None.
|
||||
"""
|
||||
if not isinstance(params, Mapping):
|
||||
return None
|
||||
value = params.get("seed")
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
try:
|
||||
if isinstance(value, int):
|
||||
return str(value)
|
||||
if isinstance(value, str) and _INTEGER.match(value):
|
||||
return str(int(value))
|
||||
except ValueError:
|
||||
# Python's integer string conversion limit: no seed, never a 500.
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def parsed_positive(params: dict | None) -> str | None:
|
||||
"""The parsed positive prompt text, or None for the placeholder or blank."""
|
||||
if not isinstance(params, Mapping):
|
||||
return None
|
||||
text = params.get("positivePrompt")
|
||||
if not isinstance(text, str) or text == PLACEHOLDERS["positivePrompt"]:
|
||||
return None
|
||||
return text if text.strip() else None
|
||||
@@ -217,10 +217,33 @@ def _js_string(value):
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if isinstance(value, list):
|
||||
return ",".join("" if _nullish(item) else _js_string(item) for item in value)
|
||||
return _array_string(value)
|
||||
return "[object Object]"
|
||||
|
||||
|
||||
def _array_string(value):
|
||||
"""``String(array)``: ``join(",")`` applied to nested arrays, on an explicit stack.
|
||||
|
||||
null and undefined elements join as "". A crafted file can nest arrays far
|
||||
deeper than the Python stack allows, so this never recurses.
|
||||
"""
|
||||
out = []
|
||||
stack = [(value, 0)]
|
||||
while stack:
|
||||
items, index = stack.pop()
|
||||
if index == len(items):
|
||||
continue
|
||||
if index:
|
||||
out.append(",")
|
||||
stack.append((items, index + 1))
|
||||
item = items[index]
|
||||
if isinstance(item, list):
|
||||
stack.append((item, 0))
|
||||
elif not _nullish(item):
|
||||
out.append(_js_string(item))
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _key(value):
|
||||
return value if isinstance(value, str) else _js_string(value)
|
||||
|
||||
|
||||
@@ -161,6 +161,21 @@ def _graph(value: object, *, workflow: bool = False) -> dict:
|
||||
return {"status": "parsed", "value": value}
|
||||
|
||||
|
||||
def project_graph(text: str, kind: Literal["prompt", "workflow"]) -> dict:
|
||||
"""One record's graph as the inspector projects it: ``{status, value}``.
|
||||
|
||||
``status`` is ``parsed`` (``value`` is the projected graph the browser
|
||||
parser receives), ``limited`` (past the depth or node bound) or
|
||||
``malformed``. Shared with the workspace card so both read one bound.
|
||||
"""
|
||||
try:
|
||||
return _graph(_strict_projection(text, kind=kind), workflow=kind == "workflow")
|
||||
except _ProjectionLimit:
|
||||
return {"status": "limited", "value": None}
|
||||
except (ValueError, TypeError, KeyError, OverflowError):
|
||||
return {"status": "malformed", "value": None}
|
||||
|
||||
|
||||
def _metadata(inspection: "InspectionRead | None") -> dict:
|
||||
formats = (
|
||||
{}
|
||||
@@ -213,16 +228,15 @@ def _metadata(inspection: "InspectionRead | None") -> dict:
|
||||
graph["status"] = failed or "limited"
|
||||
elif formats.get(key) == "invalid":
|
||||
graph["status"] = "malformed"
|
||||
elif carrier is None and formats.get(key) == "reserialized-json":
|
||||
graph["status"] = "limited"
|
||||
elif carrier is None:
|
||||
graph = project_graph(raw.get(key), key)
|
||||
else:
|
||||
try:
|
||||
if carrier is not None:
|
||||
value = carrier.get(key)
|
||||
if isinstance(value, str):
|
||||
value = _strict_projection(value, kind=key)
|
||||
elif formats.get(key) == "reserialized-json":
|
||||
raise _ProjectionLimit()
|
||||
else:
|
||||
value = _strict_projection(raw[key], kind=key)
|
||||
value = carrier.get(key)
|
||||
if isinstance(value, str):
|
||||
value = _strict_projection(value, kind=key)
|
||||
graph = _graph(value, workflow=key == "workflow")
|
||||
except _ProjectionLimit:
|
||||
graph["status"] = "limited"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
// The concept's page heading (docs/concepts/workspace/app.js heading(), 303-305), shared by every workspace route.
|
||||
|
||||
function element(tag, className, text) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
function partsOf(heading) {
|
||||
return {
|
||||
heading,
|
||||
h1: heading.querySelector(':scope > div > h1'),
|
||||
description: heading.querySelector(':scope > div > p.muted'),
|
||||
actions: heading.querySelector(':scope > .heading-actions'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace `main`'s content with `.heading` > div (`h1#page-title[tabindex=-1]`, `p.muted`) + `.heading-actions`.
|
||||
* The h1 is the focus target after a save, and the shell focuses it on every route change after the first, as the
|
||||
* concept focuses its page title on hashchange (app.js 1168). The skip link focuses `main` instead.
|
||||
*/
|
||||
export function renderHeading(main, { title = '', description = '', actions = [] } = {}) {
|
||||
const heading = element('div', 'heading');
|
||||
const copy = element('div');
|
||||
const h1 = element('h1', undefined, title);
|
||||
h1.id = 'page-title';
|
||||
h1.tabIndex = -1;
|
||||
copy.append(h1, element('p', 'muted', description));
|
||||
const actionRow = element('div', 'heading-actions');
|
||||
actionRow.append(...actions);
|
||||
heading.append(copy, actionRow);
|
||||
main.replaceChildren(heading);
|
||||
return partsOf(heading);
|
||||
}
|
||||
|
||||
/** The heading the shell rendered into `main`, or null when a view is mounted without one. */
|
||||
export function pageHeading(main) {
|
||||
const heading = main.querySelector(':scope > .heading');
|
||||
return heading ? partsOf(heading) : null;
|
||||
}
|
||||
@@ -3,7 +3,8 @@ const paths = Object.freeze({
|
||||
"library": "M4 4h5v16H4z M11 4h4v16h-4z M17 5l3-1 3 15-3 1z",
|
||||
"tags": "M3 3h8l10 10-8 8L3 11V3ZM7 7h.01",
|
||||
"upload": "M12 16V3m-5 5 5-5 5 5M3 15v6h18v-6",
|
||||
"overview": "M3 12h4l3-8 4 16 3-8h4",
|
||||
// D28 ruling: an outline home glyph of its own; the concept's `activity` pulse stays with the Activity row D5 omits.
|
||||
"overview": "M3 11 12 4l9 7M5 10v10h14V10M10 20v-6h4v6",
|
||||
"settings": "M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8ZM12 2v3m0 14v3M2 12h3m14 0h3M5 5l2 2m10 10 2 2M5 19l2-2M17 7l2-2",
|
||||
"grid": "M3 3h7v7H3z M14 3h7v7h-7z M3 14h7v7H3z M14 14h7v7h-7z",
|
||||
"list": "M9 5h12M9 12h12M9 19h12M3 5h.01M3 12h.01M3 19h.01",
|
||||
@@ -13,7 +14,13 @@ const paths = Object.freeze({
|
||||
"copy": "M8 8h13v13H8z M16 8V3H3v13h5",
|
||||
"send": "m22 2-7 20-4-9-9-4L22 2ZM22 2 11 13",
|
||||
"plus": "M12 4v16M4 12h16",
|
||||
"branch": "M6 3v12M18 9a9 9 0 0 1-9 9M9 18a3 3 0 1 1-6 0 3 3 0 0 1 6 0M21 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0"
|
||||
"branch": "M6 3v12M18 9a9 9 0 0 1-9 9M9 18a3 3 0 1 1-6 0 3 3 0 0 1 6 0M21 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0",
|
||||
// The concept's remaining vocabulary (docs/concepts/workspace/app.js `paths`), for the pages rebuilt next.
|
||||
"star": "m12 3 2.8 5.7 6.2.9-4.5 4.4 1.1 6.2-5.6-3-5.6 3 1.1-6.2L3 9.6l6.2-.9L12 3Z",
|
||||
"tagImage": "M3 4h8l9 9-7 7L3 10V4ZM7 8h.01M18 2v5M15.5 4.5h5",
|
||||
"folder": "M3 5h7l2 3h9v12H3z",
|
||||
"history": "M3 11a9 9 0 1 1 2 7M3 4v7h7M12 7v6l4 2",
|
||||
"review": "M4 3h16v18H4zM8 7h8M8 12h8M8 17h5"
|
||||
});
|
||||
|
||||
/** Create an approved decorative outline icon without injecting markup. */
|
||||
@@ -24,7 +31,7 @@ export function createWorkspaceIcon(name, doc = document) {
|
||||
const namespace = 'http://www.w3.org/2000/svg';
|
||||
const icon = doc.createElementNS(namespace, 'svg');
|
||||
for (const [attribute, value] of Object.entries({
|
||||
class: 'workspace-icon', viewBox: '0 0 24 24', 'aria-hidden': 'true',
|
||||
class: 'icon', viewBox: '0 0 24 24', 'aria-hidden': 'true',
|
||||
focusable: 'false', fill: 'none', stroke: 'currentColor',
|
||||
'stroke-width': '1.65', 'stroke-linecap': 'round', 'stroke-linejoin': 'round',
|
||||
})) icon.setAttribute(attribute, value);
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
.image-toolbar p { margin: 0; font-size: 11px; }
|
||||
.image-layouts { display: inline-flex; flex-wrap: wrap; gap: 3px; }
|
||||
.image-layouts button { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.image-layouts .workspace-icon { inline-size: 14px; block-size: 14px; }
|
||||
.image-layouts .icon { inline-size: 14px; block-size: 14px; }
|
||||
.ca-tools .image-layouts button[aria-pressed="true"] { background: var(--ca-accent); color: white; border-color: var(--ca-accent); }
|
||||
.images-main { container: images-page / inline-size; }
|
||||
.image-workspace { display: grid; grid-template-columns: minmax(0, 1fr) 310px; gap: 22px; align-items: start; }
|
||||
@@ -29,7 +29,7 @@
|
||||
.image-card-footer strong { display: block; padding-inline-end: 24px; font-size: 11px; line-height: 1.4; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.image-card-footer small { display: block; color: var(--ca-muted); font-size: 9px; line-height: 1.4; margin-block-start: 6px; overflow-wrap: anywhere; }
|
||||
.ca-tools .image-card-footer button { position: absolute; inset-inline-end: 6px; inset-block-start: 5px; inline-size: 26px; block-size: 26px; min-block-size: 26px; padding: 5px; }
|
||||
.image-card-footer .workspace-icon { inline-size: 14px; block-size: 14px; }
|
||||
.image-card-footer .icon { inline-size: 14px; block-size: 14px; }
|
||||
.image-grid[data-layout="list"] .image-card { grid-template-columns: min(160px, 45%) minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); }
|
||||
.image-grid[data-layout="list"] .image-open { border-radius: 6px 0 0 6px; }
|
||||
.image-grid[data-layout="list"] .image-card-footer { align-self: center; min-block-size: 0; }
|
||||
@@ -113,7 +113,7 @@
|
||||
.prompt-actions { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.ca-tools .prompt-actions button { display: inline-flex; align-items: center; justify-content: center; inline-size: 26px; block-size: 26px; min-block-size: 26px; padding: 5px; }
|
||||
.prompt-actions .prompt-send-button span { display: none; }
|
||||
.prompt-actions .workspace-icon { inline-size: 14px; block-size: 14px; }
|
||||
.prompt-actions .icon { inline-size: 14px; block-size: 14px; }
|
||||
.prompt-copy-status { font-size: 11px; margin-block-end: 0; }
|
||||
.prompt-copy-status:empty { display: none; }
|
||||
.image-preview-details .prompt-inspector { margin-block-start: 0; }
|
||||
|
||||
@@ -4,9 +4,11 @@ import { layoutImages, visibleImages, workspaceImageMetrics } from './image-layo
|
||||
import { mountFileInspector } from './file-inspector.mjs';
|
||||
import { createInspectionController } from './inspection-state.mjs';
|
||||
import { createPromptSender } from './prompt-sender.mjs';
|
||||
import { promptRoles } from './prompt-blocks.mjs';
|
||||
import { createWorkspaceIcon } from './icons.mjs';
|
||||
import { createMediaViewer } from './media-viewer.mjs';
|
||||
import { noun } from './plural.mjs';
|
||||
import { pageHeading } from './heading.mjs';
|
||||
|
||||
function el(tag, text, className) {
|
||||
const element = document.createElement(tag);
|
||||
@@ -48,11 +50,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
});
|
||||
main.classList.add('images-main');
|
||||
scope.cleanup(() => main.classList.remove('images-main'));
|
||||
if (!embedded) {
|
||||
const intro = el('p', 'Browse your images, explore the details, keep your creative context.', 'intro');
|
||||
(main.querySelector('.workspace-heading-copy') || main).append(intro);
|
||||
scope.cleanup(() => intro.remove());
|
||||
}
|
||||
const description = embedded ? null : pageHeading(main)?.description;
|
||||
if (description) description.textContent = 'Browse your images, explore the details, keep your creative context.';
|
||||
const filtersBar = el('div', undefined, 'image-filters');
|
||||
const search = el('input');
|
||||
search.value = filters.q;
|
||||
@@ -423,5 +422,12 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
});
|
||||
if (visit.state.loading || visit.state.error) stream.loadMore();
|
||||
} else reset();
|
||||
return {dispose};
|
||||
/** D6: the selected file's prompt pair, only once its inspection loaded with both roles resolved. */
|
||||
function selectedPair() {
|
||||
const {record, prompts, loading, error, refreshRequired} = inspection.state;
|
||||
if (scope.signal.aborted || selection === null || loading || error || refreshRequired || record?.identity?.key !== selection) return null;
|
||||
const roles = promptRoles(prompts);
|
||||
return Object.hasOwn(roles, 'positive') && Object.hasOwn(roles, 'negative') ? roles : null;
|
||||
}
|
||||
return {dispose, selectedPair, sender};
|
||||
}
|
||||
|
||||
+62
-40
@@ -1,11 +1,14 @@
|
||||
import '../api-client.js';
|
||||
import { createButton, createField, createScope, createSelect } from '../shared/toolkit/core.js';
|
||||
import { routes, mountRouter, summarizeStats, routeTitle, isHandoff } from './state.mjs';
|
||||
import { createButton, createScope } from '../shared/toolkit/core.js';
|
||||
import { routes, mountRouter, summarizeStats, routeTitle, isHandoff, loadNavCounts,
|
||||
LIBRARY_WRITE_COUNTS, NAV_COUNT_ROUTES } from './state.mjs';
|
||||
import { mountWorkspaceTheme } from './theme.mjs';
|
||||
import { mountImages } from './images.mjs';
|
||||
import { mountLibrary, mountNewPromptAction } from './library.mjs';
|
||||
import { mountLibrary, mountNewPromptAction, onLibraryWrite } from './library.mjs';
|
||||
import { mountVocabulary } from './vocabulary.mjs';
|
||||
import { createWorkspaceIcon } from './icons.mjs';
|
||||
import { bindCanvasLink, canvasHref } from './topbar.mjs';
|
||||
import { renderHeading } from './heading.mjs';
|
||||
|
||||
const { createApiClient, resolveUrl } = window.ApiClient;
|
||||
|
||||
@@ -26,61 +29,80 @@ function mountWorkspace() {
|
||||
const scope = createScope();
|
||||
const api = createApiClient();
|
||||
const main = document.getElementById('workspace-main');
|
||||
const nav = document.querySelector('nav[aria-label="Workspace"]');
|
||||
const appearance = document.getElementById('appearance-control');
|
||||
const primaryNav = document.getElementById('primary-nav');
|
||||
const systemNav = document.getElementById('system-nav');
|
||||
scope.listen(document.querySelector('.skip'), 'click', event => {
|
||||
event.preventDefault();
|
||||
main.focus();
|
||||
});
|
||||
// D4: the concept's Theme select; "System" keeps following ComfyUI, then the OS.
|
||||
const theme = mountWorkspaceTheme({ root: document.body, scope });
|
||||
const preference = createSelect([['follow', 'Follow ComfyUI'], ['light', 'Light'], ['dark', 'Dark']], '');
|
||||
const preference = document.getElementById('theme');
|
||||
preference.value = theme.preference;
|
||||
const appearanceField = createField('Appearance', preference, 'workspace-appearance');
|
||||
appearanceField.classList.add('workspace-appearance');
|
||||
appearance.replaceChildren(appearanceField);
|
||||
scope.listen(preference, 'change', () => theme.setPreference(preference.value));
|
||||
const icons = { images: 'images', library: 'library', tags: 'tags', inspect: 'upload', overview: 'overview', system: 'settings' };
|
||||
const groups = [['primary', 'WORKSPACE', ['images', 'library', 'tags', 'inspect']], ['tools', 'TOOLS', ['overview', 'system']]];
|
||||
nav.replaceChildren(...groups.map(([name, label, ids]) => {
|
||||
const group = element('div', undefined, `workspace-nav-${name}`);
|
||||
group.append(element('p', label, 'sidebar-label'));
|
||||
for (const id of ids) {
|
||||
const route = routes.find(item => item.id === id);
|
||||
const link = element('a');
|
||||
link.append(createWorkspaceIcon(icons[id]), element('span', route.label));
|
||||
link.href = `#/${route.id}`;
|
||||
link.dataset.route = route.id;
|
||||
group.append(link);
|
||||
}
|
||||
return group;
|
||||
// D6: ComfyUI ↗ opens the canvas; on Images with a resolved selection it sends that pair (D5: no Activity).
|
||||
let images = null;
|
||||
scope.cleanup(bindCanvasLink(document.querySelector('.topbar .canvas-link'), {
|
||||
href: canvasHref(resolveUrl, window.location.pathname),
|
||||
getSelectedPair: () => images?.selectedPair?.() ?? null,
|
||||
sender: {open: (roles, trigger) => images?.sender.open(roles, trigger)},
|
||||
}));
|
||||
// The concept's nav(): icon, label text, then a `.nav-count` only for a loaded real count.
|
||||
const navLinks = ids => ids.map(([id, icon]) => {
|
||||
const route = routes.find(item => item.id === id);
|
||||
const link = element('a');
|
||||
link.append(createWorkspaceIcon(icon), document.createTextNode(route.label));
|
||||
link.href = `#/${route.id}`;
|
||||
link.dataset.route = route.id;
|
||||
return link;
|
||||
});
|
||||
primaryNav.replaceChildren(...navLinks([['images', 'images'], ['library', 'library'], ['tags', 'tags'], ['inspect', 'upload']]));
|
||||
// D28: System, then the production Overview hub, in the concept's tools-and-system group.
|
||||
systemNav.replaceChildren(...navLinks([['system', 'settings'], ['overview', 'overview']]));
|
||||
const sidebar = document.querySelector('aside.sidebar');
|
||||
// One read per count, so a write that refreshes Library and Tags never supersedes an Images read in flight.
|
||||
const countRequests = new Map();
|
||||
function refreshCounts(ids) {
|
||||
for (const id of ids) {
|
||||
countRequests.get(id)?.abort();
|
||||
const request = new AbortController();
|
||||
countRequests.set(id, request);
|
||||
// Each badge lands as its own read settles; a count that failed to refresh is removed, never left stale.
|
||||
loadNavCounts(api, request.signal, (_, count) => {
|
||||
if (request.signal.aborted) return;
|
||||
const link = primaryNav.querySelector(`[data-route="${id}"]`);
|
||||
link?.querySelector('.nav-count')?.remove();
|
||||
if (link && Number.isSafeInteger(count)) link.append(element('span', count.toLocaleString(), 'nav-count'));
|
||||
}, [id]);
|
||||
}
|
||||
}
|
||||
scope.cleanup(() => { for (const request of countRequests.values()) request.abort(); });
|
||||
// A confirmed Library or vocabulary write cannot change the Images count, so only Library and Tags are re-read.
|
||||
scope.cleanup(onLibraryWrite(() => refreshCounts(LIBRARY_WRITE_COUNTS)));
|
||||
refreshCounts(NAV_COUNT_ROUTES);
|
||||
let viewScope;
|
||||
let firstRender = true;
|
||||
scope.cleanup(() => viewScope?.dispose());
|
||||
|
||||
function render(route) {
|
||||
viewScope?.dispose();
|
||||
images = null;
|
||||
viewScope = createScope();
|
||||
const view = viewScope;
|
||||
for (const link of nav.querySelectorAll('[data-route]')) {
|
||||
for (const link of sidebar.querySelectorAll('nav [data-route]')) {
|
||||
if (link.dataset.route === route.id) link.setAttribute('aria-current', 'page');
|
||||
else link.removeAttribute('aria-current');
|
||||
}
|
||||
document.title = `${routeTitle(route)} · Prompt Manager`;
|
||||
const heading = element('h1', route.label);
|
||||
// Focus target after a save, as the concept's page title.
|
||||
heading.tabIndex = -1;
|
||||
const headingCopy = element('div', undefined, 'workspace-heading-copy');
|
||||
headingCopy.append(heading);
|
||||
const headingRegion = element('header', undefined, 'workspace-heading');
|
||||
headingRegion.append(headingCopy);
|
||||
main.replaceChildren(headingRegion);
|
||||
if (!firstRender) main.focus();
|
||||
// The concept's heading(); each view fills its description and actions.
|
||||
// The concept focuses its page title on a hashchange navigation (app.js 1168); the skip link focuses `main`.
|
||||
const { description, h1 } = renderHeading(main, { title: route.label });
|
||||
if (!firstRender) h1.focus();
|
||||
firstRender = false;
|
||||
document.getElementById('workspace-breadcrumb').textContent = routeTitle(route);
|
||||
if (route.id === 'images') {
|
||||
mountNewPromptAction(main, api, view);
|
||||
mountImages(main, api, view);
|
||||
images = mountImages(main, api, view);
|
||||
return;
|
||||
}
|
||||
if (route.id === 'library') {
|
||||
@@ -92,24 +114,24 @@ function mountWorkspace() {
|
||||
return;
|
||||
}
|
||||
if (isHandoff(route)) {
|
||||
headingCopy.append(element('p', route.description, 'intro'));
|
||||
const panel = element('section', undefined, 'panel handoff');
|
||||
description.textContent = route.description;
|
||||
const panel = element('section', undefined, 'surface handoff');
|
||||
panel.append(element('h2', 'Your current tools are ready'),
|
||||
element('p', 'This workspace is being introduced in stages. Continue in the existing tool with your saved data.'),
|
||||
legacyLink(route));
|
||||
main.append(panel);
|
||||
return;
|
||||
}
|
||||
headingCopy.append(element('p', 'A home for your prompts, images and creative history.', 'intro'));
|
||||
description.textContent = 'A home for your prompts, images and creative history.';
|
||||
const stats = element('section', undefined, 'overview-stats');
|
||||
stats.setAttribute('aria-label', 'Library statistics');
|
||||
main.append(stats);
|
||||
const tools = element('section', undefined, 'panel');
|
||||
const tools = element('section', undefined, 'surface');
|
||||
tools.append(element('h2', 'Continue creating'), element('p', 'Your existing tools remain available while the new workspaces take shape.'));
|
||||
const cards = element('div', undefined, 'tool-grid');
|
||||
for (const target of routes.filter(item => item.href)) {
|
||||
const card = element('article', undefined, 'tool-card');
|
||||
card.append(element('h3', target.label), element('p', target.description), legacyLink(target));
|
||||
card.append(element('h3', target.label), element('p', target.description, 'small'), legacyLink(target));
|
||||
cards.append(card);
|
||||
}
|
||||
tools.append(cards);
|
||||
@@ -124,7 +146,7 @@ function mountWorkspace() {
|
||||
const values = summarizeStats(result.stats);
|
||||
const list = element('dl', undefined, 'stat-grid');
|
||||
for (const [label, count] of values) {
|
||||
const stat = element('div', undefined, 'panel stat');
|
||||
const stat = element('div', undefined, 'stat');
|
||||
stat.append(element('dt', label), element('dd', count.toLocaleString()));
|
||||
list.append(stat);
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
.ca-tools .file-inspector-footer :where(button, a) { font-size: 11px; min-block-size: 30px; }
|
||||
.ca-tools .image-preview-details .file-inspector-footer button { white-space: normal; }
|
||||
.file-inspector-footer .workspace-link { display: inline-flex; gap: 7px; border: 1px solid var(--ca-line); border-radius: 4px; padding: 5px 8px; }
|
||||
.file-inspector-footer .workspace-icon { inline-size: 14px; block-size: 14px; }
|
||||
.file-inspector-footer .icon { inline-size: 14px; block-size: 14px; }
|
||||
.file-status { padding: 17px; }
|
||||
@media (max-width: 700px) { .ca-tools .file-cover { block-size: 200px; } }
|
||||
@media (pointer: coarse) {
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
/* Native recipe editor and vocabulary dialogs, and the shared toast: geometry and tokens follow the approved concept (style.css 859-1007, 1424-1531). */
|
||||
.library-editor, .vocabulary-dialog, .toast { --editor-red: #ff8c98; --editor-warning: #e6bd78; }
|
||||
[data-ca-theme="light"] :where(.library-editor, .vocabulary-dialog, .toast) { --editor-red: #b71931; --editor-warning: #815514; }
|
||||
.ca-tools :is(.library-editor, .vocabulary-dialog) { color: var(--ca-text); background: var(--ca-surface); border: 1px solid var(--ca-border); border-radius: 9px; padding: 24px; width: min(650px, calc(100vw - 24px)); max-height: 90dvh; overflow: auto; }
|
||||
.library-editor::backdrop, .vocabulary-dialog::backdrop { background: #080b12b8; }
|
||||
:is(.library-editor, .vocabulary-dialog) .dialog-header { display: flex; justify-content: space-between; align-items: center; gap: 18px; margin-bottom: 20px; }
|
||||
:is(.library-editor, .vocabulary-dialog) .dialog-header h2 { margin: 0; font-size: 18px; font-weight: 600; line-height: inherit; }
|
||||
:is(.library-editor, .vocabulary-dialog) :where(form, p) { margin: 0; line-height: inherit; }
|
||||
:is(.library-editor, .vocabulary-dialog) p + p { margin-top: 10px; }
|
||||
.ca-tools :is(.library-editor, .vocabulary-dialog) button { min-height: 32px; font-size: 12px; }
|
||||
.ca-tools :is(.library-editor, .vocabulary-dialog) .dialog-header > button { font-size: 20px; min-width: 32px; }
|
||||
:is(.library-editor, .vocabulary-dialog) .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; flex-wrap: wrap; margin-top: 23px; }
|
||||
:is(.library-editor, .vocabulary-dialog) .field { display: grid; gap: 7px; font-size: 12px; margin: 15px 0; }
|
||||
:is(.library-editor, .vocabulary-dialog) .field :where(input, textarea, select) { width: 100%; font-size: 12px; }
|
||||
:is(.library-editor, .vocabulary-dialog) .field textarea { min-height: 100px; resize: vertical; }
|
||||
:is(.library-editor, .vocabulary-dialog) .two-col { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
|
||||
:is(.library-editor, .vocabulary-dialog) .small { font-size: 12px; color: var(--ca-muted); }
|
||||
:is(.library-editor, .vocabulary-dialog) .warning-note { font-size: 11px; color: var(--editor-warning); margin: 13px 0; }
|
||||
.ca-tools :is(.library-editor, .vocabulary-dialog) .danger { color: var(--editor-red); border-color: var(--editor-red); }
|
||||
:is(.library-editor, .vocabulary-dialog) :where(button, input, select, textarea):focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 3px; }
|
||||
.toast { position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%); background: var(--ca-surface); border: 1px solid var(--ca-accent); padding: 12px 18px; border-radius: 6px; box-shadow: 0 8px 35px #0004; z-index: 10; max-width: min(650px, 90vw); font-size: 12px; }
|
||||
.toast:empty { display: none; }
|
||||
@media (max-width: 700px) {
|
||||
:is(.library-editor, .vocabulary-dialog) .two-col { grid-template-columns: 1fr; }
|
||||
.toast { bottom: 12px; }
|
||||
}
|
||||
@media (pointer: coarse) { .ca-tools :is(.library-editor, .vocabulary-dialog) button { min-height: 40px; } }
|
||||
@@ -123,16 +123,12 @@ main.library-main { --library-negative: #ff8c98; }
|
||||
.history-run-marker[aria-pressed="true"] { outline-color: Highlight; }
|
||||
}
|
||||
|
||||
/* Heading and panel actions follow the concept (style.css 283-300, 540-549, 1408-1414). */
|
||||
.workspace-heading .heading-actions, .library-main .row-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.ca-tools .workspace-heading .heading-actions button, .ca-tools .library-main .row-actions button { min-block-size: 32px; font-size: 12px; max-inline-size: 100%; white-space: normal; }
|
||||
.workspace-heading .heading-actions { min-inline-size: 0; max-inline-size: 100%; }
|
||||
.ca-tools .workspace-heading button.send { color: var(--ca-focus); border-color: var(--ca-line); display: inline-flex; align-items: center; gap: 6px; }
|
||||
.workspace-heading h1:focus { outline: none; }
|
||||
.workspace-heading h1:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
|
||||
/* Panel actions follow the concept (style.css 294-300, 540-549); the page heading is the concept's own (Task A5).
|
||||
These .row-actions rules restyle the concept's own selector and are retired in PR E (library cards). */
|
||||
.library-main .row-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.ca-tools .library-main .row-actions button { min-block-size: 32px; font-size: 12px; max-inline-size: 100%; white-space: normal; }
|
||||
.library-main .library-panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-block-end: 12px; }
|
||||
@media (max-width: 700px) { .workspace-heading .heading-actions { inline-size: 100%; } }
|
||||
@media (pointer: coarse) { .ca-tools .workspace-heading .heading-actions button, .ca-tools .library-main .row-actions button { min-block-size: 40px; } }
|
||||
@media (pointer: coarse) { .ca-tools .library-main .row-actions button { min-block-size: 40px; } }
|
||||
/* Archived pairs: the existing lane at reduced emphasis (the existing .6 disabled-marker opacity); text stays at full contrast. */
|
||||
.library-main .library-variant-archived::before, .ca-tools .library-main .library-variant-archived .library-variant-dot { opacity: .6; }
|
||||
.ca-tools .library-main .library-variant-restore { position: static; margin-inline-start: 8px; min-block-size: 24px; padding: 2px 8px; font-size: 10px; vertical-align: middle; }
|
||||
|
||||
@@ -10,6 +10,7 @@ import {layoutLibraryRows,visibleLibraryRows} from './library-layout.mjs';
|
||||
import {createWorkspaceIcon} from './icons.mjs';
|
||||
import {mountRecordedHistory} from './history.mjs';
|
||||
import {countOf} from './plural.mjs';
|
||||
import {pageHeading} from './heading.mjs';
|
||||
|
||||
let shelfVisit=null;
|
||||
// Every confirmed receipt advances this; a shelf mounted before it never restores its cached stream.
|
||||
@@ -27,19 +28,40 @@ function el(tag,text,className) {
|
||||
const node=document.createElement(tag);if(text!==undefined) node.textContent=text;if(className) node.className=className;return node;
|
||||
}
|
||||
function link(text,href,className) {const node=el('a',text,className);node.href=href;return node;}
|
||||
function title(text) {const first=text.split('\n')[0].trim();return first.length>80?`${first.slice(0,80)}…`:first;}
|
||||
// Cut at 80 UTF-16 units, never inside a surrogate pair (database/library.py titled() matches).
|
||||
export function libraryTitle(text) {
|
||||
const first=text.split('\n')[0].trim();
|
||||
if (first.length<=80) return first;
|
||||
const cut=first.slice(0,80);
|
||||
return `${/[\uD800-\uDBFF]$/.test(cut)?cut.slice(0,-1):cut}…`;
|
||||
}
|
||||
const title=libraryTitle;
|
||||
/** The full stored title; the first-line fallback only when it is blank. */
|
||||
function displayTitle(positive) {return typeof positive.title==='string' && positive.title.trim()?positive.title:title(positive.text);}
|
||||
function focusHeading(main) {const heading=main.querySelector('h1');if(!heading)return;heading.tabIndex=-1;heading.focus();}
|
||||
/** The shell heading's action row (concept `.heading-actions`); a view mounted without one gets its own row. */
|
||||
function headingActions(main,fallback) {
|
||||
const actions=el('div',undefined,'heading-actions');(main.querySelector('.workspace-heading') || fallback).append(actions);return actions;
|
||||
const actions=pageHeading(main)?.actions;if(actions) return actions;
|
||||
const row=el('div',undefined,'heading-actions');fallback.append(row);return row;
|
||||
}
|
||||
/** The shell heading's description (concept `p.muted`); a view mounted without one shows it at the top of `fallback`. */
|
||||
function headingDescription(main,fallback,text) {
|
||||
const description=pageHeading(main)?.description;
|
||||
if(description) description.textContent=text;else fallback.prepend(el('p',text,'muted'));
|
||||
}
|
||||
|
||||
/** A confirmed receipt makes cached shelf items, cursor and measured heights stale; filters and anchor survive. */
|
||||
const writeListeners=new Set();
|
||||
export function invalidateLibraryAfterWrite(commit) {
|
||||
if(!commit || typeof commit.kind!=='string' || !commit.receipt || typeof commit.receipt!=='object') throw new TypeError('Invalid editor commit');
|
||||
writeGeneration++;
|
||||
if(shelfVisit) shelfVisit={filters:shelfVisit.filters,anchor:shelfVisit.layout?.anchor ?? shelfVisit.anchor ?? null};
|
||||
for(const listener of [...writeListeners]) listener(commit);
|
||||
}
|
||||
/** Observe confirmed Library and vocabulary writes (the sidebar re-reads its counts); returns the unsubscribe. */
|
||||
export function onLibraryWrite(listener) {
|
||||
if(typeof listener!=='function') throw new TypeError('Invalid Library write listener');
|
||||
writeListeners.add(listener);return ()=>{writeListeners.delete(listener);};
|
||||
}
|
||||
/**
|
||||
* Open the Library shelf with exactly these filters (Tags & categories Browse): the
|
||||
@@ -122,7 +144,6 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
const restorable=visit?.state?visit:null;
|
||||
const filters={text:'',category:'',tags:[],sort:'newest',...visit?.filters};
|
||||
const section=el('section',undefined,'library-shelf');section.setAttribute('aria-label','Prompt library');
|
||||
const introduction=el('p','One positive. Reusable negatives. Every experiment stays connected.','intro');
|
||||
const toolbar=el('search',undefined,'library-toolbar');
|
||||
const search=el('input');search.type='search';search.placeholder='Search prompts and saved negatives…';search.value=filters.text;search.maxLength=500;
|
||||
const category=createSelect([['','All categories']], '');category.value=filters.category;
|
||||
@@ -162,7 +183,7 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
} finally {exporting=false;}
|
||||
}
|
||||
scope.listen(exportButton,'click',exportLibrary);
|
||||
(main.querySelector('.workspace-heading-copy') || section).append(introduction);
|
||||
headingDescription(main,section,'One positive. Reusable negatives. Every experiment stays connected.');
|
||||
continuation.append(message,retry,more);section.append(toolbar,optionsStatus,exportStatus,caption,grid,continuation,legacy);main.append(section);
|
||||
const cards=new Map(), rowNodes=new Map(), spacers=new Map(), heights=new Map(), pendingMeasurements=new Map();
|
||||
let state,frame=0,loadFrame=0,observer,optionsRequest,focusOverride=-1;
|
||||
@@ -356,6 +377,7 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
const history=el('section',undefined,'library-history');history.setAttribute('aria-label','Prompt history');main.append(history);
|
||||
const body=el('div');history.append(body);
|
||||
// Concept heading: ← Prompt library, Edit metadata, then the selected pair's Send to ComfyUI.
|
||||
headingDescription(main,history,'One positive prompt. Reusable negative variants. One shared gallery.');
|
||||
const actions=headingActions(main,history);
|
||||
const back=createButton('← Prompt library'),edit=createButton('Edit metadata'),send=createButton('','send');
|
||||
send.append(createWorkspaceIcon('send'),el('span','Send to ComfyUI'));send.disabled=true;actions.append(back,edit,send);
|
||||
@@ -398,7 +420,6 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
if(state.error) {const retry=createButton('Retry prompt history');contentScope.listen(retry,'click',()=>loader.load(positiveId));body.append(retry);}return;
|
||||
}
|
||||
detail=state.detail;const content=contentScope;
|
||||
const subtitle=el('p','One positive prompt. Reusable negative variants. One shared gallery.','intro');
|
||||
const summary=el('article',undefined,'library-surface');const categoryChip=el('span','','library-chip');summary.append(categoryChip);
|
||||
const promptContainer=el('div');promptContainer.setAttribute('role','region');promptContainer.setAttribute('aria-label','Saved prompt texts');summary.append(promptContainer);
|
||||
const parentStatus=el('p','','library-parent-status muted');parentStatus.hidden=true;parentStatus.setAttribute('role','status');
|
||||
@@ -499,7 +520,7 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
const gallery=el('section',undefined,'library-shared-gallery');gallery.setAttribute('aria-label','Shared gallery');gallery.append(el('h2','Shared gallery'),context);
|
||||
const images=el('div',undefined,'library-linked-images');gallery.append(images);
|
||||
const recorded=el('div',undefined,'library-recorded-container');
|
||||
body.append(subtitle,summary,variants,recorded,gallery);
|
||||
body.append(summary,variants,recorded,gallery);
|
||||
const recordFor=pairId=>detail.variants.find(variant=>variant.id===pairId) || archivedList().find(variant=>variant.id===pairId) || (separate()?.id===pairId?separate():null);
|
||||
const archivedSelection=pairId=>archivedList().some(variant=>variant.id===pairId) || (separate()?.id===pairId && separate().archived===true);
|
||||
function rolesFor(pairId) {
|
||||
|
||||
@@ -42,6 +42,41 @@ export function summarizeStats(stats) {
|
||||
});
|
||||
}
|
||||
|
||||
// The sidebar's real counts (D27): one bounded `limit=1` read each, whose `total` is the count.
|
||||
const NAV_COUNT_SOURCES = Object.freeze([
|
||||
['images', '/prompt_manager/images/output?mode=workspace&limit=1'],
|
||||
['library', '/prompt_manager/library?limit=1'],
|
||||
['tags', '/prompt_manager/vocabulary/tags?limit=1'],
|
||||
]);
|
||||
|
||||
/** Route id -> count for the WORKSPACE links; null when a page is missing or its total is not a count. */
|
||||
export function summarizeNavCounts(pages = {}) {
|
||||
return new Map(NAV_COUNT_SOURCES.map(([id]) => {
|
||||
const total = pages?.[id]?.total;
|
||||
return [id, Number.isSafeInteger(total) && total >= 0 ? total : null];
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the three counts; a failed read yields null for its route only. `onCount(id, count)` runs as each read
|
||||
* settles, so a slow listing (the Images output scan) never holds back the cheap Library and Tags counts.
|
||||
*/
|
||||
/** The WORKSPACE routes that carry a count. */
|
||||
export const NAV_COUNT_ROUTES = Object.freeze(NAV_COUNT_SOURCES.map(([id]) => id));
|
||||
|
||||
/** The counts a confirmed Library or vocabulary write can change; the Images listing cannot. */
|
||||
export const LIBRARY_WRITE_COUNTS = Object.freeze(['library', 'tags']);
|
||||
|
||||
/** Read the given WORKSPACE counts (all by default), reporting each as its own read settles. */
|
||||
export async function loadNavCounts(api, signal, onCount = () => {}, ids = NAV_COUNT_ROUTES) {
|
||||
const pages = {};
|
||||
await Promise.all(NAV_COUNT_SOURCES.filter(([id]) => ids.includes(id)).map(async ([id, url]) => {
|
||||
try { pages[id] = await api.get(url, { signal }); } catch { pages[id] = undefined; }
|
||||
onCount(id, summarizeNavCounts(pages).get(id));
|
||||
}));
|
||||
return new Map([...summarizeNavCounts(pages)].filter(([id]) => ids.includes(id)));
|
||||
}
|
||||
|
||||
export function mountRouter(win, onRoute) {
|
||||
let current;
|
||||
const update = () => {
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
:root { color-scheme: light dark; }
|
||||
* { box-sizing: border-box; }
|
||||
body.ca-tools {
|
||||
margin: 0; --workspace-page: #1b1c1f; --workspace-sidebar: #202124;
|
||||
background: var(--workspace-page); --workspace-active: #14364d;
|
||||
font: 14px/1.5 Inter, ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
body[data-ca-theme="light"] { --workspace-page: #f3f4f6; --workspace-sidebar: #fff; --workspace-active: #e4f1fb; }
|
||||
[hidden] { display: none !important; }
|
||||
a { color: var(--ca-focus); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
a:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 3px; }
|
||||
main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
|
||||
.workspace-shell { display: grid; grid-template-columns: 218px minmax(0, 1fr); min-block-size: 100dvh; }
|
||||
.sidebar { background: var(--workspace-sidebar); border-inline-end: 1px solid var(--ca-line); padding: 26px 14px 16px; display: flex; flex-direction: column; gap: 5px; position: sticky; inset-block-start: 0; block-size: 100dvh; align-self: start; overflow-y: auto; }
|
||||
.brand { display: flex; gap: 11px; align-items: center; color: var(--ca-text); font-weight: 650; font-size: 14px; margin: 0 4px 28px; line-height: 1.3; }
|
||||
.brand small { display: block; color: var(--ca-muted); font-size: 9px; letter-spacing: 1.8px; font-weight: 400; margin-block-start: 6px; }
|
||||
.brand-mark { flex: 0 0 34px; inline-size: 34px; block-size: 36px; color: var(--ca-focus); font-size: 29px; position: relative; }
|
||||
.brand-mark > span { font-size: 15px; position: absolute; inset-block-end: -2px; inset-inline-end: -2px; }
|
||||
.eyebrow { font-size: 10px; letter-spacing: 1.6px; text-transform: uppercase; color: var(--ca-muted); margin: 0; }
|
||||
.sidebar-label { margin: 14px 12px 6px; font-size: 9px; font-weight: 650; letter-spacing: 1.1px; color: var(--ca-muted); }
|
||||
nav[aria-label="Workspace"] { display: flex; flex-direction: column; flex: 1; gap: 5px; }
|
||||
.workspace-nav-primary, .workspace-nav-tools { display: grid; gap: 3px; }
|
||||
.workspace-nav-tools { margin-block-start: auto; padding-block-start: 32px; }
|
||||
nav [data-route] { display: flex; align-items: center; gap: 11px; color: var(--ca-muted); padding: 8px 12px; border-radius: 6px; min-block-size: 38px; font-size: 12px; overflow-wrap: anywhere; }
|
||||
nav [data-route]:hover { background: var(--ca-field); color: var(--ca-text); text-decoration: none; }
|
||||
nav [aria-current="page"] { color: var(--ca-focus); background: var(--workspace-active); }
|
||||
.workspace-icon { display: block; inline-size: 17px; block-size: 17px; flex: 0 0 auto; }
|
||||
.sidebar-footer { margin: 17px 6px 0; padding-block-start: 17px; border-block-start: 1px solid var(--ca-line); color: var(--ca-muted); font-size: 10px; display: flex; align-items: center; gap: 8px; }
|
||||
.connection-dot { inline-size: 6px; block-size: 6px; border-radius: 50%; background: var(--ca-muted); flex: 0 0 auto; }
|
||||
.workspace-body { min-inline-size: 0; display: flex; flex-direction: column; }
|
||||
.topbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; block-size: 68px; padding-inline: 30px; border-block-end: 1px solid var(--ca-line); font-size: 12px; }
|
||||
#appearance-control { min-inline-size: 0; max-inline-size: 100%; }
|
||||
.ca-tools .workspace-appearance { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; min-inline-size: 0; max-inline-size: 100%; font-size: 11px; }
|
||||
.ca-tools .workspace-appearance select { min-inline-size: 0; max-inline-size: 100%; font-size: 11px; }
|
||||
.muted { color: var(--ca-muted); }
|
||||
main { flex: 1; padding: 30px; inline-size: 100%; max-inline-size: 1900px; min-inline-size: 0; margin-inline: auto; }
|
||||
h1 { font-size: 28px; line-height: 1.3; letter-spacing: -0.7px; margin: 0; font-weight: 620; overflow-wrap: anywhere; }
|
||||
.workspace-heading { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-block-end: 22px; }
|
||||
.workspace-heading-copy { min-inline-size: 0; }
|
||||
.workspace-heading-copy .intro { font-size: 12px; line-height: 1.5; margin: 6px 0 0; }
|
||||
h2 { font-size: 18px; margin: 0 0 8px; }
|
||||
h3 { font-size: 15px; margin: 0; }
|
||||
p { line-height: 1.6; }
|
||||
.intro { color: var(--ca-muted); margin: 0 0 30px; }
|
||||
.panel { border: 1px solid var(--ca-line); border-radius: 11px; padding: 24px; background: var(--ca-surface); }
|
||||
.panel > p { color: var(--ca-muted); margin: 0 0 24px; }
|
||||
.stat-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; margin: 0 0 26px; }
|
||||
.stat dt { color: var(--ca-muted); font-size: 12px; }
|
||||
.stat dd { margin: 8px 0 0; font-size: 32px; font-weight: 600; }
|
||||
.overview-stats > [role="status"], .overview-stats > [role="alert"], .empty-state { padding: 18px 0; color: var(--ca-muted); }
|
||||
.overview-stats > [role="alert"] { color: var(--ca-error-text); }
|
||||
.overview-stats > button { margin-bottom: 24px; }
|
||||
.tool-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 250px), 1fr)); gap: 12px; }
|
||||
.tool-card { border: 1px solid var(--ca-line); background: var(--ca-field); border-radius: 8px; padding: 20px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.tool-card p { color: var(--ca-muted); margin: 0 0 12px; font-size: 12px; flex: 1; }
|
||||
.workspace-link { display: inline-flex; align-items: center; min-height: 44px; padding: 8px 0; font-size: 12px; overflow-wrap: anywhere; }
|
||||
.page-footer { display: flex; gap: 20px; justify-content: space-between; margin: 0 32px; padding: 20px 0; border-top: 1px solid var(--ca-line); color: var(--ca-muted); font-size: 11px; }
|
||||
.skip { position: fixed; top: 10px; left: 10px; z-index: 10; clip-path: inset(50%); width: 1px; height: 1px; overflow: hidden; }
|
||||
.skip:focus { clip-path: none; width: auto; height: auto; padding: 12px; background: var(--ca-surface); }
|
||||
@media (max-width: 1250px) {
|
||||
.workspace-shell { grid-template-columns: 185px minmax(0, 1fr); }
|
||||
.sidebar { padding-inline: 9px; }
|
||||
.brand { font-size: 12px; }
|
||||
.topbar { padding-inline: 24px; }
|
||||
main { padding: 24px; }
|
||||
}
|
||||
@media (max-width: 950px) {
|
||||
.workspace-shell { grid-template-columns: minmax(0, 1fr); }
|
||||
.sidebar { position: static; block-size: auto; padding: 12px 18px; border-inline-end: 0; border-block-end: 1px solid var(--ca-line); overflow: visible; }
|
||||
.brand { margin: 0 0 12px; }
|
||||
.sidebar-label, .sidebar-footer { display: none; }
|
||||
.workspace-nav-primary, .workspace-nav-tools { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.workspace-nav-tools { margin: 0; padding-block-start: 5px; border-block-start: 1px solid var(--ca-line); }
|
||||
nav [data-route] { min-block-size: 32px; padding: 5px 10px; }
|
||||
.topbar { block-size: auto; min-block-size: 58px; padding: 10px 24px; flex-wrap: wrap; }
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
main { padding: 20px 14px; }
|
||||
.topbar { padding-inline: 14px; }
|
||||
.topbar > span { overflow-wrap: anywhere; }
|
||||
.workspace-heading { align-items: start; flex-direction: column; }
|
||||
h1 { font-size: 25px; }
|
||||
nav [data-route] { font-size: 11px; gap: 6px; }
|
||||
nav .workspace-icon { inline-size: 14px; block-size: 14px; }
|
||||
.brand { font-size: 14px; }
|
||||
.stat-grid { gap: 8px; }
|
||||
.stat { padding: 16px; }
|
||||
.stat dd { font-size: 26px; }
|
||||
.panel { padding: 18px; }
|
||||
.page-footer { margin: 0 14px; flex-wrap: wrap; }
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
nav a[aria-current="page"] { border: 2px solid Highlight; color: LinkText; }
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
const STORAGE_KEY = 'pm_workspace_theme';
|
||||
const PREFERENCES = new Set(['follow', 'light', 'dark']);
|
||||
// D4 (#249): the concept's Theme select. "system" follows the ComfyUI palette, then the OS.
|
||||
const PREFERENCES = new Set(['dark', 'light', 'system']);
|
||||
// The pre-#249 "Follow ComfyUI" value, read as System so a saved choice survives the rename.
|
||||
const LEGACY = new Map([['follow', 'system']]);
|
||||
const LIGHT_PALETTES = new Set(['light', 'solarized', 'github', 'arc']);
|
||||
const DARK_PALETTES = new Set(['dark', 'obsidian', 'nord', 'dracula', 'gruvbox']);
|
||||
|
||||
@@ -9,19 +12,20 @@ export function mountWorkspaceTheme({ root = document.body, scope, fetchImpl = g
|
||||
const previousTheme = root.dataset.caTheme;
|
||||
const previousScheme = documentElement.style.colorScheme;
|
||||
const system = win.matchMedia('(prefers-color-scheme: dark)');
|
||||
let preference = 'follow';
|
||||
let preference = 'system';
|
||||
let hostTheme = null;
|
||||
let request = null;
|
||||
let sequence = 0;
|
||||
try {
|
||||
storage ??= win.localStorage;
|
||||
const saved = storage?.getItem(STORAGE_KEY);
|
||||
const stored = storage?.getItem(STORAGE_KEY);
|
||||
const saved = LEGACY.get(stored) ?? stored;
|
||||
if (PREFERENCES.has(saved)) preference = saved;
|
||||
} catch { /* Storage can be disabled while theme controls remain usable. */ }
|
||||
|
||||
function update() {
|
||||
if (scope.signal.aborted) return;
|
||||
const theme = preference === 'follow' ? hostTheme || (system.matches ? 'dark' : 'light') : preference;
|
||||
const theme = preference === 'system' ? hostTheme || (system.matches ? 'dark' : 'light') : preference;
|
||||
root.dataset.caTheme = theme;
|
||||
documentElement.style.colorScheme = theme;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// The concept topbar's "ComfyUI ↗" link (D6 of #249).
|
||||
|
||||
const PROMPT_MANAGER = /prompt_manager\/$/;
|
||||
|
||||
/** The ComfyUI canvas root under the page's mount: the shared resolver's Prompt Manager root minus its own segment. */
|
||||
export function canvasRoot(resolve) {
|
||||
const root = resolve('/prompt_manager/');
|
||||
if (typeof root !== 'string' || !root.startsWith('/') || root.startsWith('//') || !PROMPT_MANAGER.test(root)) {
|
||||
throw new TypeError('Cannot resolve the ComfyUI canvas root');
|
||||
}
|
||||
return root.replace(PROMPT_MANAGER, '');
|
||||
}
|
||||
|
||||
const PAGE_MOUNT = /^(.*\/)prompt_manager(?:\/|$)/;
|
||||
|
||||
/**
|
||||
* The canvas link's href. A resolver failure must never stop the shell from mounting, so it falls back to the
|
||||
* ComfyUI root read from the page's own path (the #164 proxy mount), and to the origin root when even that is not a
|
||||
* same-origin root-relative path.
|
||||
*/
|
||||
export function canvasHref(resolve, pathname) {
|
||||
try {
|
||||
return canvasRoot(resolve);
|
||||
} catch (error) {
|
||||
console.warn('Prompt Manager: the ComfyUI link falls back to the page mount.', error);
|
||||
const mount = typeof pathname === 'string' ? pathname.match(PAGE_MOUNT)?.[1] : undefined;
|
||||
return mount && !mount.startsWith('//') ? mount : '/';
|
||||
}
|
||||
}
|
||||
|
||||
const plainClick = event => event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey;
|
||||
const isPair = roles => roles !== null && typeof roles === 'object' &&
|
||||
typeof roles.positive === 'string' && typeof roles.negative === 'string';
|
||||
|
||||
/**
|
||||
* Point the link at the canvas. A plain click while `getSelectedPair()` returns both resolved roles opens the
|
||||
* acknowledged pair send instead, as the concept does; otherwise the browser follows the link. Returns an unbind.
|
||||
*/
|
||||
export function bindCanvasLink(link, { getSelectedPair = () => null, sender, href }) {
|
||||
link.href = href;
|
||||
const onClick = event => {
|
||||
if (event.defaultPrevented || !plainClick(event) || !sender) return;
|
||||
const roles = getSelectedPair();
|
||||
if (!isPair(roles)) return;
|
||||
event.preventDefault();
|
||||
sender.open({ positive: roles.positive, negative: roles.negative }, link);
|
||||
};
|
||||
link.addEventListener('click', onClick);
|
||||
return () => link.removeEventListener('click', onClick);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
/* Native Tags & categories: geometry and tokens follow the approved concept (style.css 79-99, 283-304, 709-731, 840-895, 1195-1202, 1408-1526). */
|
||||
.vocabulary-main :where(.two-col, .surface, .table-wrap, .operation > div) { min-inline-size: 0; }
|
||||
.vocabulary-main .two-col { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
|
||||
.vocabulary-main .surface { padding: 22px; background: var(--ca-surface); border: 1px solid var(--ca-line); border-radius: 7px; }
|
||||
.vocabulary-main .surface + .surface { margin-top: 18px; }
|
||||
.vocabulary-main .surface :where(h2, h3, p) { margin: 0; line-height: 1.5; }
|
||||
.vocabulary-main .surface h2 { font-size: 17px; font-weight: 600; }
|
||||
.vocabulary-main .surface > p { font-size: 12px; color: var(--ca-muted); margin-top: 7px; }
|
||||
.vocabulary-main .row-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.ca-tools .vocabulary-main .row-actions button, .ca-tools .vocabulary-main .vocabulary-continuation button { min-height: 32px; font-size: 12px; }
|
||||
.vocabulary-main .operation { display: flex; justify-content: space-between; gap: 18px; align-items: center; padding: 15px 0; border-top: 1px solid var(--ca-line); }
|
||||
.vocabulary-main .operation h3 { font-size: 12px; font-weight: 600; overflow-wrap: anywhere; }
|
||||
.vocabulary-main .operation p { font-size: 11px; color: var(--ca-muted); margin-top: 5px; }
|
||||
.vocabulary-main .operation .row-actions { flex-shrink: 0; }
|
||||
.vocabulary-main .table-wrap { overflow-x: auto; }
|
||||
.vocabulary-main table { border-collapse: collapse; width: 100%; font-size: 12px; }
|
||||
.vocabulary-main caption { text-align: start; font-size: 13px; font-weight: 600; margin: 0 0 15px; }
|
||||
.vocabulary-main :where(th, td) { text-align: start; border-bottom: 1px solid var(--ca-line); padding: 13px 10px; }
|
||||
.vocabulary-main th { color: var(--ca-muted); font-size: 10px; font-weight: 500; }
|
||||
/* Long tag names wrap inside the Tag cell so the Actions column keeps its intrinsic width and no row action is clipped;
|
||||
on two-column screens the minimum keeps short names (the concept's longest sample, "architecture", is about 5em) on
|
||||
one line, as the concept shows. */
|
||||
.vocabulary-main td:first-child { overflow-wrap: anywhere; min-inline-size: calc(6em + 20px); }
|
||||
.vocabulary-main td .row-actions { justify-content: flex-end; }
|
||||
.vocabulary-main .empty { padding: 50px; text-align: center; border: 1px dashed var(--ca-line); border-radius: 6px; color: var(--ca-muted); }
|
||||
/* Never a scroll anchor: appended rows must push the end of the list away, not chain further pages. */
|
||||
.vocabulary-main .vocabulary-continuation { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; padding-block: 15px 0; overflow-anchor: none; }
|
||||
.vocabulary-main .vocabulary-continuation p { margin: 0; flex: 1 1 200px; font-size: 12px; color: var(--ca-muted); }
|
||||
.vocabulary-main .vocabulary-continuation .vocabulary-error { color: var(--ca-error-text); }
|
||||
/* Focus rings come from the toolkit (2px --ca-focus, offset 2px), which is what the concept renders: its own 3px rule loses to controls.css. */
|
||||
@media (max-width: 700px) {
|
||||
/* Concept .heading stacks with align-items: flex-start (style.css 1408-1414); the shared shell computes `start`. */
|
||||
.vocabulary-main .workspace-heading { align-items: flex-start; }
|
||||
.vocabulary-main .two-col { grid-template-columns: 1fr; }
|
||||
.vocabulary-main .operation { align-items: flex-start; flex-direction: column; }
|
||||
.vocabulary-main .operation .row-actions { flex-wrap: wrap; }
|
||||
.vocabulary-main .table-wrap { margin-inline: -10px; }
|
||||
/* One column: the fit must not depend on font metrics. The Tag column has no fixed minimum, and the Actions column
|
||||
asks for no more than its stacked width (the concept's 390px rows stack Browse over Rename / merge), so all space
|
||||
left after Prompts, Images and the stacked actions goes to the tag name. Short names stay on one line wherever the
|
||||
concept's own table fits; with a wider fallback font the name wraps instead of pushing an action out of view. */
|
||||
.vocabulary-main td:first-child { min-inline-size: 0; }
|
||||
.vocabulary-main td .row-actions { inline-size: min-content; margin-inline-start: auto; }
|
||||
}
|
||||
@media (pointer: coarse) {
|
||||
.ca-tools .vocabulary-main .row-actions button, .ca-tools .vocabulary-main .vocabulary-continuation button { min-height: 40px; }
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { browseFilters, createVocabularyCommand, createVocabularyStream } from '
|
||||
import { countOf } from './plural.mjs';
|
||||
import { browseLibrary, invalidateLibraryAfterWrite } from './library.mjs';
|
||||
import { createVocabularyDialog } from './vocabulary-dialog.mjs';
|
||||
import { pageHeading } from './heading.mjs';
|
||||
|
||||
const COPY = {
|
||||
tags: {empty: 'No tags yet.', error: 'Tags could not be loaded. Nothing was changed.', more: 'Load more tags'},
|
||||
@@ -155,12 +156,10 @@ function mountList(scope, api, kind, {content, rowsHost, renderRow, after}) {
|
||||
export function mountVocabulary(main, api, scope) {
|
||||
main.classList.add('vocabulary-main');
|
||||
scope.cleanup(() => main.classList.remove('vocabulary-main'));
|
||||
const heading = main.querySelector('.workspace-heading');
|
||||
heading.querySelector('.workspace-heading-copy').append(el('p', 'Organize once. Reuse everywhere.', 'intro'));
|
||||
const actions = el('div', undefined, 'heading-actions');
|
||||
const { description, actions } = pageHeading(main);
|
||||
description.textContent = 'Organize once. Reuse everywhere.';
|
||||
const create = createButton('+ Create tag');
|
||||
actions.append(create);
|
||||
heading.append(actions);
|
||||
|
||||
const grid = el('div', undefined, 'two-col');
|
||||
const tagSurface = el('section', undefined, 'surface');
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/* Production overlay on the verbatim concept base (concept.css, D1 of #249). Only production-only states and recorded
|
||||
deviations live here; a rule on a selector the concept also styles carries its decision marker. */
|
||||
|
||||
/* Production-only shell states. */
|
||||
main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
|
||||
/* The page title takes focus after a save or route change (concept app.js 1168); a keyboard user sees where it went. */
|
||||
#page-title:focus { outline: none; }
|
||||
#page-title:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
|
||||
/* D28: the sidebar shows no connection state, so its dot is neutral instead of the concept's demo green. */
|
||||
.connection i { background: var(--ca-muted); }
|
||||
/* D31: bounded rendering reads the grid's real width, so at extreme zoom the one-column shell must not grow to the
|
||||
sidebar's min-content (the concept's `1fr`); the column is capped at the viewport as production's shell was. */
|
||||
@media (max-width: 950px) {
|
||||
.app-shell { grid-template-columns: minmax(0, 1fr); /* D31 */ }
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
nav a[aria-current="page"] { border: 2px solid Highlight; color: LinkText; }
|
||||
}
|
||||
|
||||
/* Overview: real library statistics and the legacy tool cards inside a concept `.surface`. */
|
||||
.stat-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; margin: 0 0 26px; }
|
||||
.stat { padding: 22px; background: var(--ca-surface); border: 1px solid var(--line); border-radius: 7px; }
|
||||
.stat dt { color: var(--ca-muted); font-size: 12px; }
|
||||
.stat dd { margin: 8px 0 0; font-size: 32px; font-weight: 600; }
|
||||
.overview-stats > [role="status"], .overview-stats > [role="alert"], .empty-state { padding: 18px 0; color: var(--ca-muted); }
|
||||
.overview-stats > [role="alert"] { color: var(--ca-error-text); }
|
||||
.overview-stats > button { margin-bottom: 24px; }
|
||||
.tool-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 250px), 1fr)); gap: 12px; margin-top: 22px; }
|
||||
.tool-card { border: 1px solid var(--line); background: var(--ca-field); border-radius: 8px; padding: 20px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.tool-card p { flex: 1; margin: 0 0 12px; }
|
||||
.workspace-link { display: inline-flex; align-items: center; min-height: 44px; padding: 8px 0; font-size: 12px; overflow-wrap: anywhere; }
|
||||
.handoff .workspace-link { margin-top: 12px; }
|
||||
@media (max-width: 700px) {
|
||||
.stat-grid { gap: 8px; }
|
||||
.stat { padding: 16px; }
|
||||
.stat dd { font-size: 26px; }
|
||||
}
|
||||
|
||||
/* Tags & categories (#245): real vocabulary has unbounded names and bounded lists, which the concept's short samples
|
||||
never show. Proven by the 390px layout and long-name cases in workspace-vocabulary.spec.mjs. */
|
||||
.vocabulary-main :where(.two-col, .surface, .table-wrap, .operation > div) { min-width: 0; }
|
||||
.vocabulary-main .operation h3 { overflow-wrap: anywhere; }
|
||||
/* Long tag names wrap inside the Tag cell so the Actions column keeps its intrinsic width; on two-column screens the
|
||||
minimum keeps the concept's longest sample ("architecture", about 5em) on one line. */
|
||||
.vocabulary-main td:first-child { overflow-wrap: anywhere; min-width: calc(6em + 20px); }
|
||||
/* Tags & categories (#245 decision 5): bounded lists continue with Load more; never a scroll anchor, so appended rows push the end away. */
|
||||
.vocabulary-main .vocabulary-continuation { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; padding-block: 15px 0; overflow-anchor: none; }
|
||||
.vocabulary-main .vocabulary-continuation p { margin: 0; flex: 1 1 200px; font-size: 12px; color: var(--ca-muted); }
|
||||
.vocabulary-main .vocabulary-continuation .vocabulary-error { color: var(--ca-error-text); }
|
||||
@media (max-width: 700px) {
|
||||
/* One column: the Tag column has no fixed minimum and Actions asks only for its stacked width (the concept's 390px
|
||||
rows stack Browse over Rename / merge), so a wider fallback font wraps the name instead of hiding an action. */
|
||||
.vocabulary-main td:first-child { min-width: 0; }
|
||||
.vocabulary-main td .row-actions { width: min-content; margin-inline-start: auto; }
|
||||
}
|
||||
|
||||
/* Interim page guards: concept element rules (`.ca-tools dialog`, `pre`) now reach production dialogs and prompt text
|
||||
that keep their own design until their page PR rebuilds them in concept markup. */
|
||||
.panel { background: var(--ca-surface); border: 1px solid var(--line); } /* PR D: Images side panel (padding and radius in images.css) becomes `.detail-panel` */
|
||||
.ca-tools dialog.image-preview { width: min(1500px, 96%); max-height: 94%; padding: 18px; overflow: hidden; } /* PR C: viewer becomes `#viewer-dialog` */
|
||||
.ca-tools dialog.prompt-sender { width: min(760px, calc(100% - 32px)); max-width: none; max-height: calc(100dvh - 32px); padding: 22px; border-color: var(--ca-line); border-radius: 12px; } /* PR D */
|
||||
.ca-tools dialog.prompt-sender::backdrop { background: #000b; } /* PR D */
|
||||
.ca-tools dialog.recorded-run-dialog { width: min(1100px, calc(100% - 32px)); max-width: calc(100% - 32px); max-height: calc(100dvh - 32px); padding: 22px; border-radius: 7px; } /* PR F */
|
||||
pre.prompt-text { background: none; padding: 0; border: 0; border-radius: 0; max-height: none; overflow: visible; } /* PR D: prompt text becomes `p.prompt-text` */
|
||||
.viewer-container .viewer-hide { display: none; } /* PR C: the concept's own `.viewer-toolbar` would reveal Viewer.js's hidden toolbar */
|
||||
.ca-tools .prompt-actions .ca-icon-button { min-width: 30px; } /* PR D: the concept's 28px prompt action would shrink the toolkit icon button */
|
||||
@media (pointer: coarse) {
|
||||
.ca-tools .recorded-history .history-toolbar button { min-height: 44px; } /* PR F: concept coarse buttons are 40px */
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.ca-tools dialog.image-preview { overflow: auto; } /* PR C */
|
||||
.ca-tools dialog.recorded-run-dialog { padding: 16px; } /* PR F */
|
||||
}
|
||||
+22
-11
@@ -6,30 +6,41 @@
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>Prompt Manager · Workspace</title>
|
||||
<link rel="stylesheet" href="js/shared/toolkit/controls.css">
|
||||
<link rel="stylesheet" href="js/workspace/style.css">
|
||||
<link rel="stylesheet" href="lib/viewerjs/viewer.min.css">
|
||||
<link rel="stylesheet" href="js/workspace/concept.css">
|
||||
<link rel="stylesheet" href="js/workspace/images.css">
|
||||
<link rel="stylesheet" href="js/workspace/library.css">
|
||||
<link rel="stylesheet" href="js/workspace/inspector.css">
|
||||
<link rel="stylesheet" href="js/workspace/library-editor.css">
|
||||
<link rel="stylesheet" href="js/workspace/vocabulary.css">
|
||||
<link rel="stylesheet" href="js/workspace/workspace.css">
|
||||
<script src="lib/viewerjs/viewer.min.js" defer></script>
|
||||
<script src="js/comfy-metadata.js" defer></script>
|
||||
<script src="js/gallery-settings.js" defer></script>
|
||||
<script type="module" src="js/workspace/index.mjs"></script>
|
||||
</head>
|
||||
<body class="ca-tools">
|
||||
<a class="skip" href="#workspace-main">Skip to content</a>
|
||||
<div class="workspace-shell">
|
||||
<a class="skip" href="#workspace-main">Skip to workspace</a>
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar">
|
||||
<a class="brand" href="#/images"><span class="brand-mark" aria-hidden="true">P<span>±</span></span><span>Prompt Manager<small>COMFYASSETS</small></span></a>
|
||||
<nav aria-label="Workspace"></nav>
|
||||
<div class="sidebar-footer"><span class="connection-dot" aria-hidden="true"></span> Your library, on your server</div>
|
||||
<a class="brand" href="#/images"><span class="brand-mark">P<span>±</span></span><span>Prompt Manager<small>COMFYASSETS</small></span></a>
|
||||
<div class="sidebar-label">WORKSPACE</div>
|
||||
<nav id="primary-nav" aria-label="Workspace"></nav>
|
||||
<div class="sidebar-bottom">
|
||||
<nav id="system-nav" aria-label="Tools and system"></nav>
|
||||
<div class="connection"><i></i><div>ComfyUI workspace<small>Your library, on your server</small></div></div>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="workspace-body">
|
||||
<header class="topbar"><span>ComfyAssets <span class="muted">/ <span id="workspace-breadcrumb">Images</span></span></span><div id="appearance-control"></div></header>
|
||||
<div class="workspace">
|
||||
<header class="topbar">
|
||||
<div class="breadcrumbs">ComfyAssets <span>/</span> <span id="workspace-breadcrumb">Images</span></div>
|
||||
<div class="top-actions"><label class="theme-label" for="theme">Theme
|
||||
<select id="theme" aria-label="Theme">
|
||||
<option value="dark">Dark</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="system" selected>System</option>
|
||||
</select></label><a class="canvas-link" href="../">ComfyUI <span aria-hidden="true">↗</span></a></div>
|
||||
</header>
|
||||
<main id="workspace-main" tabindex="-1"></main>
|
||||
<footer class="page-footer">ComfyAssets <span>Local tools. Your library.</span></footer>
|
||||
<footer class="app-footer"><span>ComfyAssets · Local tools. Your library.</span></footer>
|
||||
</div>
|
||||
</div>
|
||||
<div id="workspace-toast" class="toast" role="status" aria-live="polite" aria-atomic="true"></div>
|
||||
|
||||
Reference in New Issue
Block a user