Compare commits

..
Author SHA1 Message Date
Vito Sansevero e471a47d1f docs(testing): add the concurrent SQLite test rule to the Windows preflight (#242) 2026-10-19 10:27:31 -07:00
Vito Sansevero deeacea700 test(history): wait on restore handoff events, not a 5 s clock (#242)
The retired-owner projection tests park a history read at a barrier
while the test performs a full multipart restore, then release it. The
barrier gave up after 5 s and raised inside the route handler, so on a
slow Windows runner (a 694 s suite run) aiohttp answered with its
text/plain 500 and the test reported 'history route missing'.

Every wait already ends on an event; the ceiling now only stops a broken
test from hanging (60 s). With restore slowed by 5.5 s locally, all 8
subtests failed with text/plain before and pass after.
2026-10-19 10:27:11 -07:00
Vito Sansevero 94d50d15f2 test(history): let concurrent replay peers queue like ordinary writers (#242)
History writes deliberately give up after a bounded 250 ms wait so they
never stall ComfyUI; the existing bounded-timeout test still proves that.
The concurrent replay tests assert that every peer succeeds, which is a
serialization and first-capture property, so they now raise the budget
to the shared 30 s busy timeout while the first writer is held open.
The budget is one named constant, EXECUTION_BUSY_TIMEOUT_MS, shared by
the transaction helper and the background writer.

Before: 20/20 'database is locked' with the held writer. After: 0/20.
2026-10-19 10:24:58 -07:00
Vito Sansevero 06c26e0a41 test(history): hold the first concurrent writer open past the bounded wait (#242)
The concurrent replay tests expected every peer to succeed, but each peer
only waits 250 ms for BEGIN IMMEDIATE by design. On a slow Windows runner
three queued commits exceeded that and a peer raised 'database is
locked'. Holding the first writer's transaction open for 500 ms makes the
same failure deterministic on any host (20/20 locally).
2026-10-19 10:23:20 -07:00
Vito Sansevero ba4e958804 fix(database): stop WAL commits from holding the write lock across a flush (#242)
Every managed connection now goes through configure_connection: WAL,
foreign keys, the 30 s busy timeout and, when WAL is active,
synchronous=NORMAL. Under FULL each commit flushed to disk while it held
SQLite's only write lock; on slow Windows disks a burst of writers (a
scan beside ComfyUI saves, or the concurrency tests) starved a polling
waiter past the busy timeout and it failed with 'database is locked'.

With a 60 ms emulated flush on Linux, 8 threads x 50 save+use calls went
from 49.6 s with a 32.1 s worst call (and 'database is locked' in the
unit test) to 0.7 s with a 0.4 s worst call. NORMAL in WAL mode keeps the
database consistent after an application crash; only an OS crash or
power loss can drop the most recent commits. In-memory or non-WAL
databases keep SQLite's FULL default.
2026-10-19 10:22:17 -07:00
Vito Sansevero a574f57cd8 test(database): require identical WAL/NORMAL settings on managed connections (#242)
Concurrent writers failed with 'database is locked' on Windows runners
despite the 30 s busy timeout: every WAL commit flushed to disk while it
held the write lock, so polling waiters starved. These tests pin the
shared per-connection settings, including synchronous=NORMAL in WAL mode.
2026-10-19 10:21:18 -07:00
73 changed files with 300 additions and 14781 deletions
+24 -44
View File
@@ -20,34 +20,24 @@ jobs:
frontend-coverage:
name: frontend coverage (chromium and touch)
runs-on: ubuntu-latest
# Layout comparisons depend on the fonts each browser falls back to, so browser jobs run in the
# same pinned Playwright image as local runs (tag = @playwright/test in tools/vendor/package-lock.json;
# tests/vendor/browser-image.test.mjs checks it). Browsers and fonts come from the image.
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble
options: --user 1001
timeout-minutes: 25
steps:
- uses: actions/checkout@v7
# The image's own Python 3.12 and Node 24: setup-python/setup-node install the runner's toolcache
# builds, whose entry points name host paths that do not exist in the container. The image has no
# ensurepip and the job has no root, so a checksum-pinned pip wheel bootstraps a user-space venv.
- name: Python environment
env:
PIP_WHEEL: https://files.pythonhosted.org/packages/f3/6e/1736e5b4ae2b778ef2f81c47d797de9f891d4d8acb047a24ca37a60294dd/pip-26.2.1-py3-none-any.whl
PIP_SHA256: 71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e
run: |
venv="$RUNNER_TEMP/venv" wheel="$RUNNER_TEMP/pip-26.2.1-py3-none-any.whl"
python3 -m venv --without-pip "$venv"
curl -fsSL --retry 3 -o "$wheel" "$PIP_WHEEL"
echo "$PIP_SHA256 $wheel" | sha256sum -c -
"$venv/bin/python" "$wheel/pip" install --no-index --no-cache-dir "$wheel"
"$venv/bin/python" -m pip install --no-cache-dir -r requirements-dev.txt
echo "$venv/bin" >> "$GITHUB_PATH"
- run: python --version && node --version && node --version | grep -q '^v24\.'
- uses: actions/setup-python@v7
with:
python-version: '3.12'
cache: pip
cache-dependency-path: requirements-dev.txt
- run: python -m pip install -r requirements-dev.txt
- uses: actions/setup-node@v7
with:
node-version: '24'
cache: npm
cache-dependency-path: tools/vendor/package-lock.json
- run: npm ci --prefix tools/vendor --ignore-scripts
- run: npm run --prefix tools/vendor check
- run: npm run --prefix tools/vendor test
- run: npx --prefix tools/vendor playwright install --with-deps chromium
- name: Frontend unit, viewer, workflow, and coverage checks
run: npm run --prefix tools/vendor test:coverage
- name: Upload frontend coverage and Chromium traces
@@ -64,12 +54,6 @@ jobs:
browser-workflows:
name: page workflows (${{ matrix.browser }})
runs-on: ubuntu-latest
# Layout comparisons depend on the fonts each browser falls back to, so browser jobs run in the
# same pinned Playwright image as local runs (tag = @playwright/test in tools/vendor/package-lock.json;
# tests/vendor/browser-image.test.mjs checks it). Browsers and fonts come from the image.
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble
options: --user 1001
timeout-minutes: 25
strategy:
fail-fast: false
@@ -77,23 +61,19 @@ jobs:
browser: [firefox, webkit]
steps:
- uses: actions/checkout@v7
# The image's own Python 3.12 and Node 24: setup-python/setup-node install the runner's toolcache
# builds, whose entry points name host paths that do not exist in the container. The image has no
# ensurepip and the job has no root, so a checksum-pinned pip wheel bootstraps a user-space venv.
- name: Python environment
env:
PIP_WHEEL: https://files.pythonhosted.org/packages/f3/6e/1736e5b4ae2b778ef2f81c47d797de9f891d4d8acb047a24ca37a60294dd/pip-26.2.1-py3-none-any.whl
PIP_SHA256: 71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e
run: |
venv="$RUNNER_TEMP/venv" wheel="$RUNNER_TEMP/pip-26.2.1-py3-none-any.whl"
python3 -m venv --without-pip "$venv"
curl -fsSL --retry 3 -o "$wheel" "$PIP_WHEEL"
echo "$PIP_SHA256 $wheel" | sha256sum -c -
"$venv/bin/python" "$wheel/pip" install --no-index --no-cache-dir "$wheel"
"$venv/bin/python" -m pip install --no-cache-dir -r requirements-dev.txt
echo "$venv/bin" >> "$GITHUB_PATH"
- run: python --version && node --version && node --version | grep -q '^v24\.'
- uses: actions/setup-python@v7
with:
python-version: '3.12'
cache: pip
cache-dependency-path: requirements-dev.txt
- run: python -m pip install -r requirements-dev.txt
- uses: actions/setup-node@v7
with:
node-version: '24'
cache: npm
cache-dependency-path: tools/vendor/package-lock.json
- run: npm ci --prefix tools/vendor --ignore-scripts
- run: npx --prefix tools/vendor playwright install --with-deps ${{ matrix.browser }}
- name: Page workflows in ${{ matrix.browser }}
run: npm run --prefix tools/vendor test:workflows -- --project ${{ matrix.browser }}
- name: Upload browser traces
+3 -124
View File
@@ -42,18 +42,8 @@ npm run --prefix tools/vendor test:workflows -- --project firefox --project webk
`PM_TEST_PYTHON` selects the Python executable; `PM_TEST_PORT` selects the local
port (default 8765). Set these environment variables when Python is outside PATH
or the port is occupied. Test servers are never reused. On unsupported Linux
distributions, browser system dependencies may need manual installation. For a
quick browser run, use `test:workflows -- --project chromium`.
CI runs the browser jobs inside the pinned image
`mcr.microsoft.com/playwright:v<@playwright/test version>-noble`
(`tests/vendor/browser-image.test.mjs` keeps the tag in step with the lockfile).
Concept comparisons measure geometry, and geometry follows the fonts a browser
falls back to: the concept names Inter, which neither the image nor a stock
runner ships. On a plain `ubuntu-latest` runner the fallback is DejaVu Sans,
whose wider metrics make the concept's own Tags table overflow a 390px viewport,
so the 390px comparisons are only meaningful in the pinned image. To reproduce CI,
run the suites in that image.
distributions, browser system dependencies may need manual installation; CI uses
Ubuntu. For a quick browser run, use `test:workflows -- --project chromium`.
## Managed real ComfyUI
@@ -227,55 +217,7 @@ workflow graph and its facts become limited). The serialized
response stays under 8 MiB by omitting, in order, the workflow graph, then the prompt
graph, then all raw records atomically. Retry does not raise these limits. Ingestion
(scan, live monitor, admin import) and queue-time run counting keep the default
profile, including its 500-node gate. Only `INSPECTION_PROFILE` reads the node
semantics table `web/js/inspection-semantics.json` (at most 1 MiB and eight nesting
levels, loaded on first use). With it, `SamplerCustomAdvanced` follows its `guider`
link (through Reroutes) to `CFGGuider` (positive and negative) or `BasicGuider`
(positive only; the negative is `absent`, "Guider has no negative input"). Any
other guider, such as `DualCFGGuider`, is `unsupported-node`. Conditioning is
traced through the table's pass-throughs (FluxGuidance, ReferenceLatent, the
ConditioningSet* nodes, ControlNet apply nodes, InpaintModelConditioning and
WanImageToVideo by output slot). `ConditioningZeroOut` is an explicit empty text
with source `zeroed-conditioning`. `easy ifElse` with a literal boolean follows that
branch only; with a linked boolean both branches are traced, equal texts resolve and
different texts make the sampler `ambiguous` with one candidate per choice (at most
32, else `work-limit`). ConditioningCombine/Concat/Average resolve only when every
part has the same text, otherwise `unresolved` with `composite` naming the node.
Results that depend on a forced choice are never memoized. A `FaceDetailer`
(`detailer-stage`) is a sampler stage: the samplers reached through its `image`
ancestry are compared with it. Equal texts resolve with the detailer as the sampler;
different texts are `ambiguous` with `multistage-differs` and one candidate per
stage; an unreadable stage makes the chain `unresolved`. Unanchored, a sampler
whose output only feeds detailers (or previews) is not a candidate of its own.
A sampler's latent ancestry is walked through every link input of non-sampler
nodes within the same visit and depth budgets: with no earlier sampler it is a
single stage whatever the origin (VAEEncode, EmptyLatentBatch, EmptyFlux2LatentImage,
EmptyHunyuanLatentVideo, SetLatentNoiseMask, InpaintModelConditioning or
WanImageToVideo slot 2). An earlier sampler (img2img from a decode, hires fix) stays
`unresolved` "Multistage sampler ancestry is ambiguous" until stage texts are
compared; an unlisted node with sampler-shaped inputs (positive, negative, guider,
latent_image, a pipe, video embeds, custom-sampler parts, or a `model` link together
with conditioning, neg_conditioning, samples or latent) is `unsupported-node`
naming it, here and in a detailer's image ancestry; a latent that is not a link or
reaches its own sampler is `malformed`; running out of budget is `work-limit` or
`depth-limit`, never single stage. Each sampler's walk runs once per resolution;
selector branch runs share it and other candidates are charged its visit cost.
Text encoders: `single` encoders (the Qwen image encoders, `TextEncodeQwenImage21`
slot 0 `prompt` and slot 1 `negative_prompt`, `KikoTextEncode`) read one string
input unchanged. `parts-equal` encoders (CLIPTextEncodeSDXL, Flux, SD3) resolve when
every part is identical or exactly one part is non-empty (exact `""`, whitespace is
text); otherwise `composite`. String sources reproduce the pinned KJNodes code from
literal settings only: `StringConstant` is its `string`; `StringConstantMultiline`
with a literal true `strip_newlines` is `replace("\n", "").strip()`;
`JoinStringMulti` keeps `string_1` (even empty), appends `string_2`..`string_N`
(`N` = `inputcount`) after the literal delimiter unless missing or exactly `""`, and
ignores later inputs. A linked setting is `unsupported-node`; a list output
(`return_list` true or linked) and `PromptSearchList` are `runtime-value` ("Text is
decided at run time"), never one list item. Unverified text nodes (LoRAfromText,
RegexReplace, XYZPrompt, ImpactWildcardProcessor) stay `unsupported-node`. Every
intermediate text is length-checked and charged to the scanned-character budget. If
the table cannot be loaded, graph inspection is `unresolved` ("Inspection node
semantics are unavailable") and ingestion is unaffected, because it never imports the table.
profile, including its 500-node gate.
`tools/inspection_corpus.py --root DIR --out JSONL [--workers N] [--baseline JSONL]
[--summary TXT] [--code TREE]` measures a media folder read-only against the #240
@@ -693,66 +635,3 @@ Both token functions (`library_edit_token`, `category_impact_token`) share
outputs.
Deploying this backend needs a ComfyUI restart (Python and route changes).
## Native vocabulary UI (#236)
`#/tags` ("Tags & categories", page title "Tags") is a native workspace route; it no
longer hands off to the legacy tag manager. That page stays reachable from Overview's
"Open existing tag manager" card for tag search and sort, the untagged entry and
multi-tag AND/OR browsing; bulk prompt actions (delete, tag, category) stay on the
legacy admin page. The screen consumes only the `/prompt_manager/vocabulary/*` routes
above and adds no route, so PR B needs no ComfyUI restart: a browser refresh is enough.
Screen: a `+ Create tag` heading action, a Tag vocabulary table (columns Tag, Prompts,
Images shown as "—" with hidden "Not available yet", and Actions with Browse and
Rename / merge), and a Categories panel whose category rows end with the Manage
category row (Review). Lists load 100 rows at a time, append when scrolled into view
and keep `Load more tags` / `Load more categories` while more remain. Prompts counts
are the Library Browse totals.
Dialogs (`vocabulary-dialog.mjs`): **Create tag**, **Rename or merge tag** (renaming to
an existing name merges; it carries a **Delete tag…** confirmation that names the
links removed) and **Manage categories** (Category select, **Preview**, then **Apply**).
Names with commas are rejected on these native routes only.
Guarded states, each exercised against the real server in
`tests/browser/workspace-vocabulary.spec.mjs`: stale epoch (`storage-changed`) and
`vocabulary-conflict` write nothing and re-read; an unknown outcome keeps the dialog
open, re-reads and never resends a delete; an unconfirmed Delete offers Retry that only
re-reads the impact; a failed list read shows Retry with "Nothing was changed."; a
failed Load more moves keyboard focus to its Retry; a write that lands after its dialog
was force-closed or left with Back still re-reads the lists and drops the cached Library
shelf; Manage categories reads up to 1,000 categories and says so when capped.
**Browse** on a tag or category row opens Library with exactly that Tags or Category
filter (the concept puts the name in the search text instead); the Prompts count equals
the number of cards Browse shows.
Test files: `tests/js/workspace-vocabulary-state.test.js` (strict parsing, stale and
abort handling, late writes after dispose, sentences), `tests/js/workspace-state.test.js`
(route/title), `tests/browser/workspace-vocabulary.spec.mjs` (screen, dialogs, Browse,
mobile, the concept geometry/token/control comparison and its mutation check),
`tests/browser/vocabulary-canonical-comparison.mjs` (inventory mapping),
`tests/browser/vocabulary-visual.spec.mjs` (paired capture artifacts) and the
`focused node tag suggestions include a newly created tag` case in
`tests/browser/focused-node.spec.mjs`. The fixture is
`tests/browser/workspace_vocabulary_fixture.py`. The screen is not host-facing, so no
`tests/comfyui` case was added.
```sh
node --test tests/js/workspace-vocabulary-state.test.js tests/js/workspace-state.test.js
PM_TEST_PORT=8766 npm run --prefix tools/vendor test:workflows -- \
workspace-vocabulary.spec.mjs vocabulary-visual.spec.mjs focused-node.spec.mjs \
--project chromium --project touch --project firefox --project webkit
```
Recorded deviations from the concept (maintainer acceptance): no Auto-tag images
button and an Images "—" cell until the image-annotation slice; bounded lists with
Load more; title "Tags"; Manage categories adds a Category select and Preview before
Apply; "Apply" instead of "Apply in prototype"; Delete tag… inside Rename or merge;
Browse sets the exact filter; singular "1 positive prompt"; the production list appends
fixture-only tags after the concept's sample tags. Paired concept/production captures
for both themes and widths are attached as test artifacts; no PNG baseline is added
until acceptance. Not compared: hover states and the dialog focus ring (the page-level
keyboard ring and link ring offsets are asserted). No coverage floor was lowered;
`vocabulary-state.mjs`, `vocabulary.mjs` and `vocabulary-dialog.mjs` have additive
floors in `frontend-coverage-baseline.json`.
+16 -52
View File
@@ -27,22 +27,10 @@ except ImportError:
try:
from ...database.operations import PromptDatabase
from ...database.pair_ingestion import ingest_resolved_pair
from ...utils.generation_params import (
PLACEHOLDERS,
GenerationParamsError,
extract_generation_params,
parse_js_json,
)
from ...utils.prompt_pair_resolver import resolve_prompt_pair
except ImportError:
from database.operations import PromptDatabase
from database.pair_ingestion import ingest_resolved_pair
from utils.generation_params import (
PLACEHOLDERS,
GenerationParamsError,
extract_generation_params,
parse_js_json,
)
from utils.prompt_pair_resolver import resolve_prompt_pair
@@ -1523,21 +1511,6 @@ class AdminRoutesMixin:
counts["pair_links_added"] += outcome.get("links_added", 0)
return counts
@staticmethod
def _graph_prompt_text(metadata):
"""The positive prompt the browser parser traces from the graphs, or None."""
records = {}
for key in ("prompt", "workflow"):
value = metadata.get(key)
records[key] = parse_js_json(value) if isinstance(value, str) else value
try:
positive = extract_generation_params(records)["positivePrompt"]
except GenerationParamsError:
return None
if not isinstance(positive, str) or positive == PLACEHOLDERS["positivePrompt"]:
return None
return positive.strip() or None
def _ingest_scanned_file(self, media_file, metadata, detailed=False):
"""Enrich exact pairs; retain legacy positive-only import for absent pairs."""
if not metadata:
@@ -1558,32 +1531,23 @@ class AdminRoutesMixin:
self.db, str(media_file), canonical, resolution
)
return result if detailed else result["status"]
if canonical.get("prompt") and resolution["state"] in (
"ambiguous",
"unresolved",
):
# The resolver could not decide: link the prompt the inspector and the
# legacy pages show for this file (the browser parser, in Python).
# Graphs without a recognized sampler keep the legacy extractor below.
prompt_text = self._graph_prompt_text(metadata)
if not prompt_text:
return resolution["state"]
elif resolution["state"] == "ambiguous":
if resolution["state"] == "ambiguous":
return "ambiguous"
elif resolution["state"] == "unresolved" and not isinstance(
canonical.get("parameters"), str
):
return resolution["state"]
else:
parsed = self._parse_comfyui_prompt(metadata)
if not (parsed.get("prompt") or parsed.get("parameters")):
return None
prompt_text = self._extract_readable_prompt(parsed)
if prompt_text is not None and not isinstance(prompt_text, str):
prompt_text = str(prompt_text)
if not (prompt_text and prompt_text.strip()):
return "found"
prompt_text = prompt_text.strip()
if resolution["state"] == "unresolved":
if canonical.get("prompt") or not isinstance(
canonical.get("parameters"), str
):
return resolution["state"]
parsed = self._parse_comfyui_prompt(metadata)
if not (parsed.get("prompt") or parsed.get("parameters")):
return None
prompt_text = self._extract_readable_prompt(parsed)
if prompt_text is not None and not isinstance(prompt_text, str):
prompt_text = str(prompt_text)
if not (prompt_text and prompt_text.strip()):
return "found"
prompt_text = prompt_text.strip()
prompt_hash = generate_prompt_hash(prompt_text)
existing = self.db.get_prompt_by_hash(prompt_hash)
+14 -40
View File
@@ -9,11 +9,6 @@ try:
MetadataReadError,
inspect_prompt_evidence,
read_inspection_file,
unreadable_evidence,
)
from ...utils.prompt_pair_resolver import (
INSPECTION_PROFILE,
PREVIEW_INSPECTION_PROFILE,
)
from ...utils.video_metadata import is_video_path
except ImportError:
@@ -21,11 +16,6 @@ except ImportError:
MetadataReadError,
inspect_prompt_evidence,
read_inspection_file,
unreadable_evidence,
)
from utils.prompt_pair_resolver import (
INSPECTION_PROFILE,
PREVIEW_INSPECTION_PROFILE,
)
from utils.video_metadata import is_video_path
@@ -70,28 +60,6 @@ def _current_file_facts(path: Path) -> os.stat_result:
return observed
def _in_host_temp(path: Path) -> bool:
"""Whether a file lies in ComfyUI's temp folder, where previews are written.
An unknown temp folder reads as no: the file is then inspected as saved
output.
"""
try:
import folder_paths
temp = folder_paths.get_temp_directory()
except (ImportError, AttributeError, OSError):
return False
if type(temp) is not str or not temp:
return False
try:
folder = os.path.normcase(os.path.realpath(temp))
target = os.path.normcase(os.path.realpath(path))
return os.path.commonpath([folder, target]) == folder
except (OSError, ValueError):
return False
def inspect_current_file(
path: Path, *, source_label: str, saved: dict | None, library: dict
) -> dict:
@@ -105,18 +73,24 @@ def inspect_current_file(
if any(getattr(before, key) != getattr(after, key) for key in fields):
raise CurrentFileChanged
if inspection is None:
evidence = unreadable_evidence()
evidence = {
"prompts": {
role: {"status": "unresolved", "text": None, "source": None}
for role in ("positive", "negative")
},
"resolution": {
"state": "unresolved",
"sampler_id": None,
"reason": "Media metadata could not be read",
"reason_code": None,
"node": None,
},
}
else:
role_saved = (
None if saved and saved.get("association_status") == "ambiguous" else saved
)
# A preview may be the output of a stage that reaches only previews.
profile = (
PREVIEW_INSPECTION_PROFILE if _in_host_temp(path) else INSPECTION_PROFILE
)
evidence = inspect_prompt_evidence(
inspection.metadata, role_saved, profile=profile
)
evidence = inspect_prompt_evidence(inspection.metadata, role_saved)
return {
"file": {
"observation": "current-file",
-13
View File
@@ -130,19 +130,6 @@ test(`${variant} completes saved tags with keyboard and pointer and appends comm
await expect(tags).toHaveValue('A brand new tag');
});
test(`${variant} focused node tag suggestions include a newly created tag`, async ({page,request}) => {
const name=`created_${variant.toLowerCase()}_vocabulary`;
const listing=await request.get('/prompt_manager/vocabulary/tags?limit=1');await expect(listing).toBeOK();
const created=await request.post('/prompt_manager/vocabulary/tags',{data:{expected_epoch:(await listing.json()).library_epoch,name}});
await expect(created).toBeOK();expect((await created.json()).tag).toMatchObject({name,prompt_count:0});
await page.getByText('Metadata · category / tags',{exact:true}).click();
const tags=page.getByLabel('Tags',{exact:true});
await tags.fill(`created_${variant.toLowerCase()}`);
await expect(page.getByRole('option',{name,exact:true})).toBeVisible();
await tags.press('ArrowDown');await tags.press('Enter');
await expect(tags).toHaveValue(`${name}, `);
});
test(`${variant} metadata autocomplete ignores stale responses, recovers from errors and cleans up`, async ({page}) => {
let release;
let mode='delayed';
+1 -4
View File
@@ -26,10 +26,7 @@ test('inspection facts survive actual API to browser transport', async ({ page,
const complete = result.complete;
expect(Object.values(complete.generation).map(fact => fact.value)).toEqual(['9007199254740993', '28', '6.5', 'dpmpp_2m', 'karras', 'fixture-model.safetensors']);
expect(Object.values(complete.generation).every(fact => fact.source === 'api-graph' && fact.evidence === 'configuration')).toBe(true);
// Differing branches show what the legacy parser shows: the first sampler's seed.
expect(Object.values(result.ambiguous.generation).map(fact => [fact.status, fact.value])).toEqual([
['known', '9007199254740993'], ['known', '28'], ['known', '6.5'], ['known', 'dpmpp_2m'], ['known', 'karras'], ['known', 'fixture-model.safetensors']]);
expect(Object.values(result.ambiguous.generation).every(fact => fact.sampler_id === null)).toBe(true);
expect(Object.values(result.ambiguous.generation).every(fact => fact.status === 'ambiguous' && fact.value === null)).toBe(true);
expect(result['workflow-only'].generation.seed).toMatchObject({ status: 'known', value: '9007199254740993', source: 'workflow', evidence: 'configuration' });
expect(result['workflow-only'].prompts.positive.source).toBe('saved-association');
expect(Object.values(result['control-heavy'].generation).every(fact => fact.status === 'unresolved' && fact.value === null && fact.reason === 'projection-limited')).toBe(true);
-11
View File
@@ -232,17 +232,6 @@ test('unverified saved associations explain uncertainty and cannot be sent',asyn
await expect(details(page).getByRole('button',{name:/^Send /})).toHaveCount(0);
});
test('unresolved roles name the blocking node as text and offer no prompt actions',async({page})=>{
const line='Prompt uses a node the inspector does not support (<img src=x onerror="window.injected=1">).';
await page.route('**/images/inspect/uncertain.png?root=0*',route=>route.fulfill({json:{success:true,root_scope:new URL(route.request().url()).searchParams.get('root_scope'),image:{root_index:0,path:'uncertain.png',filename:'uncertain.png'},prompts:{
positive:{status:'unresolved',text:null,source:null,message:line},negative:{status:'unresolved',text:null,source:null,message:line},
}}}));
await page.goto(workspace);await select(page,'0:uncertain.png');
for (const name of ['Positive','Negative']) await expect(role(details(page),name)).toContainText(line);
await expect(details(page).locator('img[onerror]')).toHaveCount(0);
expect(await page.evaluate(()=>window.injected)).toBeUndefined();
await expect(details(page).getByRole('button',{name:/^(Send|Copy) /})).toHaveCount(0);
});
test('prompt role headings contain compact real actions and honest source evidence',async({page})=>{
await page.goto(workspace);await select(page,'0:shared/same.png');
-2
View File
@@ -24,7 +24,6 @@ from tests.browser.workspace_history_fixture import (
from tests.browser.workspace_library_editor_fixture import (
register_library_editor_commands,
)
from tests.browser.workspace_vocabulary_fixture import register_vocabulary_commands
CANONICAL_ROOT = Path(__file__).resolve().parents[2]
CANONICAL_ASSETS = {
@@ -105,7 +104,6 @@ def create_app(root: Path) -> web.Application:
api.add_routes(routes)
history_commands = register_workspace_history_commands(routes, api, root)
library_editor_commands = register_library_editor_commands(routes, api, root)
register_vocabulary_commands(routes, api, root)
async def canonical_asset(request: web.Request) -> web.Response:
filename, content_type = CANONICAL_ASSETS[request.path]
@@ -1,108 +0,0 @@
// Concept-vs-production inventory for the native Tags & categories screen (concept renderTags, app.js:719-729).
// pageInventoryOf runs in the page; dialogs reuse the editor's inventoryOf. The concept is never rewritten:
// 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) {
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;
const font=node=>css(node,'fontFamily','fontSize','fontWeight','lineHeight','letterSpacing','color','marginTop','marginBottom');
const edges=node=>css(node,'paddingTop','paddingRight','paddingBottom','paddingLeft','borderTopWidth','borderTopStyle','borderTopColor',
'borderBottomWidth','borderBottomStyle','borderBottomColor','borderTopLeftRadius','backgroundColor','color');
const buttonOf=node=>({text:node.textContent.trim(),type:node.type,primary:node.classList.contains('ca-primary'),danger:node.classList.contains('danger'),
...css(node,'minHeight','fontSize','fontWeight','color','backgroundColor','borderTopColor','borderTopLeftRadius','paddingLeft','paddingRight'),
height:px(node.getBoundingClientRect().height)});
// Visible text of a cell, without visually hidden helper text (recorded separately).
const shownText=node=>[...node.childNodes].filter(child=>!(child.classList?.contains('ca-visually-hidden'))).map(child=>child.textContent).join('').trim();
// 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 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')];
const [tagSurface,categorySurface]=surfaces;
const wrap=tagSurface.querySelector('.table-wrap'),table=wrap.querySelector('table'),caption=table.querySelector('caption');
const rows=[...table.querySelectorAll('tbody tr')];
const firstCell=rows[0]?.querySelector('td');
const operations=[...categorySurface.querySelectorAll('.operation')].filter(visible);
return {
heading:{...css(heading,'display','flexDirection','justifyContent','alignItems','columnGap','rowGap','marginBottom'),
h1:{text:h1.textContent,tabIndex:h1.tabIndex,...font(h1)},
subtitle:{text:subtitle.textContent,...font(subtitle)},
actions:{...css(actions,'display','columnGap','flexWrap'),fullWidth:px(actions.getBoundingClientRect().width)===px(heading.getBoundingClientRect().width),
buttons:[...actions.querySelectorAll('button')].map(buttonOf)},
height:px(heading.getBoundingClientRect().height)},
twoCol:{columns:getComputedStyle(grid).gridTemplateColumns.split(' ').length,...css(grid,'display','columnGap','rowGap'),
stacked:surfaces.length>1&&surfaces[1].getBoundingClientRect().top>=surfaces[0].getBoundingClientRect().bottom,
width:px(gridBox.width)},
surfaces:surfaces.map(surface=>({...edges(surface),...css(surface,'marginTop'),width:px(surface.getBoundingClientRect().width),
h2:{text:surface.querySelector('h2').textContent,...font(surface.querySelector('h2')),offset:px(surface.querySelector('h2').getBoundingClientRect().top-surface.getBoundingClientRect().top)},
paragraphs:[...surface.querySelectorAll(':scope > p')].filter(visible).map(node=>({text:node.textContent,...font(node),
offset:px(node.getBoundingClientRect().top-surface.getBoundingClientRect().top)}))})),
table:{wrap:{...css(wrap,'overflowX','marginLeft','marginRight'),offset:px(wrap.getBoundingClientRect().top-tagSurface.getBoundingClientRect().top)},
...css(table,'borderCollapse','fontSize','color'),fullWidth:px(table.getBoundingClientRect().width)>=px(wrap.clientWidth),
caption:{text:caption.textContent,...font(caption),...css(caption,'textAlign','captionSide')},
headers:[...table.querySelectorAll('thead th')].map(cell=>({text:cell.textContent,scope:cell.getAttribute('scope'),...font(cell),...edges(cell),...css(cell,'textAlign','verticalAlign')})),
firstCell:firstCell?{...font(firstCell),...edges(firstCell),...css(firstCell,'textAlign','verticalAlign')}:null,
firstRowHeight:rows[0]?px(rows[0].getBoundingClientRect().height):null,
rowActions:rows[0]?{...css(rows[0].querySelector('.row-actions'),'display','justifyContent','columnGap','flexWrap','alignItems'),
buttons:[...rows[0].querySelectorAll('.row-actions button')].map(buttonOf)}:null,
rows:rows.map(row=>{const cells=[...row.querySelectorAll('td')];return {cells:cells.length,height:px(row.getBoundingClientRect().height),tagLines:lines(cells[0]),tag:cells[0].textContent,prompts:cells[1].textContent,
images:shownText(cells[2]),imagesHidden:cells[2].querySelector('.ca-visually-hidden')?.textContent??null,
actions:[...cells[3].querySelectorAll('.row-actions button')].map(node=>node.textContent.trim())};})},
operations:operations.map(row=>{const h3=row.querySelector('h3'),p=row.querySelector('p'),rowActions=row.querySelector('.row-actions');return {
...css(row,'display','flexDirection','justifyContent','alignItems','columnGap','paddingTop','paddingBottom','borderTopWidth','borderTopStyle','borderTopColor'),
height:px(row.getBoundingClientRect().height),h3:{text:h3.textContent,...font(h3)},p:{text:p.textContent,...font(p)},
actions:{...css(rowActions,'flexShrink','flexWrap','columnGap'),buttons:[...rowActions.querySelectorAll('button')].map(buttonOf)}};}),
noPageScroll:document.documentElement.scrollWidth<=innerWidth+1,
};
}
// Recorded deviations only (plan "Mock inventory" + decisions D1-D15 + ledger rulings). Everything else must be equal.
export function mapVocabularyConcept(reference) {
return {...reference,
heading:{...reference.heading,actions:{...reference.heading.actions,
// D2: Auto-tag images is omitted until the image-annotation slice owns image tags.
buttons:reference.heading.actions.buttons.filter(item=>item.text!=='Auto-tag images')}},
table:{...reference.table,rows:reference.table.rows.map(row=>({...row,
// D3: no image-tag owner yet, so the Images cell shows "—" with a visually hidden "Not available yet".
images:'—',imagesHidden:'Not available yet'}))},
operations:reference.operations.map(row=>({...row,p:{...row.p,
// Ledger ruling: "1 positive prompts" -> singular, consistent with the shared plural helper (#238/#243).
text:row.p.text.replace(/^1 positive prompts$/,'1 positive prompt')}})),
};
}
// Concept dialogs are simpleInput (app.js:1138-1143). donors are concept inventories for controls the concept
// shows elsewhere: a `.field select` and a `p.small` (Filters dialog, app.js:1351) and a danger dialog action
// (Archive… in the editor, app.js:976), so added controls are held to concept tokens, never to production values.
export function mapVocabularyDialog(mode,reference,{filters,editor,categories}) {
const fields=reference.fields.map(item=>({...item,
// Inventory rows 27-29: the name is `required` (blank Apply is blocked natively).
required:true}));
const buttons=reference.actions.buttons.map(item=>item.text==='Apply in prototype'
// Inventory row 26: "Apply in prototype" is Apply; it submits the form so `required` is enforced natively.
?{...item,text:'Apply',type:'submit'}:item);
if(mode==='rename') {
// D14 (and ledger ruling "Delete inside Rename/merge"): danger `Delete tag…` first in the actions, concept danger tokens.
const danger=editor.actions.buttons.find(item=>item.danger);
return {...reference,fields,actions:{...reference.actions,buttons:[{...danger,text:'Delete tag…',type:'button'},...buttons]}};
}
if(mode==='manage') {
const select=filters.fields.find(item=>item.tag==='select');
return {...reference,
fields:[
// D9: a `Category` source select first, listing the stored categories (Manage loads every page, ruling: up to 1,000).
{...select,label:'Category',required:true,options:categories.map(value=>[value,value])},
// D9 / Task 8: the proposed name offers a category datalist (no trailing commas: one value).
...fields.map(item=>({...item,listed:true,commaOptions:false}))],
// D9: the preview sentence is a `p.small`, empty and hidden until Preview.
small:[{...filters.small[0],text:''}],
// D9: the primary is `Preview` until a preview is shown, then `Apply`.
actions:{...reference.actions,buttons:buttons.map(item=>item.text==='Apply'?{...item,text:'Preview'}:item)}};
}
return {...reference,fields,actions:{...reference.actions,buttons}};
}
-103
View File
@@ -1,103 +0,0 @@
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import {captureCanonicalState} from './history-canonical-comparison.mjs';
// Artifact-only captures of Tags & categories states that have no concept counterpart: the Manage categories
// preview, loading and 1,000-category cap notes, tag conflict and restore notes, the Delete tag confirmation (D14),
// empty lists (D12), a failed read with Retry, and Load more (D5). The page and the three concept dialogs are captured
// as concept/production pairs by workspace-vocabulary.spec.mjs. There are deliberately no pixel assertions or
// baselines: the maintainer accepts these captures visually before any baseline is added.
test.use({locale:'en-US',timezoneId:'UTC',reducedMotion:'reduce'});
const route='/prompt_manager/workspace#/tags';
const main=page=>page.locator('#workspace-main');
const rows=page=>main(page).locator('tbody tr');
const dialog=page=>page.locator('dialog.vocabulary-dialog');
const button=(scope,name)=>scope.getByRole('button',{name,exact:true});
const note=page=>dialog(page).locator('.warning-note');
const tagSurface='#workspace-main .two-col > section.surface:first-child';
const categoryOperation=(page,name)=>main(page).locator('.operation').filter({has:page.getByRole('heading',{level:3,name,exact:true})});
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);
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);}
const empty=(manifest,kind)=>({success:true,library_epoch:manifest.epoch,total:0,has_more:false,next_cursor:null,[kind]:[]});
for(const [width,height] of [[1600,1100],[390,844]])for(const theme of ['dark','light'])test(`vocabulary state captures for maintainer acceptance ${width} ${theme}`,async({page,request},testInfo)=>{
await page.setViewportSize({width,height});
const shot=(name,selector='dialog.vocabulary-dialog')=>captureCanonicalState(page,testInfo,`production-${name}-${width}-${theme}`,selector);
let manifest=await seed(request);await open(page,theme,manifest.tags.length);
// Manage categories: preview shown (D9).
let box=await openDialog(page,categoryOperation(page,'Manage category').getByRole('button',{name:'Review',exact:true}));
await expect(button(box,'Preview')).toBeEnabled();
await box.getByLabel('Proposed new category name',{exact:true}).fill('Scenery');await button(box,'Preview').click();
await expect(box.locator('p.small')).toHaveText('“Landscapes” is used by 3 positive prompts. Apply renames it to “Scenery”.');
await shot('manage-preview');
await page.keyboard.press('Escape');await expect(box).toHaveCount(0);
// Rename or merge: conflict note, then restore note (the server answers 409; nothing is written).
for(const [name,code,text] of [['rename-conflict','vocabulary-conflict',/This tag changed since this dialog opened/],
['rename-restored','storage-changed',/Prompt storage was restored since this dialog opened/]]) {
await page.route('**/prompt_manager/vocabulary/tags/*',item=>item.request().method()==='PATCH'
?item.fulfill({status:409,json:{success:false,code,error:'Changed.',library_epoch:manifest.epoch}}):item.continue(),{times:1});
box=await openDialog(page,rows(page).first().getByRole('button',{name:'Rename / merge',exact:true}));
await box.getByLabel('New name; existing names merge',{exact:true}).fill('cinematic_light');await button(box,'Apply').click();
await expect(note(page)).toHaveText(text);await shot(name);
await page.keyboard.press('Escape');await expect(box).toHaveCount(0);
await expect(rows(page)).toHaveCount(manifest.tags.length);
}
// Delete tag confirmation (D14) with the real impact sentence.
box=await openDialog(page,rows(page).first().getByRole('button',{name:'Rename / merge',exact:true}));
await button(box,'Delete tag…').click();await expect(button(box,'Delete')).toBeEnabled();
await expect(box.locator('p.small')).toBeVisible();await shot('delete-confirmation');
await page.keyboard.press('Escape');await expect(box).toHaveCount(0);
// Manage categories while further category pages load, and at the 1,000-category cap (ledger ruling).
manifest=await seed(request,{extra_categories:150});
let release;const held=new Promise(resolve=>{release=resolve;});
await page.route('**/prompt_manager/vocabulary/categories?*',async item=>{if(new URL(item.request().url()).searchParams.has('cursor'))await held;await item.continue();});
await open(page,theme,manifest.tags.length);
box=await openDialog(page,categoryOperation(page,'Manage category').getByRole('button',{name:'Review',exact:true}));
await expect(note(page)).toHaveText('Loading categories…');await shot('manage-loading');
release();await expect(note(page)).toBeHidden();await page.keyboard.press('Escape');await expect(box).toHaveCount(0);
await page.unroute('**/prompt_manager/vocabulary/categories?*');
await page.route('**/prompt_manager/vocabulary/categories?*',item=>{
const cursor=new URL(item.request().url()).searchParams.get('cursor');const index=cursor?Number(cursor.slice(1)):0;
const categories=Array.from({length:100},(_,offset)=>({value:`many_${String(index*100+offset).padStart(5,'0')}`,prompt_count:1}));
return item.fulfill({json:{success:true,library_epoch:manifest.epoch,total:5000,has_more:true,next_cursor:`c${index+1}`,categories}});
});
await open(page,theme);await expect(categoryOperation(page,'many_00000')).toHaveCount(1);
const review=categoryOperation(page,'Manage category').getByRole('button',{name:'Review',exact:true});
// Every mocked page has more, so scrolling to Review would keep appending; click it in place.
await review.evaluate(node=>node.click());await expect(dialog(page)).toBeVisible();
await expect(note(page)).toHaveText('Only the first 1,000 categories are listed.');await shot('manage-capped');
await page.keyboard.press('Escape');await page.unroute('**/prompt_manager/vocabulary/categories?*');
// Empty lists (D12).
await page.route('**/prompt_manager/vocabulary/tags?*',item=>item.fulfill({json:empty(manifest,'tags')}));
await page.route('**/prompt_manager/vocabulary/categories?*',item=>item.fulfill({json:empty(manifest,'categories')}));
await open(page,theme);
await expect(main(page).getByText('No tags yet.',{exact:true})).toBeVisible();await expect(main(page).getByText('No categories yet.',{exact:true})).toBeVisible();
await shot('empty','#workspace-main');
await page.unroute('**/prompt_manager/vocabulary/tags?*');await page.unroute('**/prompt_manager/vocabulary/categories?*');
// A failed read shows the inline error with Retry and no zero counts (D12).
await page.route('**/prompt_manager/vocabulary/tags?*',item=>item.fulfill({status:500,json:{success:false,error:'Database is unavailable'}}));
await open(page,theme);await expect(main(page).getByRole('alert')).toHaveText('Tags could not be loaded. Nothing was changed.');
await shot('read-error',tagSurface);
await page.unroute('**/prompt_manager/vocabulary/tags?*');
// Load more while more tags remain (D5): the end of the first page.
manifest=await seed(request,{extra_tags:211});await open(page,theme,100);
const more=button(main(page),'Load more tags');await expect(more).toBeVisible();
// A full-page clip of the end of the first page: scrolling there would append the next page before the capture.
const end=await more.evaluate(node=>{const box=node.closest('.surface').getBoundingClientRect();return box.bottom+scrollY;});
const path=testInfo.outputPath(`production-load-more-${width}-${theme}.png`);
await page.screenshot({path,fullPage:true,animations:'disabled',clip:{x:0,y:Math.max(0,end-420),width,height:440}});
await testInfo.attach(`production-load-more-${width}-${theme}`,{path,contentType:'image/png'});
await expect(rows(page)).toHaveCount(100);
});
-40
View File
@@ -458,46 +458,6 @@ test('frozen Effective pair uses the shared acknowledged sender and Escape close
} finally {await host.evaluate(()=>window.historyHost.dispose());await host.close();}
});
test('the inline current file names its prompts apart from the frozen run fields',async({page})=>{
// The current file resolves its own prompts, as a real host's saved PNG does, so the dialog holds two prompt texts per role.
await page.route(url=>url.pathname.endsWith('/inspection'),async route=>{const response=await route.fetch();const body=await response.json();
body.prompts={positive:{status:'resolved',text:'current file positive',source:'graph-inferred'},negative:{status:'resolved',text:'current file negative',source:'graph-inferred'}};
await route.fulfill({response,json:body});});
await open(page,page.fixture.detail_id);await marker(page,page.fixture.runs.shared).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
await dialog(page).getByRole('button',{name:'Raw',exact:true}).click();
const frozen=dialog(page).getByRole('region',{name:'Frozen run prompt texts',exact:true});
const before=await exact(frozen.getByRole('region',{name:'Positive prompt',exact:true}).locator('pre'));
await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();
await expect(page.getByRole('dialog',{name:'Image preview',exact:true})).toBeVisible();await page.keyboard.press('Escape');await expect(dialog(page)).toBeVisible();
const current=dialog(page).getByRole('region',{name:'Current reported file',exact:true});await expect(current).toBeVisible();
await expect(current.getByRole('region',{name:'Current file positive prompt',exact:true}).locator('pre')).toHaveText('current file positive');
await expect(current.getByRole('region',{name:'Current file negative prompt',exact:true}).locator('pre')).toHaveText('current file negative');
await expect(current.getByRole('button',{name:'Copy current file positive prompt',exact:true})).toBeVisible();
for(const role of ['Positive','Negative'])await expect(dialog(page).getByRole('region',{name:`${role} prompt`,exact:true})).toHaveCount(1);
await expect(current.getByRole('region',{name:/^(Positive|Negative) prompt$/})).toHaveCount(0);
expect(await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('pre'))).toBe(before);
});
test('the recorded run dialog with a resolved current file has no duplicate button or region names',async({page})=>{
await page.route(url=>url.pathname.endsWith('/inspection'),async route=>{const response=await route.fetch();const body=await response.json();
body.prompts={positive:{status:'resolved',text:'current file positive',source:'graph-inferred'},negative:{status:'resolved',text:'current file negative',source:'graph-inferred'}};
await route.fulfill({response,json:body});});
await open(page,page.fixture.detail_id);await marker(page,page.fixture.runs.shared).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
await dialog(page).getByRole('button',{name:'Raw',exact:true}).click();
await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();
await expect(page.getByRole('dialog',{name:'Image preview',exact:true})).toBeVisible();await page.keyboard.press('Escape');
await expect(dialog(page).getByRole('region',{name:'Current reported file',exact:true})).toBeVisible();
await expect(dialog(page).getByRole('region',{name:'Current file positive prompt',exact:true})).toBeVisible();
// Receipt rows repeat per-row actions by design; every other control and region in the dialog must be uniquely named.
const snapshot=await dialog(page).ariaSnapshot();const counts=new Map();
for(const line of snapshot.split('\n')){const match=/^\s*- (button|region|link) "((?:[^"\\]|\\.)*)"/.exec(line);if(!match)continue;
const key=`${match[1]} ${match[2]}`;counts.set(key,(counts.get(key)||0)+1);}
expect(counts.size).toBeGreaterThan(10);
const receiptNames=await dialog(page).locator('[data-receipt]').first().evaluate(row=>[...row.querySelectorAll('button,a,[role="region"]')].map(node=>node.getAttribute('aria-label')||node.textContent.trim()));
const repeated=[...counts].filter(([key,n])=>n>1&&!receiptNames.some(name=>key.endsWith(` ${name}`)));
expect(repeated).toEqual([]);
});
test('pagehide tears down dialogs and persisted reentry revalidates before enabling recorded actions',async({page})=>{
await open(page,page.fixture.detail_id);await marker(page,page.fixture.runs.shared).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();await expect(page.getByRole('dialog',{name:'Image preview',exact:true})).toBeVisible();
-793
View File
@@ -1,793 +0,0 @@
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
import {writeFile} from 'node:fs/promises';
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import {captureCanonicalState} from './history-canonical-comparison.mjs';
import {pageInventoryOf,inventoryOf,diffInventory,mapVocabularyConcept,mapVocabularyDialog} from './vocabulary-canonical-comparison.mjs';
const route='/prompt_manager/workspace#/tags';
// Prompts per concept tag (app.js:71-165): first appearance order, Library Browse counts.
const CONCEPT_COUNTS=[['cinematic',2],['blue_hour',1],['architecture',1],['minimal',2],['warm_light',1],['editorial',1],['forest',1],['mist',1],
['peaceful',2],['neon',1],['night',1],['space',1],['concept_art',1],['coast',1],['soft_light',1]];
const CATEGORIES=[['Landscapes',3],['Architecture',1],['Environments',1],['Illustration',1]];
const main=page=>page.locator('#workspace-main');
const surfaces=page=>main(page).locator('.two-col > section.surface');
const table=page=>main(page).getByRole('table',{name:'Reusable tags',exact:true});
const rows=page=>table(page).locator('tbody tr');
const tagRow=(page,name)=>rows(page).filter({has:page.locator('td:first-child').getByText(name,{exact:true})});
const categoryRow=(page,name)=>surfaces(page).nth(1).locator('.operation').filter({has:page.getByRole('heading',{level:3,name,exact:true})});
const loadMore=page=>main(page).getByRole('button',{name:'Load more tags',exact:true});
async function seed(request,data={}) {
const response=await request.post('/__test__/vocabulary/reset',{data});await expect(response).toBeOK();return response.json();
}
async function expectLibrary(page,{tags='',category='',count}) {
await expect(page).toHaveURL(/#\/library$/);
await expect(main(page).getByRole('heading',{level:1})).toHaveText('Prompt library');
await expect(main(page).getByRole('heading',{level:1})).toBeFocused();
await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('');
await expect(page.getByRole('textbox',{name:'Tags',exact:true})).toHaveValue(tags);
const select=page.getByRole('combobox',{name:'Category',exact:true});
await expect(select).toHaveValue(category);
if(!category) await expect(select.locator('option:checked')).toHaveText('All categories');
await expect(page.locator('.library-caption')).toHaveText(`${count} of ${count} positive prompts · Images are grouped by positive prompt`);
await expect(page.locator('article[data-prompt-id]')).toHaveCount(count);
}
let manifest;
test.beforeEach(async({page,request,browserName})=>{
await startCoverage(page,browserName);manifest=await seed(request);
page.vocabularyErrors=[];page.on('pageerror',error=>page.vocabularyErrors.push(error.message));
});
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.vocabularyErrors).toEqual([]);});
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(actions).toHaveText(['+ Create tag']);
await expect(actions.first()).not.toHaveClass(/ca-primary/);
await expect(main(page).getByRole('button',{name:'Auto-tag images'})).toHaveCount(0);
await expect(surfaces(page)).toHaveCount(2);
await expect(surfaces(page).locator('h2')).toHaveText(['Tag vocabulary','Categories']);
await expect(surfaces(page).locator(':scope > p:visible')).toHaveText(['Category/tag autocomplete uses this same vocabulary across nodes and dialogs.',
'Categories provide a broad home. Tags describe content; proposed collections group projects without changing either.']);
await expect(table(page).locator('caption')).toHaveText('Reusable tags');
await expect(table(page).locator('thead th')).toHaveText(['Tag','Prompts','Images','Actions']);
expect(await table(page).locator('thead th').evaluateAll(cells=>cells.map(cell=>cell.getAttribute('scope')))).toEqual(['col','col','col','col']);
const expected=[...CONCEPT_COUNTS,[manifest.pair_only_tag,1],[manifest.archived_only_tag,0],[manifest.negative_only_tag,0],[manifest.unused_tag,0]];
await expect(rows(page)).toHaveCount(expected.length);
expect(manifest.tags).toEqual(expected.map(([name])=>name));
await expect(rows(page).locator('td:nth-child(1)')).toHaveText(expected.map(([name])=>name));
await expect(rows(page).locator('td:nth-child(2)')).toHaveText(expected.map(([,count])=>String(count)));
const images=await rows(page).locator('td:nth-child(3)').evaluateAll(cells=>cells.map(cell=>[cell.querySelector('[aria-hidden="true"]')?.textContent,cell.querySelector('.ca-visually-hidden')?.textContent]));
expect(images).toEqual(expected.map(()=>['—','Not available yet']));
await expect(rows(page).first().locator('td:nth-child(3)')).toHaveAccessibleName('Not available yet');
const labels=await rows(page).evaluateAll(nodes=>nodes.map(row=>[...row.querySelectorAll('td:nth-child(4) .row-actions button')].map(button=>button.textContent)));
expect(labels).toEqual(expected.map(()=>['Browse','Rename / merge']));
const operations=surfaces(page).nth(1).locator('.operation');
await expect(operations.locator('h3')).toHaveText([...CATEGORIES.map(([name])=>name),'Manage category']);
await expect(operations.locator('p')).toHaveText([...CATEGORIES.map(([,count])=>`${count} positive prompt${count===1?'':'s'}`),'Rename or consolidate a category with a preview of affected recipes.']);
await expect(operations.locator('.row-actions button')).toHaveText(['Browse','Browse','Browse','Browse','Review']);
await expect(main(page).locator('.handoff')).toHaveCount(0);
await expect(main(page).locator('a[href*="admin"]')).toHaveCount(0);
await expect(main(page).getByRole('button',{name:/^Retry/})).toHaveCount(0);
await expect(loadMore(page)).toBeHidden();
});
test('prompt counts equal Library Browse results',async({page})=>{
for(const name of ['cinematic',manifest.pair_only_tag,manifest.archived_only_tag]) {
await page.goto(route);
const row=tagRow(page,name);await expect(row).toHaveCount(1);
const count=Number(await row.locator('td:nth-child(2)').textContent());
await row.getByRole('button',{name:'Browse',exact:true}).click();
await expectLibrary(page,{tags:name,count});
}
});
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();
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});
});
test('long vocabularies append and Load more is keyboard reachable',async({page,request})=>{
manifest=await seed(request,{extra_tags:211});expect(manifest.tags).toHaveLength(230);
await page.goto(route);
await expect(rows(page)).toHaveCount(100);
await expect(loadMore(page)).toBeVisible();
await rows(page).last().getByRole('button',{name:'Rename / merge',exact:true}).evaluate(button=>button.focus({preventScroll:true}));
await page.keyboard.press('Tab');
await expect(loadMore(page)).toBeFocused();
// Focus scrolled the end of the list into view, which appends the next page.
await expect(rows(page)).toHaveCount(200);
await expect(loadMore(page)).toBeFocused();
await page.keyboard.press('Enter');
await expect(rows(page)).toHaveCount(230);
await expect(loadMore(page)).toHaveCount(0);
await expect(rows(page).nth(200).getByRole('button',{name:'Browse',exact:true})).toBeFocused();
await expect(rows(page).locator('td:nth-child(1)')).toHaveText(manifest.tags);
// Reload at the top so restored scroll does not append before the check.
await page.evaluate(()=>window.scrollTo(0,0));
await page.reload();
await expect(rows(page)).toHaveCount(100);
const scroll=top=>page.evaluate(y=>new Promise(resolve=>{window.scrollTo(0,y ?? document.documentElement.scrollHeight);requestAnimationFrame(()=>requestAnimationFrame(resolve));}),top);
for(const count of [200,230]) {
// Each new arrival of the end of the list appends one page.
await scroll(0);await scroll(null);
await expect(rows(page)).toHaveCount(count);
}
await expect(loadMore(page)).toHaveCount(0);
await expect(rows(page).locator('td:nth-child(1)')).toHaveText(manifest.tags);
});
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=>{
if(failures++) return route.continue();
return route.fulfill({status:500,json:{success:false,error:'Database is unavailable'}});
});
await page.goto(route);
const alert=surfaces(page).first().getByRole('alert');
await expect(alert).toHaveText('Tags could not be loaded. Nothing was changed.');
await expect(table(page)).toHaveCount(0);
await expect(surfaces(page).first().locator('td')).toHaveCount(0);
await expect(surfaces(page).first().locator('.empty')).toBeHidden();
await expect(loadMore(page)).toHaveCount(0);
await expect(categoryRow(page,'Landscapes').locator('p')).toHaveText('3 positive prompts');
const retry=surfaces(page).first().getByRole('button',{name:'Retry',exact:true});
await retry.click();
await expect(rows(page)).toHaveCount(manifest.tags.length);
await expect(alert).toHaveCount(0);await expect(retry).toHaveCount(0);
});
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 page.reload();
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Tags');
await page.goto('/prompt_manager/workspace#/library');
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Prompt library');
await expect(page).toHaveTitle('Prompt library · Prompt Manager');
});
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);};
const Observer=window.IntersectionObserver;
window.IntersectionObserver=class extends Observer {
constructor(...args) {super(...args);this.record={targets:[],disconnected:false};window.__vocabularyObservers.push(this.record);}
observe(target) {this.record.targets.push(target.className);return super.observe(target);}
disconnect() {this.record.disconnected=true;return super.disconnect();}
};
});
const held=[];
await page.route('**/prompt_manager/vocabulary/**',route=>{held.push(route);});
await page.goto(route);
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 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]);
await expect(main(page).locator('.two-col')).toHaveCount(0);
expect(await page.evaluate(()=>document.getElementById('workspace-main').classList.contains('vocabulary-main'))).toBe(false);
await page.unroute('**/prompt_manager/vocabulary/**');
for(const item of held) await item.abort().catch(()=>{});
});
test('390px layout stacks without horizontal page scroll; table scrolls locally',async({page,request})=>{
manifest=await seed(request,{extra_tags:3});
await page.setViewportSize({width:390,height:844});
await page.goto(route);
await expect(rows(page)).toHaveCount(manifest.tags.length);
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');
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};
});
expect(layout).toEqual({tracks:1,stacked:true,overflow:'auto',heading:'column',operation:'column',actions:true});
});
async function expectEveryRowActionVisible(page,request,css) {
const name='an_extraordinarily_long_descriptive_vocabulary_x';expect(name.length).toBeGreaterThanOrEqual(40);expect(name.length).toBeLessThanOrEqual(50);
await expect(await request.post('/prompt_manager/vocabulary/tags',{data:{expected_epoch:manifest.epoch,name}})).toBeOK();
await page.setViewportSize({width:390,height:844});
await page.goto(route);await expect(rows(page)).toHaveCount(manifest.tags.length+1);await expect(tagRow(page,name)).toHaveCount(1);
if(css)await page.addStyleTag({content:css});
const report=await table(page).evaluate(node=>{
const wrap=node.closest('.table-wrap'),box=wrap.getBoundingClientRect();
const clipped=[...node.querySelectorAll('tbody tr')].flatMap(row=>[...row.querySelectorAll('.row-actions button')]
.filter(item=>{const rect=item.getBoundingClientRect();return rect.left<box.left-0.5||rect.right>box.right+0.5;})
.map(item=>`${row.cells[0].textContent}: ${item.textContent}`));
return {overflow:wrap.scrollWidth<=wrap.clientWidth+1,clipped};
});
expect(report).toEqual({overflow:true,clipped:[]});
expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
}
test('long tag names wrap at 390px so every row action stays fully visible',async({page,request})=>expectEveryRowActionVisible(page,request));
// The fit must not depend on font metrics: CI's stock DejaVu Sans fallback is about 9% wider than the pinned image's
// fallback, which pushed Rename / merge out of view. Wider letter spacing stands in for any wider fallback font.
test('row actions stay fully visible at 390px with wider font metrics',async({page,request})=>
expectEveryRowActionVisible(page,request,'.vocabulary-main table{letter-spacing:1px}'));
// Dialogs: the editor's modal shell (concept simpleInput, app.js:1138-1143) with truthful outcomes.
const dialog=page=>page.locator('dialog.vocabulary-dialog');
const button=(scope,name)=>scope.getByRole('button',{name,exact:true});
const field=(scope,label)=>scope.getByLabel(label,{exact:true});
const note=scope=>scope.locator('.warning-note');
const toast=page=>page.locator('#workspace-toast');
const actionNames=scope=>scope.locator('.dialog-actions button').evaluateAll(nodes=>nodes.map(node=>node.textContent.trim()));
const snapshot=async request=>{const response=await request.get('/__test__/vocabulary/snapshot');await expect(response).toBeOK();return response.json();};
const backup=async request=>{const response=await request.get('/__test__/vocabulary/backup');await expect(response).toBeOK();return response.body();};
const restore=async(request,buffer)=>expect(await request.post('/prompt_manager/restore',{multipart:{database_file:{name:'vocabulary.db',mimeType:'application/octet-stream',buffer}}})).toBeOK();
const tagNames=rows=>rows.tags.map(([,name])=>name);
function trackWrites(page) {
const writes=[];page.on('request',item=>{if(item.method()!=='GET')writes.push({method:item.method(),url:new URL(item.url()).pathname,body:item.postDataJSON()});});return writes;
}
async function openPage(page) {await page.goto(route);await expect(rows(page)).toHaveCount(manifest.tags.length);}
async function openDialog(page,trigger,title) {
await trigger.click();const box=dialog(page);await expect(box).toBeVisible();
expect(await box.evaluate(node=>node.matches(':modal'))).toBe(true);
await expect(box.getByRole('heading',{level:2})).toHaveText(title);
await expect(button(box,'Close dialog')).toBeFocused();
return box;
}
const openRename=(page,name)=>openDialog(page,tagRow(page,name).getByRole('button',{name:'Rename / merge',exact:true}),'Rename or merge tag');
const openManage=page=>openDialog(page,categoryRow(page,'Manage category').getByRole('button',{name:'Review',exact:true}),'Manage categories');
test('create tag adds an unused tag that every editor offers',async({page,request})=>{
const writes=trackWrites(page);await openPage(page);const before=await snapshot(request);
const box=await openDialog(page,main(page).getByRole('button',{name:'+ Create tag',exact:true}),'Create tag');
await expect(box).toHaveAttribute('aria-labelledby',await box.getByRole('heading',{level:2}).getAttribute('id'));
await expect(box.locator('.dialog-header > button')).toHaveText('×');
await expect(box.locator('.field label')).toHaveText(['Tag name']);
const input=field(box,'Tag name');await expect(input).toHaveValue('');await expect(input).toHaveAttribute('required','');
expect(await actionNames(box)).toEqual(['Cancel','Apply']);
await expect(button(box,'Apply')).toHaveClass(/ca-primary/);await expect(button(box,'Cancel')).not.toHaveClass(/ca-primary/);
await button(box,'Apply').click();
await expect(box).toBeVisible();expect(await input.evaluate(node=>node.validity.valueMissing)).toBe(true);expect(writes).toEqual([]);
await input.fill('sunset');await button(box,'Apply').click();
await expect(box).toHaveCount(0);
await expect(toast(page)).toHaveText('Tag created.');
const row=tagRow(page,'sunset');await expect(row.locator('td:nth-child(2)')).toHaveText('0');
await expect(row.getByRole('button',{name:'Rename / merge',exact:true})).toBeFocused();
expect(writes).toEqual([{method:'POST',url:'/prompt_manager/vocabulary/tags',body:{expected_epoch:manifest.epoch,name:'sunset'}}]);
const after=await snapshot(request);
expect(tagNames(after)).toEqual([...manifest.tags,'sunset']);
expect({...after,tags:before.tags}).toEqual(before);
await page.goto('/prompt_manager/workspace#/library');
await page.getByRole('button',{name:'+ New prompt',exact:true}).click();
await expect(page.locator('dialog.library-editor datalist#tag-options option[value="sunset,"]')).toHaveCount(1);
await page.keyboard.press('Escape');
await page.goto(route);await expect(tagRow(page,'sunset')).toHaveCount(1);
});
test('rename and merge persist and collapse duplicate links',async({page,request})=>{
const writes=trackWrites(page);await openPage(page);
let box=await openRename(page,'blue_hour');
await expect(box.locator('.field label')).toHaveText(['New name; existing names merge']);
const input=field(box,'New name; existing names merge');
await expect(input).toHaveValue('blue_hour');await expect(input).toHaveAttribute('required','');
expect(await actionNames(box)).toEqual(['Delete tag…','Cancel','Apply']);
await expect(button(box,'Delete tag…')).toHaveClass(/danger/);await expect(button(box,'Apply')).toHaveClass(/ca-primary/);
await input.fill('golden_hour');await button(box,'Apply').click();
await expect(box).toHaveCount(0);await expect(toast(page)).toHaveText('Tag renamed.');
await expect(tagRow(page,'blue_hour')).toHaveCount(0);
await expect(tagRow(page,'golden_hour').locator('td:nth-child(2)')).toHaveText('1');
await expect(tagRow(page,'golden_hour').getByRole('button',{name:'Rename / merge',exact:true})).toBeFocused();
expect(writes.at(-1)).toMatchObject({method:'PATCH',body:{expected_epoch:manifest.epoch,name:'blue_hour',new_name:'golden_hour'}});
expect(writes.at(-1).url).toMatch(/^\/prompt_manager\/vocabulary\/tags\/\d+$/);
// Alpine observatory carries both: merging must leave one link, not two.
box=await openRename(page,'architecture');
await field(box,'New name; existing names merge').fill('cinematic');await button(box,'Apply').click();
await expect(box).toHaveCount(0);await expect(toast(page)).toHaveText('Tag merged without duplicate links.');
await expect(tagRow(page,'architecture')).toHaveCount(0);
const target=tagRow(page,'cinematic');await expect(target.locator('td:nth-child(2)')).toHaveText('2');
await expect(target.getByRole('button',{name:'Rename / merge',exact:true})).toBeFocused();
const after=await snapshot(request);
expect(tagNames(after)).not.toContain('architecture');
const cinematic=after.tags.find(([,name])=>name==='cinematic')[0];
expect(after.prompt_tags.filter(([,tag])=>tag===cinematic)).toHaveLength(2);
await page.reload();await expect(tagRow(page,'golden_hour')).toHaveCount(1);await expect(tagRow(page,'architecture')).toHaveCount(0);
await tagRow(page,'cinematic').getByRole('button',{name:'Browse',exact:true}).click();
await expectLibrary(page,{tags:'cinematic',count:2});
});
test('manage categories previews real impact before applying',async({page,request})=>{
const writes=trackWrites(page);await openPage(page);
let box=await openManage(page);
await expect(box.locator('.field label')).toHaveText(['Category','Proposed new category name']);
const source=field(box,'Category'),target=field(box,'Proposed new category name');
await expect(source.locator('option')).toHaveText(CATEGORIES.map(([name])=>name));
await expect(source).toHaveValue('Landscapes');
await expect(target).toHaveValue('');await expect(target).toHaveAttribute('required','');await expect(target).toHaveAttribute('list','vocabulary-category-options');
// Its own id: the recipe editor's `datalist#categories` is a different list.
await expect(page.locator('datalist#categories')).toHaveCount(0);
expect(await box.locator('datalist#vocabulary-category-options option').evaluateAll(nodes=>nodes.map(node=>node.value))).toEqual(CATEGORIES.map(([name])=>name));
expect(await actionNames(box)).toEqual(['Cancel','Preview']);
await expect(button(box,'Preview')).toHaveClass(/ca-primary/);
const impact=box.locator('p.small');await expect(impact).toBeHidden();
await target.fill('Scenery');await button(box,'Preview').click();
await expect(impact).toHaveText('“Landscapes” is used by 3 positive prompts. Apply renames it to “Scenery”.');
expect(await actionNames(box)).toEqual(['Cancel','Apply']);
expect(writes.map(write=>write.url)).toEqual(['/prompt_manager/vocabulary/categories/preview']);
await target.fill('Sceneries');
await expect(impact).toBeHidden();expect(await actionNames(box)).toEqual(['Cancel','Preview']);
await target.fill('Scenery');await button(box,'Preview').click();await expect(impact).toBeVisible();
await button(box,'Apply').click();
await expect(box).toHaveCount(0);await expect(toast(page)).toHaveText('Category renamed.');
await expect(categoryRow(page,'Landscapes')).toHaveCount(0);
await expect(categoryRow(page,'Scenery').locator('p')).toHaveText('3 positive prompts');
await expect(categoryRow(page,'Scenery').getByRole('button',{name:'Browse',exact:true})).toBeFocused();
expect(writes.at(-1)).toMatchObject({url:'/prompt_manager/vocabulary/categories/apply',body:{expected_epoch:manifest.epoch,source:'Landscapes',target:'Scenery',impact_token:expect.stringMatching(/^[0-9a-f]{64}$/)}});
box=await openManage(page);
await field(box,'Category').selectOption('Illustration');await field(box,'Proposed new category name').fill('Architecture');
await button(box,'Preview').click();
await expect(box.locator('p.small')).toHaveText('“Illustration” is used by 1 positive prompt. Apply consolidates it into the existing “Architecture”.');
await field(box,'Category').selectOption('Environments');await expect(box.locator('p.small')).toBeHidden();
await field(box,'Category').selectOption('Illustration');await button(box,'Preview').click();
await button(box,'Apply').click();
await expect(box).toHaveCount(0);await expect(toast(page)).toHaveText('Categories consolidated.');
await expect(categoryRow(page,'Illustration')).toHaveCount(0);
await expect(categoryRow(page,'Architecture').locator('p')).toHaveText('2 positive prompts');
await page.reload();await expect(categoryRow(page,'Scenery')).toHaveCount(1);await expect(categoryRow(page,'Illustration')).toHaveCount(0);
const after=await snapshot(request);
expect(after.prompts.filter(row=>row[2]==='Scenery')).toHaveLength(3);
});
test('an edit during a pending preview needs a new Preview; Apply never sends the old values',async({page,request})=>{
const writes=trackWrites(page);await openPage(page);const before=await snapshot(request);
let release;const held=new Promise(resolve=>{release=resolve;});
await page.route('**/prompt_manager/vocabulary/categories/preview',async route=>{await held;await route.continue();},{times:1});
const box=await openManage(page);
const source=field(box,'Category'),target=field(box,'Proposed new category name'),impact=box.locator('p.small');
await target.fill('Scenery');await button(box,'Preview').click();
await expect(button(box,'Preview')).toBeDisabled();
await target.fill('Vistas');await source.selectOption('Illustration');
release();
await expect.poll(()=>writes.length).toBe(1);
expect(writes[0]).toMatchObject({url:'/prompt_manager/vocabulary/categories/preview',body:{source:'Landscapes',target:'Scenery'}});
// The late "Landscapes → Scenery" preview no longer describes the form: no impact, no Apply.
await expect(button(box,'Preview')).toBeEnabled();
await expect(impact).toBeHidden();expect(await actionNames(box)).toEqual(['Cancel','Preview']);
await expect(target).toHaveValue('Vistas');await expect(source).toHaveValue('Illustration');
await button(box,'Preview').click();
await expect(impact).toHaveText('“Illustration” is used by 1 positive prompt. Apply renames it to “Vistas”.');
expect(writes.at(-1)).toMatchObject({url:'/prompt_manager/vocabulary/categories/preview',body:{source:'Illustration',target:'Vistas'}});
expect(writes.filter(write=>write.url.endsWith('/apply'))).toEqual([]);
expect(await snapshot(request)).toEqual(before);
});
test('delete tag confirms real impact and removes only the vocabulary name',async({page,request})=>{
const writes=trackWrites(page);await openPage(page);const before=await snapshot(request);
let box=await openRename(page,'cinematic');
await button(box,'Delete tag…').click();
await expect(box.getByRole('heading',{level:2})).toHaveText('Delete tag');
await expect(button(box,'Close dialog')).toBeFocused();
await expect(box.locator('p.small')).toHaveText('Deleting “cinematic” removes the name and its links from 2 positive prompts. The prompts, negative variants, images and history themselves are kept.');
expect(await actionNames(box)).toEqual(['Cancel','Delete']);
await expect(button(box,'Delete')).toHaveClass(/danger/);await expect(box.locator('.field')).toHaveCount(0);
await button(box,'Cancel').click();await expect(box).toHaveCount(0);
await expect(tagRow(page,'cinematic').getByRole('button',{name:'Rename / merge',exact:true})).toBeFocused();
expect(writes).toEqual([]);expect(await snapshot(request)).toEqual(before);
box=await openRename(page,'cinematic');await button(box,'Delete tag…').click();
await expect(box.locator('p.small')).toBeVisible();await button(box,'Delete').click();
await expect(box).toHaveCount(0);await expect(toast(page)).toHaveText('Tag deleted.');
await expect(tagRow(page,'cinematic')).toHaveCount(0);
await expect(main(page).getByRole('heading',{level:1})).toBeFocused();
expect(writes).toEqual([{method:'POST',url:expect.stringMatching(/^\/prompt_manager\/vocabulary\/tags\/\d+\/delete$/),body:{expected_epoch:manifest.epoch,name:'cinematic'}}]);
const after=await snapshot(request);
const cinematic=before.tags.find(([,name])=>name==='cinematic')[0];
expect(after.tags).toEqual(before.tags.filter(([id])=>id!==cinematic));
expect(after.prompt_tags).toEqual(before.prompt_tags.filter(([,tag])=>tag!==cinematic));
for(const table of ['prompt_pair_tags','prompt_pairs','generated_images','image_prompt_pairs']) expect(after[table]).toEqual(before[table]);
expect(after.prompts.map(([id,text,category])=>[id,text,category])).toEqual(before.prompts.map(([id,text,category])=>[id,text,category]));
await page.goto('/prompt_manager/workspace#/library');
await expect(page.locator('article[data-prompt-id]')).toHaveCount(6);
await expect(page.locator('.library-chip').filter({hasText:/^cinematic$/})).toHaveCount(0);
await expect(page.locator('.library-chip').filter({hasText:/^blue_hour$/})).toHaveCount(1);
});
test('cancel and escape write nothing and restore focus',async({page,request})=>{
const writes=trackWrites(page);await openPage(page);const before=await snapshot(request);
const create=main(page).getByRole('button',{name:'+ Create tag',exact:true});
let box=await openDialog(page,create,'Create tag');
await field(box,'Tag name').fill('never_saved');await page.keyboard.press('Escape');
await expect(box).toHaveCount(0);await expect(create).toBeFocused();
box=await openRename(page,'mist');await field(box,'New name; existing names merge').fill('never_renamed');
await button(box,'Close dialog').click();await expect(box).toHaveCount(0);
await expect(tagRow(page,'mist').getByRole('button',{name:'Rename / merge',exact:true})).toBeFocused();
box=await openRename(page,'mist');await button(box,'Delete tag…').click();await expect(box.locator('p.small')).toBeVisible();
await page.keyboard.press('Escape');await expect(box).toHaveCount(0);
await expect(tagRow(page,'mist').getByRole('button',{name:'Rename / merge',exact:true})).toBeFocused();
box=await openManage(page);await field(box,'Proposed new category name').fill('Scenery');await button(box,'Preview').click();
await expect(box.locator('p.small')).toBeVisible();await button(box,'Cancel').click();
await expect(box).toHaveCount(0);await expect(categoryRow(page,'Manage category').getByRole('button',{name:'Review',exact:true})).toBeFocused();
expect(writes.map(write=>write.url)).toEqual(['/prompt_manager/vocabulary/categories/preview']);
expect(await snapshot(request)).toEqual(before);
await expect(toast(page)).toHaveText('');
});
test('conflict and restore states keep the dialog and write nothing',async({page,request})=>{
const saved=await backup(request);const writes=trackWrites(page);await openPage(page);
let box=await openRename(page,'mist');
await expect(await request.put('/prompt_manager/tags/mist',{data:{new_name:'fog'}})).toBeOK();
await field(box,'New name; existing names merge').fill('haze');await button(box,'Apply').click();
await expect(note(box)).toHaveText('This tag changed since this dialog opened. Nothing was changed. The list was re-read; review it before trying again.');
await expect(box).toBeVisible();await expect(button(box,'Apply')).toBeDisabled();
await expect(tagRow(page,'fog')).toHaveCount(1);await expect(tagRow(page,'mist')).toHaveCount(0);
expect(tagNames(await snapshot(request))).not.toContain('haze');
await button(box,'Cancel').click();await expect(box).toHaveCount(0);
box=await openManage(page);await field(box,'Proposed new category name').fill('Scenery');await button(box,'Preview').click();
await expect(box.locator('p.small')).toHaveText('“Landscapes” is used by 3 positive prompts. Apply renames it to “Scenery”.');
const geometry=manifest.recipes[1].positive_id;
const current=await(await request.get(`/prompt_manager/library/${geometry}`)).json();
await expect(await request.patch(`/prompt_manager/library/${geometry}`,{data:{expected_epoch:current.library_epoch,if_match:current.positive.edit_token,metadata:{category:'Landscapes'}}})).toBeOK();
await button(box,'Apply').click();
await expect(note(box)).toHaveText('This category changed since this dialog opened. Nothing was changed. Preview again before applying.');
expect(await actionNames(box)).toEqual(['Cancel','Preview']);await expect(box.locator('p.small')).toBeHidden();
expect((await snapshot(request)).prompts.some(row=>row[2]==='Scenery')).toBe(false);
await button(box,'Preview').click();
await expect(box.locator('p.small')).toHaveText('“Landscapes” is used by 4 positive prompts. Apply renames it to “Scenery”.');
await expect(note(box)).toBeHidden();
await button(box,'Cancel').click();
const before=await snapshot(request);
box=await openDialog(page,main(page).getByRole('button',{name:'+ Create tag',exact:true}),'Create tag');
await restore(request,saved);
await field(box,'Tag name').fill('sunrise');await button(box,'Apply').click();
await expect(note(box)).toHaveText('Prompt storage was restored since this dialog opened. Nothing was changed.');
await expect(button(box,'Apply')).toBeDisabled();
expect(await actionNames(box)).toEqual(['Reload','Cancel','Apply']);
const restored=await snapshot(request);expect(tagNames(restored)).not.toContain('sunrise');expect(restored.tags).not.toEqual(before.tags);
await button(box,'Reload').click();await expect(box).toHaveCount(0);
await expect(main(page).getByRole('heading',{level:1})).toBeFocused();
await expect(tagRow(page,'mist')).toHaveCount(1);await expect(tagRow(page,'fog')).toHaveCount(0);
expect(writes.filter(write=>write.url==='/prompt_manager/vocabulary/tags')).toHaveLength(1);
});
test('lost response is outcome-unknown',async({page,request})=>{
const writes=trackWrites(page);await openPage(page);
await page.route('**/prompt_manager/vocabulary/tags/*',async route=>{
if(route.request().method()!=='PATCH')return route.continue();
await route.fetch();await route.abort('failed');
});
const box=await openRename(page,'forest');
await field(box,'New name; existing names merge').fill('woodland');await button(box,'Apply').click();
await expect(note(box)).toHaveText('The result could not be confirmed. The lists were re-read; check them before applying again.');
await expect(box).toBeVisible();
await expect(tagRow(page,'woodland')).toHaveCount(1);await expect(tagRow(page,'forest')).toHaveCount(0);
expect(tagNames(await snapshot(request))).toContain('woodland');
// Never resent on its own.
await page.evaluate(()=>new Promise(resolve=>setTimeout(resolve,300)));
expect(writes.filter(write=>write.method==='PATCH')).toHaveLength(1);
await expect(button(box,'Apply')).toBeEnabled();
await button(box,'Cancel').click();await expect(box).toHaveCount(0);
await expect(main(page).getByRole('heading',{level:1})).toBeFocused();
});
const nav=(page,name)=>page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name,exact:true});
const chips=(page,name)=>main(page).locator('article[data-prompt-id] .library-chip').filter({hasText:new RegExp(`^${name}$`)});
/** Visit the Library so its shelf is cached, then open Rename on blue_hour and start a rename whose PATCH is held. */
async function heldRename(page) {
await page.goto('/prompt_manager/workspace#/library');await expect(chips(page,'blue_hour')).toHaveCount(1);
await nav(page,'Overview').click();await nav(page,'Tags & categories').click();
await expect(rows(page)).toHaveCount(manifest.tags.length);
let held;const arrived=new Promise(resolve=>{held=resolve;});
await page.route('**/prompt_manager/vocabulary/tags/*',route=>route.request().method()==='PATCH'?held(route):route.continue());
const box=await openRename(page,'blue_hour');
await field(box,'New name; existing names merge').fill('golden_hour');await button(box,'Apply').click();
return {box,patch:await arrived};
}
async function releaseAndExpectFreshLibrary(page,patch) {
const landed=page.waitForResponse(response=>response.request().method()==='PATCH');
await patch.continue();expect((await landed).ok()).toBe(true);
await nav(page,'Prompt library').click();
await expect(main(page).getByRole('heading',{level:1})).toHaveText('Prompt library');
await expect(chips(page,'golden_hour')).toHaveCount(1);await expect(chips(page,'blue_hour')).toHaveCount(0);
}
test('a write that lands after a forced dialog close still refreshes the lists and the cached Library',async({page,request})=>{
const {box,patch}=await heldRename(page);
// Chromium force-closes a modal on a repeated Escape; close() is the same event in every engine.
await box.evaluate(node=>node.close());
await expect(box).toHaveCount(0);
await expect(tagRow(page,'blue_hour').getByRole('button',{name:'Rename / merge',exact:true})).toBeFocused();
const landed=page.waitForResponse(response=>response.request().method()==='PATCH');
await patch.continue();await landed;
await expect(tagRow(page,'golden_hour')).toHaveCount(1);await expect(tagRow(page,'blue_hour')).toHaveCount(0);
expect(tagNames(await snapshot(request))).toContain('golden_hour');
await expect(toast(page)).not.toHaveText('Tag renamed.');
await nav(page,'Prompt library').click();
await expect(chips(page,'golden_hour')).toHaveCount(1);await expect(chips(page,'blue_hour')).toHaveCount(0);
});
test('Back during a write removes the dialog and the landed write refreshes the cached Library',async({page})=>{
const {box,patch}=await heldRename(page);
await page.goBack();
await expect(main(page).getByRole('heading',{level:1})).toHaveText('Overview');
await expect(box).toHaveCount(0);
expect(await page.evaluate(()=>document.querySelectorAll('dialog').length)).toBe(0);
// Nothing stays inert: the page takes keyboard focus again.
await nav(page,'Prompt library').focus();await expect(nav(page,'Prompt library')).toBeFocused();
await releaseAndExpectFreshLibrary(page,patch);
});
test('a failed Load more read moves keyboard focus to Retry',async({page,request})=>{
manifest=await seed(request,{extra_tags:211});
await page.route('**/prompt_manager/vocabulary/tags?*',route=>{
if(new URL(route.request().url()).searchParams.has('cursor')) return route.fulfill({status:500,json:{success:false,error:'Database is unavailable'}});
return route.continue();
});
await page.goto(route);await expect(rows(page)).toHaveCount(100);
await loadMore(page).evaluate(button=>button.focus({preventScroll:true}));
await page.keyboard.press('Enter');
const retry=surfaces(page).first().getByRole('button',{name:'Retry',exact:true});
await expect(retry).toBeVisible();await expect(retry).toBeFocused();
await expect(rows(page)).toHaveCount(100);
});
test('an unconfirmed delete disables Delete and re-reads the impact before anything else',async({page,request})=>{
const writes=trackWrites(page);await openPage(page);
await page.route('**/prompt_manager/vocabulary/tags/*/delete',async route=>{await route.fetch();await route.abort('failed');});
const box=await openRename(page,'forest');await button(box,'Delete tag…').click();
const impact=box.locator('p.small');await expect(impact).toBeVisible();
await button(box,'Delete').click();
await expect(note(box)).toHaveText('The result could not be confirmed. The lists were re-read; check them before applying again.');
await expect(impact).toBeHidden();await expect(button(box,'Delete')).toBeDisabled();
expect(await actionNames(box)).toEqual(['Retry','Cancel','Delete']);
await expect(tagRow(page,'forest')).toHaveCount(0);expect(tagNames(await snapshot(request))).not.toContain('forest');
await page.evaluate(()=>new Promise(resolve=>setTimeout(resolve,300)));
expect(writes.filter(write=>write.method==='POST')).toHaveLength(1);
// Retry re-reads the impact and resends nothing: the tag is gone, so it is a changed tag.
await button(box,'Retry').click();
await expect(note(box)).toHaveText('This tag changed since this dialog opened. Nothing was changed. The list was re-read; review it before trying again.');
await expect(button(box,'Delete')).toBeDisabled();await expect(impact).toBeHidden();
expect(writes.filter(write=>write.method==='POST')).toHaveLength(1);
});
test('delete confirmation read failure, missing tag and restore keep Delete disabled',async({page,request})=>{
const saved=await backup(request);const writes=trackWrites(page);await openPage(page);
let failures=1;
await page.route('**/prompt_manager/vocabulary/tags/*/impact',route=>failures-->0?route.fulfill({status:500,json:{success:false,error:'Database is unavailable'}}):route.continue());
let box=await openRename(page,'mist');await button(box,'Delete tag…').click();
await expect(note(box)).toHaveText('Linked prompts could not be read. Nothing was deleted.');
await expect(button(box,'Delete')).toBeDisabled();expect(await actionNames(box)).toEqual(['Retry','Cancel','Delete']);
await button(box,'Retry').click();
await expect(box.locator('p.small')).toHaveText('Deleting “mist” removes the name and its links from 1 positive prompt. The prompts, negative variants, images and history themselves are kept.');
await expect(note(box)).toBeHidden();await expect(button(box,'Delete')).toBeEnabled();
await button(box,'Cancel').click();
// Deleted elsewhere: the impact read finds no tag, which is a changed tag.
box=await openRename(page,'forest');
await expect(await request.delete('/prompt_manager/tags/forest')).toBeOK();
await button(box,'Delete tag…').click();
await expect(note(box)).toHaveText('This tag changed since this dialog opened. Nothing was changed. The list was re-read; review it before trying again.');
await expect(button(box,'Delete')).toBeDisabled();await expect(tagRow(page,'forest')).toHaveCount(0);
await button(box,'Cancel').click();
// Renamed elsewhere after the impact read: the guarded delete is refused and writes nothing.
box=await openRename(page,'peaceful');await button(box,'Delete tag…').click();await expect(button(box,'Delete')).toBeEnabled();
await expect(await request.put('/prompt_manager/tags/peaceful',{data:{new_name:'calm'}})).toBeOK();
await button(box,'Delete').click();
await expect(note(box)).toHaveText('This tag changed since this dialog opened. Nothing was changed. The list was re-read; review it before trying again.');
await expect(button(box,'Delete')).toBeDisabled();await expect(tagRow(page,'calm').locator('td:nth-child(2)')).toHaveText('2');
await button(box,'Cancel').click();
box=await openRename(page,'mist');await restore(request,saved);
await button(box,'Delete tag…').click();
await expect(note(box)).toHaveText('Prompt storage was restored since this dialog opened. Nothing was changed.');
await expect(button(box,'Delete')).toBeDisabled();expect(await actionNames(box)).toEqual(['Reload','Cancel','Delete']);
await button(box,'Reload').click();await expect(box).toHaveCount(0);
await expect(main(page).getByRole('heading',{level:1})).toBeFocused();await expect(tagRow(page,'forest')).toHaveCount(1);
expect(writes.map(write=>write.url)).toEqual([expect.stringMatching(/^\/prompt_manager\/vocabulary\/tags\/\d+\/delete$/)]);
});
test('manage categories lists every category page without Load more',async({page,request})=>{
manifest=await seed(request,{extra_categories:150});
let release;const held=new Promise(resolve=>{release=resolve;});
await page.route('**/prompt_manager/vocabulary/categories?*',async route=>{
if(new URL(route.request().url()).searchParams.has('cursor')) await held;
return route.continue();
});
await page.goto(route);await expect(categoryRow(page,'Landscapes')).toHaveCount(1);
const box=await openManage(page);
await expect(note(box)).toHaveText('Loading categories…');await expect(button(box,'Preview')).toBeDisabled();
release();
await expect(note(box)).toBeHidden();await expect(button(box,'Preview')).toBeEnabled();
const source=field(box,'Category');
await expect(source.locator('option')).toHaveCount(154);
await expect(source.locator('option').last()).toHaveText('bulk_category_0149');
expect(await box.locator('datalist#vocabulary-category-options option').count()).toBe(154);
await source.selectOption('bulk_category_0149');await field(box,'Proposed new category name').fill('Bulk renamed');
await button(box,'Preview').click();
await expect(box.locator('p.small')).toHaveText('“bulk_category_0149” is used by 1 positive prompt. Apply renames it to “Bulk renamed”.');
await expect(main(page).getByRole('button',{name:'Load more categories',exact:true})).toHaveCount(0);
});
test('manage categories stops at 1,000 categories and says so',async({page})=>{
await page.route('**/prompt_manager/vocabulary/categories?*',route=>{
const cursor=new URL(route.request().url()).searchParams.get('cursor');const index=cursor?Number(cursor.slice(1)):0;
const categories=Array.from({length:100},(_,item)=>({value:`many_${String(index*100+item).padStart(5,'0')}`,prompt_count:1}));
return route.fulfill({json:{success:true,library_epoch:manifest.epoch,total:5000,has_more:true,next_cursor:`c${index+1}`,categories}});
});
await page.goto(route);await expect(categoryRow(page,'many_00000')).toHaveCount(1);
// Every mocked page has more, so scrolling to Review would keep appending under the pointer.
const review=categoryRow(page,'Manage category').getByRole('button',{name:'Review',exact:true});
const box=await openDialog(page,{click:()=>review.evaluate(node=>node.click())},'Manage categories');
await expect(note(box)).toHaveText('Only the first 1,000 categories are listed.');
await expect(field(box,'Category').locator('option')).toHaveCount(1000);
await expect(field(box,'Category').locator('option').last()).toHaveText('many_00999');
await expect(button(box,'Preview')).toBeEnabled();
});
test('a failed category read in Manage categories offers Retry',async({page,request})=>{
manifest=await seed(request,{extra_categories:150});
let failures=1;
await page.route('**/prompt_manager/vocabulary/categories?*',route=>{
if(new URL(route.request().url()).searchParams.has('cursor') && failures-->0) return route.fulfill({status:500,json:{success:false,error:'Database is unavailable'}});
return route.continue();
});
await page.goto(route);await expect(categoryRow(page,'Landscapes')).toHaveCount(1);
const box=await openManage(page);
await expect(note(box)).toHaveText('Categories could not be loaded. Nothing was changed.');
await expect(button(box,'Preview')).toBeDisabled();expect(await actionNames(box)).toEqual(['Retry','Cancel','Preview']);
await button(box,'Retry').click();
await expect(field(box,'Category').locator('option')).toHaveCount(154);
await expect(note(box)).toBeHidden();await expect(button(box,'Preview')).toBeEnabled();
});
// Concept comparison (Task 9): the immutable concept at /__test__/canonical/workspace against production with the
// concept fixture, same browser, both themes, both widths, normal and reduced motion. Only recorded deviations are mapped.
const concept='/__test__/canonical/workspace';
const SIZES=[[1600,1100],[390,844]];
const MOTIONS=['no-preference','reduce'];
async function openConceptTags(page,theme,motion) {
await page.emulateMedia({reducedMotion:motion});
await page.goto('about:blank');await page.goto(`${concept}#tags`);
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
await expect(page.locator('#main h1')).toHaveText('Tags & categories');
}
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 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);
/** 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');
await scope.locator('tbody tr').first().getByRole('button',{name:'Rename / merge',exact:true}).focus();
await page.keyboard.press('Shift+Tab');
const browse=scope.locator('tbody tr').first().getByRole('button',{name:'Browse',exact:true});await expect(browse).toBeFocused();
const ring=await browse.evaluate(node=>{const style=getComputedStyle(node);return {visible:node.matches(':focus-visible'),
outlineStyle:style.outlineStyle,outlineWidth:style.outlineWidth,outlineColor:style.outlineColor,outlineOffset:style.outlineOffset};});
await page.keyboard.press('Tab');
const brandLink=page.locator('a.brand').first();await brandLink.focus();
const linkRing=await brandLink.evaluate(node=>{const style=getComputedStyle(node);return {visible:node.matches(':focus-visible'),
outlineStyle:style.outlineStyle,outlineWidth:style.outlineWidth,outlineOffset:style.outlineOffset};});
await page.keyboard.press('Tab');
await expect(page).toHaveTitle(/^Tags( · Prompt Manager( concept)?)?$/);
return {breadcrumb:(await page.locator(production?'#workspace-breadcrumb':'#breadcrumb').textContent()).trim(),
title:(await page.title()).replace(/ · Prompt Manager( concept)?$/,''),ring,linkRing};
}
/** Concept rows are the concept's own sample tags; production appends the fixture-only tags after them (D4 creation order). */
function comparePage(reference,actual) {
const expected=mapVocabularyConcept(reference);
const shared=expected.table.rows.length;
return {diff:diffInventory(expected,{...actual,table:{...actual.table,rows:actual.table.rows.slice(0,shared)}}),
extra:actual.table.rows.slice(shared).map(row=>row.tag)};
}
const fixtureOnly=()=>[manifest.pair_only_tag,manifest.archived_only_tag,manifest.negative_only_tag,manifest.unused_tag];
for(const [width,height] of SIZES)for(const theme of ['dark','light'])test(`tags page 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) {
await openConceptTags(page,theme,motion);const reference=await pageOf(page,false);
if(motion==='no-preference')await captureCanonicalState(page,testInfo,`concept-tags-${width}-${theme}`,'#main');
const referenceChrome=await chromeOf(page,false);
await openProductionTags(page,theme,motion);const actual=await pageOf(page,true);
if(motion==='no-preference')await captureCanonicalState(page,testInfo,`production-tags-${width}-${theme}`,'#workspace-main');
result[motion]={reference,actual,...comparePage(reference,actual),referenceChrome,actualChrome:await chromeOf(page,true)};
}
const path=testInfo.outputPath('tags-geometry.json');await writeFile(path,JSON.stringify(result,null,2));
await testInfo.attach('tags-geometry',{path,contentType:'application/json'});
for(const [motion,{reference,diff,extra,referenceChrome,actualChrome}] of Object.entries(result)) {
expect.soft(referenceChrome,`${motion} concept chrome`).toMatchObject({breadcrumb:'Tags',title:'Tags',ring:{visible:true},linkRing:{outlineOffset:'3px'}});
expect.soft(actualChrome,`${motion} breadcrumb, title and focus ring`).toEqual(referenceChrome);
expect.soft(reference.heading.actions.buttons.map(item=>item.text),`${motion} concept heading actions`).toEqual(['+ Create tag','Auto-tag images']);
expect.soft(reference.table.rows.map(row=>[row.tag,Number(row.prompts)]),`${motion} concept rows`).toEqual(CONCEPT_COUNTS);
expect.soft(diff,`${motion} differences after the recorded deviations`).toEqual([]);
expect.soft(extra,`${motion} fixture-only rows`).toEqual(fixtureOnly());
}
const {actual}=result['no-preference'];
expect(actual.noPageScroll).toBe(true);
expect(actual.twoCol.columns).toBe(width<=700?1:2);
});
const conceptDialogs={
create:async page=>page.locator('#main').getByRole('button',{name:'+ Create tag',exact:true}).click(),
rename:async page=>page.locator('#main tbody tr').first().getByRole('button',{name:'Rename / merge',exact:true}).click(),
manage:async page=>page.locator('#main').getByRole('button',{name:'Review',exact:true}).click(),
};
const productionDialogs={
create:page=>openDialog(page,main(page).getByRole('button',{name:'+ Create tag',exact:true}),'Create tag'),
rename:page=>openRename(page,'cinematic'),
manage:async page=>{const box=await openManage(page);await expect(button(box,'Preview')).toBeEnabled();return box;},
};
async function conceptDonors(page,theme) {
await page.goto('about:blank');await page.goto(`${concept}#images`);await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
await page.locator('#main').getByRole('button',{name:'Filters',exact:true}).click();await expect(page.locator('#modal')).toBeVisible();
const filters=await page.locator('#modal').evaluate(inventoryOf);
await page.goto('about:blank');await page.goto(`${concept}#prompt/alpine`);await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();await expect(page.locator('#modal')).toBeVisible();
const editor=await page.locator('#modal').evaluate(inventoryOf);
return {filters,editor,categories:CATEGORIES.map(([name])=>name)};
}
for(const [width,height] of SIZES)for(const theme of ['dark','light'])test(`dialog inventories match the concept ${width}x${height} ${theme}`,async({page},testInfo)=>{
await page.setViewportSize({width,height});const result={};
const donors=await conceptDonors(page,theme);
for(const mode of Object.keys(conceptDialogs)) {
await openConceptTags(page,theme,'no-preference');await conceptDialogs[mode](page);
const modal=page.locator('#modal');await expect(modal).toBeVisible();
result[mode]={reference:await modal.evaluate(inventoryOf),referenceValue:await modal.locator('input').inputValue()};
await captureCanonicalState(page,testInfo,`concept-dialog-${mode}-${width}-${theme}`,'#modal');
}
await openProductionTags(page,theme,'no-preference');
for(const mode of Object.keys(productionDialogs)) {
const box=await productionDialogs[mode](page);
result[mode].actual=await box.evaluate(inventoryOf);result[mode].actualValue=await box.locator('input').inputValue();
await captureCanonicalState(page,testInfo,`production-dialog-${mode}-${width}-${theme}`,'dialog.vocabulary-dialog');
await page.keyboard.press('Escape');await expect(box).toHaveCount(0);
}
const path=testInfo.outputPath('vocabulary-dialog-geometry.json');await writeFile(path,JSON.stringify({donors,result},null,2));
await testInfo.attach('vocabulary-dialog-geometry',{path,contentType:'application/json'});
for(const [mode,{reference,actual,referenceValue,actualValue}] of Object.entries(result)) {
expect.soft(reference.actions.buttons.map(item=>item.text),`${mode} concept actions`).toEqual(['Cancel','Apply in prototype']);
expect.soft(diffInventory(mapVocabularyDialog(mode,reference,donors),actual),`${mode} differences after the recorded deviations`).toEqual([]);
expect.soft(actualValue,`${mode} prefilled value`).toBe(referenceValue);
}
});
test('mutation check: the comparison reports a changed surface padding and a changed Browse label',async({page})=>{
await page.setViewportSize({width:1600,height:1100});
await openConceptTags(page,'dark','no-preference');const reference=await pageOf(page,false);
await openProductionTags(page,'dark','no-preference');
expect(comparePage(reference,await pageOf(page,true)).diff).toEqual([]);
await page.addStyleTag({content:'.vocabulary-main .surface{padding:21px!important}'});
await rows(page).first().getByRole('button',{name:'Browse',exact:true}).evaluate(node=>{node.textContent='Open';});
const paths=comparePage(reference,await pageOf(page,true)).diff.map(item=>item.path);
expect(paths).toContain('surfaces.0.paddingTop');
expect(paths).toContain('surfaces.1.paddingLeft');
expect(paths).toContain('table.rows.0.actions.0');
expect(paths).toContain('table.rowActions.buttons.0.text');
// The dialog comparison is not vacuous either: a removed Cancel and a recolored field are reported.
const donors=await conceptDonors(page,'dark');
await openConceptTags(page,'dark','no-preference');await conceptDialogs.create(page);
const dialogReference=mapVocabularyDialog('create',await page.locator('#modal').evaluate(inventoryOf),donors);
await openProductionTags(page,'dark','no-preference');const box=await productionDialogs.create(page);
expect(diffInventory(dialogReference,await box.evaluate(inventoryOf))).toEqual([]);
await page.addStyleTag({content:'dialog.vocabulary-dialog .field input{color:rgb(4,5,6)!important}'});
await button(box,'Cancel').evaluate(node=>node.remove());
const dialogPaths=diffInventory(dialogReference,await box.evaluate(inventoryOf)).map(item=>item.path);
expect(dialogPaths).toContain('actions.buttons.length');
expect(dialogPaths).toContain('fields.0.control.color');
await page.keyboard.press('Escape');
});
+1 -2
View File
@@ -8,7 +8,7 @@ const routes = [
['overview', 'Overview', null],
['images', 'Images', '/prompt_manager/gallery'],
['library', 'Prompt library', '/prompt_manager/admin'],
['tags', 'Tags & categories', null],
['tags', 'Tags & categories', '/prompt_manager/admin#/tags'],
['inspect', 'Inspect a file', '/prompt_manager/gallery.html'],
['system', 'System', '/prompt_manager/admin'],
];
@@ -68,7 +68,6 @@ test('all shell destinations deep-link, reload and expose working existing appli
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');
if (route === 'tags') await expect(main.locator('.handoff')).toHaveCount(0);
if (legacy) {
await expect(main.locator(`a[href="${legacy}"]`)).toBeVisible();
await expect(await request.get(legacy.split('#')[0])).toBeOK();
+1 -3
View File
@@ -89,9 +89,7 @@ def seed_workspace_inspector(
],
"links": [],
}
# Two branches that differ only in seed: facts merge per field (spec section 10).
branch = {**complete["3"], "inputs": {**complete["3"]["inputs"], "seed": 7}}
ambiguous = {**complete, "5": branch}
ambiguous = {**complete, "5": complete["3"]}
examples = (
("complete.png", {"prompt": complete, "workflow": workflow}),
("empty.png", {}),
@@ -1,233 +0,0 @@
"""Owned, deterministic vocabulary data for the native Tags & categories workflows.
The six recipes are the approved concept's (docs/concepts/workspace/app.js:71-165)
in concept order, so tag creation order and category first use match the mock.
"""
from __future__ import annotations
from pathlib import Path
import shutil
from typing import TYPE_CHECKING
from aiohttp import web
if TYPE_CHECKING:
from py.api import PromptManagerAPI
CONCEPT_RECIPES = (
{
"title": "Alpine observatory",
"category": "Landscapes",
"tags": ["cinematic", "blue_hour", "architecture"],
"positive": "A remote alpine observatory at blue hour, warm light through "
"glass, dramatic mountain peaks, cinematic composition, atmospheric mist, "
"fine detail",
"negative": "blurry, low quality, low resolution",
},
{
"title": "Quiet geometry",
"category": "Architecture",
"tags": ["minimal", "warm_light", "editorial"],
"positive": "Monolithic sandstone pavilion in a vast desert, sweeping dunes, "
"warm terracotta, late afternoon sun, architectural photography, quiet "
"geometry",
"negative": "plastic, harsh shadows, noise, watermark",
},
{
"title": "Somewhere in the pines",
"category": "Landscapes",
"tags": ["forest", "mist", "peaceful"],
"positive": "A quiet cedar cabin above a still forest lake, towering "
"evergreens, soft morning mist, muted jade and moss, a small amber window, "
"cinematic still",
"negative": "oversaturated, blurry, low quality, text",
},
{
"title": "After the rain",
"category": "Environments",
"tags": ["neon", "night", "cinematic"],
"positive": "Rain-soaked city street at night, blue neon and rose "
"reflections, deep perspective, cinematic science fiction, solitary "
"architecture, detailed atmosphere",
"negative": "blurry, low resolution, watermark, oversaturated",
},
{
"title": "The long way home",
"category": "Illustration",
"tags": ["space", "minimal", "concept_art"],
"positive": "An isolated lunar research outpost, a luminous planet above the "
"horizon, sweeping pale terrain, quiet science fiction, finely detailed "
"concept art",
"negative": "blurry, low quality, watermark",
},
{
"title": "Between sea and sky",
"category": "Landscapes",
"tags": ["coast", "soft_light", "peaceful"],
"positive": "A solitary lighthouse on a rugged coastal cliff, sea mist, "
"glints of morning light on turquoise water, atmospheric perspective, fine "
"art landscape",
"negative": "oversaturated, flat lighting, watermark, text",
},
)
PAIR_ONLY_TAG = "pair_only_detail"
ARCHIVED_ONLY_TAG = "archived_pair_detail"
NEGATIVE_ONLY_TAG = "legacy_negative_detail"
UNUSED_TAG = "unused_vocabulary"
MAX_EXTRA_TAGS = 300
MAX_EXTRA_CATEGORIES = 200
_TABLES = (
"image_prompt_pairs",
"prompt_pair_tags",
"prompt_pairs",
"generated_images",
"prompt_tags",
"tags",
"prompt_library_state",
"prompts",
)
# Every owner row a vocabulary command could touch, in a stable order, for
# before/after comparisons; prompt text, pairs and images must never change.
_SNAPSHOT = {
"tags": "SELECT id, name FROM tags ORDER BY id",
"prompt_tags": "SELECT prompt_id, tag_id FROM prompt_tags ORDER BY 1, 2",
"prompt_pair_tags": "SELECT pair_id, tag_id FROM prompt_pair_tags ORDER BY 1, 2",
"prompts": "SELECT id, text, category, tags, updated_at FROM prompts ORDER BY id",
"prompt_pairs": "SELECT id, positive_prompt_id, negative_prompt_id, category,"
" archived, updated_at FROM prompt_pairs ORDER BY id",
"generated_images": "SELECT id, prompt_id, image_path FROM generated_images"
" ORDER BY id",
"image_prompt_pairs": "SELECT id, image_id, pair_id FROM image_prompt_pairs"
" ORDER BY id",
}
def seed_vocabulary(
api: PromptManagerAPI, root: Path, extra_tags: int = 0, extra_categories: int = 0
) -> dict:
"""Replace this server's disposable rows with the concept vocabulary."""
db = api.db
with db.model.get_connection() as conn:
for table in _TABLES:
conn.execute(f"DELETE FROM {table}")
for name in ("output", "input"):
shutil.rmtree(root / name)
(root / name).mkdir()
epoch = db.get_execution_epoch()
recipes = []
for recipe in CONCEPT_RECIPES:
receipt = db.create_library_recipe(
recipe["positive"],
recipe["negative"],
expected_epoch=epoch,
parent_metadata={
"title": recipe["title"],
"category": recipe["category"],
"tags": list(recipe["tags"]),
},
)
recipes.append(
{"positive_id": receipt["positive"]["id"], "pair_id": receipt["pair"]["id"]}
)
first = CONCEPT_RECIPES[0]["positive"]
# A tag carried only by an active saved pair counts its Library parent.
db.save_prompt_pair(first, "grain, banding", tags=[PAIR_ONLY_TAG])
# A tag carried only by an archived pair is hidden from Library Browse.
archived = db.save_prompt_pair(
CONCEPT_RECIPES[1]["positive"], "lens flare", tags=[ARCHIVED_ONLY_TAG]
)
db.archive_prompt_pair(archived, True)
# A legacy tag on a negative-only component is never a Library parent.
with db.model.get_connection() as conn:
negative_id = conn.execute(
"SELECT negative_prompt_id FROM prompt_pairs WHERE id=?",
(recipes[2]["pair_id"],),
).fetchone()[0]
db.update_prompt_metadata(negative_id, tags=[NEGATIVE_ONLY_TAG])
db.create_vocabulary_tag(UNUSED_TAG, expected_epoch=epoch)
for number in range(extra_tags):
db.create_vocabulary_tag(f"bulk_tag_{number:04}", expected_epoch=epoch)
# Each extra category is carried by one minimal Library recipe, after the concept's.
for number in range(extra_categories):
db.create_library_recipe(
f"Bulk category recipe {number:04}",
"",
expected_epoch=epoch,
parent_metadata={
"title": f"Bulk {number:04}",
"category": f"bulk_category_{number:04}",
},
)
with db.model.get_connection() as conn:
tags = [row[0] for row in conn.execute("SELECT name FROM tags ORDER BY id")]
api.invalidate_gallery_cache()
return {
"epoch": epoch,
"recipes": recipes,
"archived_pair_id": archived,
"negative_only_id": negative_id,
"tags": tags,
"pair_only_tag": PAIR_ONLY_TAG,
"archived_only_tag": ARCHIVED_ONLY_TAG,
"negative_only_tag": NEGATIVE_ONLY_TAG,
"unused_tag": UNUSED_TAG,
}
def vocabulary_snapshot(api: PromptManagerAPI) -> dict:
"""Plain rows of every vocabulary owner table."""
with api.db.model.get_connection() as conn:
return {
name: [list(row) for row in conn.execute(query)]
for name, query in _SNAPSHOT.items()
}
def register_vocabulary_commands(
routes: web.RouteTableDef, api: PromptManagerAPI, root: Path
) -> None:
"""Register only fixed seed, snapshot and backup commands; no paths or SQL input."""
@routes.post("/__test__/vocabulary/reset")
async def reset(request: web.Request) -> web.Response:
data = await request.json() if request.can_read_body else {}
if not isinstance(data, dict) or set(data) - {"extra_tags", "extra_categories"}:
raise web.HTTPBadRequest()
extra = data.get("extra_tags", 0)
categories = data.get("extra_categories", 0)
for value, limit in (
(extra, MAX_EXTRA_TAGS),
(categories, MAX_EXTRA_CATEGORIES),
):
if isinstance(value, bool) or not isinstance(value, int):
raise web.HTTPBadRequest()
if not 0 <= value <= limit:
raise web.HTTPBadRequest()
if request.query:
raise web.HTTPBadRequest()
manifest = await api._run_in_executor(
seed_vocabulary, api, root, extra, categories
)
return web.json_response({"success": True, **manifest})
@routes.get("/__test__/vocabulary/snapshot")
async def snapshot(request: web.Request) -> web.Response:
if request.query:
raise web.HTTPBadRequest()
rows = await api._run_in_executor(vocabulary_snapshot, api)
return web.json_response(rows)
@routes.get("/__test__/vocabulary/backup")
async def backup(request: web.Request) -> web.Response:
"""A database copy for the real restore route, which rotates the epoch."""
if request.query:
raise web.HTTPBadRequest()
path = root / "owned-vocabulary-backup.db"
created = await api._run_in_executor(api.db.model.backup_database, str(path))
if not created:
raise web.HTTPInternalServerError()
return web.Response(
body=path.read_bytes(), content_type="application/octet-stream"
)
+2 -2
View File
@@ -243,10 +243,10 @@ test.describe('public chronological history with the actual acknowledged host br
const frozen = attempts[0].context;
for (const mode of ['Raw', 'Effective']) {
await run.getByRole('button', { name: mode, exact: true }).click();
for (const [role, value] of [['Positive', frozen.positive[mode.toLowerCase()]], ['Negative', frozen.negative[mode.toLowerCase()]]]) expect(await run.getByRole('region', { name: 'Frozen run prompt texts', exact: true }).getByRole('region', { name: `${role} prompt`, exact: true }).locator('pre').evaluate(node => node.textContent)).toBe(value);
for (const [role, value] of [['Positive', frozen.positive[mode.toLowerCase()]], ['Negative', frozen.negative[mode.toLowerCase()]]]) expect(await run.getByRole('region', { name: `${role} prompt`, exact: true }).locator('pre').evaluate(node => node.textContent)).toBe(value);
await page.evaluate(async id => {const { app } = await import('/scripts/app.js');const node = app.graph.getNodeById(id);node.widgets.find(w => w.name === 'text').value = 'Before send';window.chronologyEffects = { queue: 0, load: 0 };window.chronologyOriginals = {};for (const [method, key] of [['queuePrompt', 'queue'], ['loadGraphData', 'load']]) {const original = app[method];window.chronologyOriginals[method] = original;app[method] = function(...args) {window.chronologyEffects[key]++;return original.apply(this, args);};}}, destination);
try {
await run.getByRole('region', { name: 'Frozen run prompt texts', exact: true }).getByRole('button', { name: 'Send prompt pair', exact: true }).click();const sender = workspace.getByRole('dialog', { name: 'Send to ComfyUI', exact: true });
await run.getByRole('button', { name: 'Send prompt pair', exact: true }).click();const sender = workspace.getByRole('dialog', { name: 'Send to ComfyUI', exact: true });
const hosts = sender.getByRole('combobox', { name: 'ComfyUI window', exact: true });await expect(hosts.locator('option')).toHaveCount(2);await hosts.selectOption({ index: 1 });
await sender.getByRole('combobox', { name: 'Destination node', exact: true }).selectOption({ index: 1 });await sender.getByRole('button', { name: 'Preview changes', exact: true }).click();
await sender.getByRole('button', { name: 'Apply to ComfyUI', exact: true }).click();await expect(sender).toContainText('Sent to ComfyUI.');
+1 -1
View File
@@ -15,7 +15,7 @@ Browser tests use the committed file; FFmpeg is not required to run them.
# Inspection parity fixtures
`inspection_parity/` holds small synthetic PNGs (`f01b.png` ... `f24.png`, each
`inspection_parity/` holds small synthetic PNGs (`f01b.png` ... `f17.png`, each
under 32 KiB) and `vectors.json`, used by the #240 inspection parity tests. Every
graph, prompt text, model name and filename prefix in them is original synthetic
content written for this repository. Nothing comes from user output. They are
File diff suppressed because one or more lines are too long
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 988 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 989 B

+73 -427
View File
@@ -6,9 +6,9 @@
"anchor": null,
"expect": {
"metadata_status": "available",
"state": "ambiguous",
"reason": "Multiple sampler branches",
"reason_code": null,
"state": "unresolved",
"reason": "No supported sampler pair found (saw FaceDetailer, SamplerCustomAdvanced)",
"reason_code": "no-sampler",
"selection": null,
"positive": null,
"negative": null,
@@ -19,24 +19,24 @@
"value": null
},
"steps": {
"status": "known",
"value": "20"
"status": "ambiguous",
"value": null
},
"cfg": {
"status": "unresolved",
"status": "ambiguous",
"value": null
},
"sampler": {
"status": "known",
"value": "euler"
"status": "ambiguous",
"value": null
},
"scheduler": {
"status": "ambiguous",
"value": null
},
"model": {
"status": "known",
"value": "flux-2-klein-9b-fp8.safetensors"
"status": "ambiguous",
"value": null
}
}
}
@@ -48,12 +48,12 @@
"anchor": null,
"expect": {
"metadata_status": "available",
"state": "absent",
"reason": "Guider has no negative input",
"reason_code": null,
"state": "unresolved",
"reason": "No supported sampler pair found (saw FaceDetailer, SamplerCustomAdvanced)",
"reason_code": "no-sampler",
"selection": null,
"positive": "a red fox in snow, detailed fur, Scene 0: unique shot description soft winter light,",
"negative": "<absent>",
"positive": null,
"negative": null,
"candidate_count": 0,
"facts": {
"seed": {
@@ -61,8 +61,8 @@
"value": "1000"
},
"steps": {
"status": "known",
"value": "20"
"status": "unresolved",
"value": null
},
"cfg": {
"status": "not-recorded",
@@ -73,8 +73,8 @@
"value": "euler"
},
"scheduler": {
"status": "known",
"value": "Flux2Scheduler"
"status": "unresolved",
"value": null
},
"model": {
"status": "known",
@@ -258,12 +258,12 @@
"anchor": null,
"expect": {
"metadata_status": "available",
"state": "resolved",
"reason": "",
"reason_code": null,
"state": "unresolved",
"reason": "Unsupported node semantics or output slot",
"reason_code": "unsupported-node",
"selection": null,
"positive": "a red fox in snow, detailed fur",
"negative": "blurry, lowres",
"positive": null,
"negative": null,
"candidate_count": 0,
"facts": {
"seed": {
@@ -300,12 +300,12 @@
"anchor": null,
"expect": {
"metadata_status": "available",
"state": "resolved",
"reason": "",
"reason_code": null,
"state": "unresolved",
"reason": "Unsupported node semantics or output slot",
"reason_code": "unsupported-node",
"selection": null,
"positive": "a red fox in snow, detailed fur",
"negative": "blurry, lowres",
"positive": null,
"negative": null,
"candidate_count": 0,
"facts": {
"seed": {
@@ -342,12 +342,12 @@
"anchor": null,
"expect": {
"metadata_status": "available",
"state": "resolved",
"reason": "",
"reason_code": null,
"state": "unresolved",
"reason": "Unsupported node semantics or output slot",
"reason_code": "unsupported-node",
"selection": null,
"positive": "a red fox in snow, detailed fur",
"negative": "blurry, lowres",
"positive": null,
"negative": null,
"candidate_count": 0,
"facts": {
"seed": {
@@ -385,8 +385,8 @@
"expect": {
"metadata_status": "available",
"state": "unresolved",
"reason": "Combined conditioning has different texts",
"reason_code": "composite",
"reason": "Unsupported node semantics or output slot",
"reason_code": "unsupported-node",
"selection": null,
"positive": null,
"negative": null,
@@ -426,12 +426,12 @@
"anchor": null,
"expect": {
"metadata_status": "available",
"state": "resolved",
"reason": "",
"reason_code": null,
"state": "unresolved",
"reason": "Unsupported node semantics or output slot",
"reason_code": "unsupported-node",
"selection": null,
"positive": "a red fox in snow, detailed fur",
"negative": "blurry, lowres",
"positive": null,
"negative": null,
"candidate_count": 0,
"facts": {
"seed": {
@@ -510,12 +510,12 @@
"anchor": null,
"expect": {
"metadata_status": "available",
"state": "resolved",
"reason": "",
"reason_code": null,
"state": "unresolved",
"reason": "No supported sampler pair found (saw SamplerCustomAdvanced, CFGGuider)",
"reason_code": "no-sampler",
"selection": null,
"positive": "a red fox in snow, detailed fur",
"negative": "blurry, lowres",
"positive": null,
"negative": null,
"candidate_count": 0,
"facts": {
"seed": {
@@ -603,28 +603,28 @@
"candidate_count": 0,
"facts": {
"seed": {
"status": "known",
"value": "42"
"status": "ambiguous",
"value": null
},
"steps": {
"status": "known",
"value": "25"
"status": "ambiguous",
"value": null
},
"cfg": {
"status": "known",
"value": "6.5"
"status": "ambiguous",
"value": null
},
"sampler": {
"status": "known",
"value": "euler"
"status": "ambiguous",
"value": null
},
"scheduler": {
"status": "known",
"value": "normal"
"status": "ambiguous",
"value": null
},
"model": {
"status": "known",
"value": "sdxl_base.safetensors"
"status": "ambiguous",
"value": null
}
}
}
@@ -636,12 +636,12 @@
"anchor": null,
"expect": {
"metadata_status": "available",
"state": "resolved",
"reason": "",
"reason_code": null,
"state": "unresolved",
"reason": "Unsupported latent ancestry",
"reason_code": "unsupported-node",
"selection": null,
"positive": "a red fox in snow, detailed fur",
"negative": "blurry, lowres",
"positive": null,
"negative": null,
"candidate_count": 0,
"facts": {
"seed": {
@@ -678,12 +678,12 @@
"anchor": null,
"expect": {
"metadata_status": "available",
"state": "resolved",
"reason": "",
"reason_code": null,
"state": "unresolved",
"reason": "Unsupported latent ancestry",
"reason_code": "unsupported-node",
"selection": null,
"positive": "a red fox in snow, detailed fur",
"negative": "blurry, lowres",
"positive": null,
"negative": null,
"candidate_count": 0,
"facts": {
"seed": {
@@ -888,12 +888,12 @@
"anchor": null,
"expect": {
"metadata_status": "available",
"state": "absent",
"reason": "Guider has no negative input",
"reason_code": null,
"state": "unresolved",
"reason": "No supported sampler pair found (saw SamplerCustomAdvanced)",
"reason_code": "no-sampler",
"selection": null,
"positive": "a red fox in snow, detailed fur",
"negative": "<absent>",
"positive": null,
"negative": null,
"candidate_count": 0,
"facts": {
"seed": {
@@ -923,258 +923,6 @@
}
}
},
{
"name": "f19-inspection",
"fixture": "f19",
"profile": "inspection",
"anchor": null,
"expect": {
"metadata_status": "available",
"state": "ambiguous",
"reason": "Sampler stages have different prompts",
"reason_code": "multistage-differs",
"selection": null,
"positive": null,
"negative": null,
"candidate_count": 0,
"facts": {
"seed": {
"status": "ambiguous",
"value": null
},
"steps": {
"status": "ambiguous",
"value": null
},
"cfg": {
"status": "ambiguous",
"value": null
},
"sampler": {
"status": "known",
"value": "euler"
},
"scheduler": {
"status": "known",
"value": "normal"
},
"model": {
"status": "known",
"value": "sdxl_base.safetensors"
}
}
}
},
{
"name": "f20-inspection",
"fixture": "f20",
"profile": "inspection",
"anchor": null,
"expect": {
"metadata_status": "available",
"state": "resolved",
"reason": "",
"reason_code": null,
"selection": null,
"positive": "a red fox in snow, detailed fur",
"negative": "blurry, lowres",
"candidate_count": 0,
"facts": {
"seed": {
"status": "known",
"value": "42"
},
"steps": {
"status": "known",
"value": "25"
},
"cfg": {
"status": "known",
"value": "6.5"
},
"sampler": {
"status": "known",
"value": "euler"
},
"scheduler": {
"status": "known",
"value": "normal"
},
"model": {
"status": "known",
"value": "sdxl_base.safetensors"
}
}
}
},
{
"name": "f21-inspection",
"fixture": "f21",
"profile": "inspection",
"anchor": null,
"expect": {
"metadata_status": "available",
"state": "unresolved",
"reason": "Text is decided at run time",
"reason_code": "runtime-value",
"selection": null,
"positive": null,
"negative": null,
"candidate_count": 0,
"facts": {
"seed": {
"status": "known",
"value": "42"
},
"steps": {
"status": "known",
"value": "25"
},
"cfg": {
"status": "known",
"value": "6.5"
},
"sampler": {
"status": "known",
"value": "euler"
},
"scheduler": {
"status": "known",
"value": "normal"
},
"model": {
"status": "known",
"value": "sdxl_base.safetensors"
}
}
}
},
{
"name": "f22-inspection",
"fixture": "f22",
"profile": "inspection",
"anchor": null,
"expect": {
"metadata_status": "available",
"state": "unresolved",
"reason": "Text is decided at run time",
"reason_code": "runtime-value",
"selection": null,
"positive": null,
"negative": null,
"candidate_count": 0,
"facts": {
"seed": {
"status": "known",
"value": "42"
},
"steps": {
"status": "known",
"value": "25"
},
"cfg": {
"status": "known",
"value": "6.5"
},
"sampler": {
"status": "known",
"value": "euler"
},
"scheduler": {
"status": "known",
"value": "normal"
},
"model": {
"status": "known",
"value": "sdxl_base.safetensors"
}
}
}
},
{
"name": "f23-inspection",
"fixture": "f23",
"profile": "inspection",
"anchor": null,
"expect": {
"metadata_status": "available",
"state": "resolved",
"reason": "",
"reason_code": null,
"selection": null,
"positive": "a red fox in snow, detailed fur",
"negative": "",
"candidate_count": 0,
"facts": {
"seed": {
"status": "known",
"value": "42"
},
"steps": {
"status": "known",
"value": "25"
},
"cfg": {
"status": "known",
"value": "6.5"
},
"sampler": {
"status": "known",
"value": "euler"
},
"scheduler": {
"status": "known",
"value": "normal"
},
"model": {
"status": "known",
"value": "sdxl_base.safetensors"
}
}
}
},
{
"name": "f24-inspection",
"fixture": "f24",
"profile": "inspection",
"anchor": null,
"expect": {
"metadata_status": "available",
"state": "resolved",
"reason": "",
"reason_code": null,
"selection": null,
"positive": "a red fox in snow, detailed fur",
"negative": "blurry, lowres",
"candidate_count": 0,
"facts": {
"seed": {
"status": "known",
"value": "42"
},
"steps": {
"status": "known",
"value": "25"
},
"cfg": {
"status": "known",
"value": "6.5"
},
"sampler": {
"status": "known",
"value": "euler"
},
"scheduler": {
"status": "known",
"value": "normal"
},
"model": {
"status": "known",
"value": "sdxl_base.safetensors"
}
}
}
},
{
"name": "f01-default",
"fixture": "f01",
@@ -1548,107 +1296,5 @@
"candidate_count": 0,
"facts": null
}
},
{
"name": "f19-default",
"fixture": "f19",
"profile": "default",
"anchor": null,
"expect": {
"metadata_status": "available",
"state": "resolved",
"reason": "",
"reason_code": null,
"selection": null,
"positive": "a red fox in snow, detailed fur",
"negative": "blurry, lowres",
"candidate_count": 0,
"facts": null
}
},
{
"name": "f20-default",
"fixture": "f20",
"profile": "default",
"anchor": null,
"expect": {
"metadata_status": "available",
"state": "unresolved",
"reason": "Unsupported node semantics or output slot",
"reason_code": null,
"selection": null,
"positive": null,
"negative": null,
"candidate_count": 0,
"facts": null
}
},
{
"name": "f21-default",
"fixture": "f21",
"profile": "default",
"anchor": null,
"expect": {
"metadata_status": "available",
"state": "unresolved",
"reason": "Unsupported node semantics or output slot",
"reason_code": null,
"selection": null,
"positive": null,
"negative": null,
"candidate_count": 0,
"facts": null
}
},
{
"name": "f22-default",
"fixture": "f22",
"profile": "default",
"anchor": null,
"expect": {
"metadata_status": "available",
"state": "unresolved",
"reason": "Unsupported node semantics or output slot",
"reason_code": null,
"selection": null,
"positive": null,
"negative": null,
"candidate_count": 0,
"facts": null
}
},
{
"name": "f23-default",
"fixture": "f23",
"profile": "default",
"anchor": null,
"expect": {
"metadata_status": "available",
"state": "unresolved",
"reason": "Unsupported node semantics or output slot",
"reason_code": null,
"selection": null,
"positive": null,
"negative": null,
"candidate_count": 0,
"facts": null
}
},
{
"name": "f24-default",
"fixture": "f24",
"profile": "default",
"anchor": null,
"expect": {
"metadata_status": "available",
"state": "unresolved",
"reason": "Unsupported node semantics or output slot",
"reason_code": null,
"selection": null,
"positive": null,
"negative": null,
"candidate_count": 0,
"facts": null
}
}
]
-444
View File
@@ -1,444 +0,0 @@
"""Named workflow cases shared by the generation-parameter parser tests.
Each case is the raw metadata records a file would carry, as text, so the cases
exercise ``parse_js_json`` as well as the tracing. The JS parity test runs every
case through the legacy browser parser too. Prompt texts and seeds are invented.
"""
import json
def _text(value):
return None if value is None else json.dumps(value)
def case(prompt=None, workflow=None):
return {"prompt": _text(prompt), "workflow": _text(workflow)}
def encoder(text, clip=("4", 1)):
return {
"class_type": "CLIPTextEncode",
"inputs": {"text": text, "clip": list(clip)},
}
def ksampler(positive, negative, model=("4", 0), **settings):
inputs = {
"seed": 5,
"steps": 20,
"cfg": 7,
"sampler_name": "euler",
"scheduler": "normal",
"denoise": 1.0,
"model": list(model),
"positive": list(positive),
"negative": list(negative),
"latent_image": ["2", 0],
}
inputs.update(settings)
return {"class_type": "KSampler", "inputs": inputs}
CHECKPOINT = {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": "example.safetensors"},
}
LATENT = {
"class_type": "EmptyLatentImage",
"inputs": {"width": 512, "height": 512, "batch_size": 1},
}
def _basic():
return {
"4": CHECKPOINT,
"2": LATENT,
"6": encoder("a cat on a windowsill"),
"7": encoder("blurry, watermark"),
"3": ksampler(("6", 0), ("7", 0)),
"9": {"class_type": "SaveImage", "inputs": {"images": ["8", 0]}},
}
def _prompt_manager(text, prepend="", append=""):
return {
"class_type": "PromptManager",
"inputs": {
"text": text,
"category": "",
"tags": "",
"search_text": "",
"prepend_text": prepend,
"append_text": append,
"clip": ["4", 1],
},
}
def _shared_prompt_pipeline():
"""One PromptManager pair feeds a base pass, an upscale pass and a detailer."""
return {
"1": CHECKPOINT,
"5": ksampler(
("25", 0),
("26", 0),
("1", 0),
seed=111,
steps=4,
cfg=1.0,
sampler_name="lcm",
latent_image=["20", 0],
),
"6": {
"class_type": "VAEDecode",
"inputs": {"samples": ["5", 0], "vae": ["1", 2]},
},
"11": {
"class_type": "VAEEncode",
"inputs": {"pixels": ["6", 0], "vae": ["1", 2]},
},
"12": ksampler(
("25", 0),
("26", 0),
("1", 0),
seed=222,
steps=6,
cfg=1.0,
sampler_name="lcm",
denoise=0.45,
latent_image=["11", 0],
),
"13": {
"class_type": "VAEDecode",
"inputs": {"samples": ["12", 0], "vae": ["1", 2]},
},
"17": {
"class_type": "FaceDetailer",
"inputs": {
"seed": 333,
"steps": 4,
"cfg": 1.0,
"sampler_name": "lcm",
"image": ["13", 0],
"model": ["1", 0],
"clip": ["1", 1],
"vae": ["1", 2],
"positive": ["25", 0],
"negative": ["26", 0],
},
},
"20": {
"class_type": "EmptyLatentImage",
"inputs": {"width": 640, "height": 1536, "batch_size": 1},
},
"25": _prompt_manager("a lighthouse at dusk, oil painting"),
"26": _prompt_manager("blurry, watermark"),
"48": {
"class_type": "KikoSaveImage",
"inputs": {"filename_prefix": ["49", 0], "images": ["17", 0]},
},
"49": {
"class_type": "KikoProjectFilePath",
"inputs": {"project_name": "example"},
},
}
def _custom_sampler(guider):
return {
"13": CHECKPOINT,
"20": encoder("a red fox in snow", clip=("13", 1)),
"21": encoder("lowres", clip=("13", 1)),
"22": guider,
"25": {"class_type": "RandomNoise", "inputs": {"noise_seed": 42}},
"16": {"class_type": "KSamplerSelect", "inputs": {"sampler_name": "dpmpp_2m"}},
"17": {
"class_type": "BasicScheduler",
"inputs": {
"scheduler": "karras",
"steps": 8,
"denoise": 1.0,
"model": ["13", 0],
},
},
"1300:8": {
"class_type": "SamplerCustomAdvanced",
"inputs": {
"noise": ["25", 0],
"guider": ["22", 0],
"sampler": ["16", 0],
"sigmas": ["17", 0],
"latent_image": ["5", 0],
},
},
}
def _workflow_only():
return {
"nodes": [
{
"id": 4,
"type": "CheckpointLoaderSimple",
"mode": 0,
"inputs": [],
"widgets_values": ["example.safetensors"],
},
{
"id": 6,
"type": "CLIPTextEncode",
"mode": 0,
"inputs": [{"name": "clip", "link": 3}],
"widgets_values": ["a tidepool at noon"],
},
{
"id": 7,
"type": "CLIPTextEncode",
"mode": 0,
"inputs": [{"name": "clip", "link": 5}],
"widgets_values": ["noisy"],
},
{
"id": 10,
"type": "Reroute",
"mode": 0,
"inputs": [{"name": "", "link": 7}],
},
{
"id": 3,
"type": "KSampler",
"mode": 0,
"inputs": [
{"name": "model", "link": 1},
{"name": "positive", "link": 8},
{"name": "negative", "link": 4},
{"name": "latent_image", "link": 2},
],
"widgets_values": [314, "randomize", 25, 6.5, "dpmpp_2m", "karras", 1],
},
{
"id": 11,
"type": "CLIPTextEncode",
"mode": 2,
"inputs": [],
"widgets_values": ["muted node text"],
},
],
"links": [
[1, 4, 0, 3, 0, "MODEL"],
[3, 4, 1, 6, 0, "CLIP"],
[5, 4, 1, 7, 0, "CLIP"],
[7, 6, 0, 10, 0, "CONDITIONING"],
[8, 10, 0, 3, 1, "CONDITIONING"],
[4, 7, 0, 3, 2, "CONDITIONING"],
{
"id": 2,
"origin_id": 99,
"origin_slot": 0,
"target_id": 3,
"target_slot": 3,
"type": "LATENT",
},
],
}
def _chain(length):
graph = {"4": CHECKPOINT, "1": encoder("deep chain text")}
previous = "1"
for index in range(length):
node_id = str(100 + index)
graph[node_id] = {
"class_type": "ConditioningPass",
"inputs": {"conditioning": [previous, 0]},
}
previous = node_id
graph["3"] = ksampler((previous, 0), ("1", 0))
return graph
CASES = {
"basic": case(_basic()),
"prompt-manager-prepend-append": case(
{
"4": CHECKPOINT,
"6": _prompt_manager("a cat", prepend="masterpiece", append="4k"),
"7": _prompt_manager(
"masterpiece, a dog", prepend="masterpiece", append="4k"
),
"3": ksampler(("6", 0), ("7", 0)),
}
),
"linked-text-concat": case(
{
"4": CHECKPOINT,
"10": {"class_type": "PrimitiveString", "inputs": {"value": "first part"}},
"11": {
"class_type": "StringConcatenate",
"inputs": {
"string_a": ["10", 0],
"string_b": "second part",
"delimiter": ", ",
"seed": 3,
"string_c": " ",
},
},
"6": {
"class_type": "CLIPTextEncode",
"inputs": {"text": ["11", 0], "clip": ["4", 1]},
},
"7": encoder(""),
"3": ksampler(("6", 0), ("7", 0)),
}
),
"prompt-manager-pair": case(
{
"4": CHECKPOINT,
"8": {
"class_type": "PromptManagerPair",
"inputs": {
"text": "pair positive",
"negative_text": "pair negative",
"prepend_text": "",
"append_text": "",
"clip": ["4", 1],
},
},
"3": ksampler(("8", 0), ("8", 1)),
}
),
"conditioning-passthrough": case(
{
"4": CHECKPOINT,
"6": encoder("through a controlnet"),
"7": encoder("negative through it"),
"12": {
"class_type": "ControlNetApplyAdvanced",
"inputs": {
"positive": ["6", 0],
"negative": ["7", 0],
"control_net": ["13", 0],
},
},
"3": ksampler(("12", 0), ("12", 1)),
}
),
"first-sampler-in-js-key-order": case(
{
"4": CHECKPOINT,
"6": encoder("from sampler twelve"),
"7": encoder("from sampler five"),
"x": ksampler(("6", 0), ("6", 0)),
"12": ksampler(("6", 0), ("6", 0), seed=12),
"5": ksampler(("7", 0), ("7", 0), seed=55),
}
),
"custom-sampler-cfg-guider": case(
_custom_sampler(
{
"class_type": "CFGGuider",
"inputs": {
"model": ["13", 0],
"positive": ["20", 0],
"negative": ["21", 0],
"cfg": 3.5,
},
}
)
),
"custom-sampler-basic-guider": case(
_custom_sampler(
{
"class_type": "BasicGuider",
"inputs": {"model": ["13", 0], "conditioning": ["20", 0]},
}
)
),
"no-sampler-loose-settings": case(
{
"4": CHECKPOINT,
"3": {
"class_type": "MyKSamplerVariant",
"inputs": {
"noise_seed": 9,
"steps": 12,
"cfg": 2,
"sampler_name": "ddim",
},
},
}
),
"primitive-seed": case(
{
"4": CHECKPOINT,
"6": encoder("seeded"),
"30": {
"class_type": "Seed (rgthree)",
"inputs": {"other": "x", "seed": 77},
},
"3": ksampler(("6", 0), ("6", 0), seed=["30", 0]),
}
),
"workflow-only": case(workflow=_workflow_only()),
"overwritten-prompt-manager-text": case(
{
"4": CHECKPOINT,
"25": _prompt_manager("same saved text"),
"26": _prompt_manager("same saved text"),
"3": ksampler(("25", 0), ("26", 0)),
},
{
"nodes": [
{
"id": 25,
"type": "PromptManager",
"widgets_values": ["the real positive"],
},
{
"id": 26,
"type": "PromptManager",
"widgets_values": ["the real negative"],
},
],
"links": [],
},
),
"conditioning-cycle": case(
{
"4": CHECKPOINT,
"20": {
"class_type": "ConditioningCombine",
"inputs": {"conditioning_1": ["21", 0]},
},
"21": {
"class_type": "ConditioningCombine",
"inputs": {"conditioning_1": ["20", 0]},
},
"3": ksampler(("20", 0), ("21", 0)),
}
),
"chain-within-visit-limit": case(_chain(450)),
"chain-beyond-visit-limit": case(_chain(600)),
"chain-at-last-visit": case(_chain(499)),
"chain-one-past-last-visit": case(_chain(500)),
"blank-text-and-zero-seed": case(
{
"4": CHECKPOINT,
"6": encoder(" "),
"7": encoder("kept negative"),
"3": ksampler(("6", 0), ("7", 0), seed=0),
}
),
"shared-prompt-pipeline": case(_shared_prompt_pipeline()),
"null-workflow-node": case(_basic(), {"nodes": [None], "links": []}),
"unparsable-records": {"prompt": '{"3": NaN}', "workflow": "not json"},
"seed-beyond-double-precision": case(
{
"4": CHECKPOINT,
"6": encoder("x"),
"3": ksampler(("6", 0), ("6", 0), cfg=1.0, seed=4974052037430000001),
}
),
"nothing": case(),
}
+1 -116
View File
@@ -2,7 +2,7 @@
Every graph here is original synthetic content (CC0): neutral texts, generic model
file names and invented filename prefixes. Nothing is copied from user outputs.
The shapes reproduce the parity audit's fixture numbering (``f01``..``f24``).
The shapes reproduce the parity audit's fixture numbering (``f01``..``f17``).
``build(name)`` returns ``{"prompt", "workflow", "extra_chunks"}``. ``write_png``
writes the two records the way core ``SaveImage`` does (Pillow ``PngInfo.add_text``,
@@ -66,12 +66,6 @@ COMMITTED = (
"f15",
"f16",
"f17",
"f19",
"f20",
"f21",
"f22",
"f23",
"f24",
)
# Built in a temporary directory at test time (too large to commit).
TEST_TIME = ("f01", "f04", "f05")
@@ -106,10 +100,6 @@ def _enc(text):
return {"class_type": "CLIPTextEncode", "inputs": {"text": text, "clip": ["4", 1]}}
def _linked_enc(link):
return {"class_type": "CLIPTextEncode", "inputs": {"text": link, "clip": ["4", 1]}}
def _ksampler_graph(positive, negative, sampler="KSampler", latent=None, extra=None):
graph = {
"4": {
@@ -701,105 +691,6 @@ def _f17():
return _basic(graph, _ui(graph))
DETAILER_POS = "a red fox in snow, sharp detailed face"
def _f19():
"""KSampler -> VAEDecode -> FaceDetailer -> SaveImage; the detailer re-prompts."""
graph = _ksampler_graph(_enc(POS), _enc(NEG))
graph["10"] = _enc(DETAILER_POS)
graph["50"] = {
"class_type": "FaceDetailer",
"inputs": {
"image": ["8", 0],
"model": ["4", 0],
"clip": ["4", 1],
"vae": ["4", 2],
"positive": ["10", 0],
"negative": ["7", 0],
"seed": 7,
"steps": 12,
"cfg": 4.5,
"sampler_name": "euler",
"scheduler": "normal",
"denoise": 0.4,
},
}
graph["9"]["inputs"]["images"] = ["50", 0]
return _basic(graph)
def _f20():
"""``easy ifElse`` with a literal boolean picks the positive branch."""
select = {
"class_type": "easy ifElse",
"inputs": {"boolean": True, "on_true": ["10", 0], "on_false": ["11", 0]},
}
extra = {"10": _enc(POS), "11": _enc("a grey wolf in fog")}
return _basic(_ksampler_graph(select, _enc(NEG), extra=extra))
def _f21():
"""``JoinStringMulti`` with ``return_list: true`` feeds the positive encoder."""
join = {
"class_type": "JoinStringMulti",
"inputs": {
"inputcount": 2,
"delimiter": ", ",
"return_list": True,
"string_1": ["11", 0],
"string_2": ["12", 0],
},
}
extra = {
"10": join,
"11": {"class_type": "PrimitiveString", "inputs": {"value": "a red fox"}},
"12": {"class_type": "PrimitiveString", "inputs": {"value": "in snow"}},
}
positive = _linked_enc(["10", 0])
return _basic(_ksampler_graph(positive, _enc(NEG), extra=extra))
def _f22():
"""``PromptSearchList`` (database search at run time) feeds the positive."""
search = {
"class_type": "PromptSearchList",
"inputs": {
"text": "fox",
"category": "",
"tags": "",
"min_rating": 0,
"limit": 50,
},
}
positive = _linked_enc(["10", 0])
return _basic(_ksampler_graph(positive, _enc(NEG), extra={"10": search}))
def _f24():
"""``TextEncodeQwenImage21``: slot 0 is the positive, slot 1 the negative."""
qwen = {
"class_type": "TextEncodeQwenImage21",
"inputs": {
"clip": ["4", 1],
"prompt": POS,
"negative_prompt": NEG,
"vae": ["4", 2],
"resolution": 1024,
},
}
graph = _ksampler_graph(qwen, None)
del graph["7"]
graph["3"]["inputs"]["negative"] = ["6", 1]
return _basic(graph)
def _f23():
"""The negative is ``ConditioningZeroOut`` of the positive encoder."""
zero = {"class_type": "ConditioningZeroOut", "inputs": {"conditioning": ["6", 0]}}
return _basic(_ksampler_graph(_enc(POS), zero))
_BUILDERS = {
"f01": _f01,
"f01b": _f01b,
@@ -823,12 +714,6 @@ _BUILDERS = {
"f15": _f15,
"f16": _f16,
"f17": _f17,
"f19": _f19,
"f20": _f20,
"f21": _f21,
"f22": _f22,
"f23": _f23,
"f24": _f24,
}
-226
View File
@@ -164,232 +164,6 @@ test("facts require model output ancestry with supported loaders and passthrough
assert.equal(factsFor(numericModel).model.status, "unresolved", "model names require string evidence");
});
// D19: a FaceDetailer re-samples the KSampler's decoded image with its own settings.
function detailerFactGraph(detailer = {}) {
const graph = factGraph({ seed: "42", steps: 25, cfg: "6.5", sampler_name: "euler", scheduler: "normal" });
graph.decode = { class_type: "VAEDecode", inputs: { samples: ["s", 0], vae: ["model", 2] } };
graph.d = { class_type: "FaceDetailer", inputs: { image: ["decode", 0], model: ["model", 0], seed: "7", steps: 12, cfg: "6.5", sampler_name: "euler", scheduler: "normal", denoise: "0.4", ...detailer } };
return graph;
}
const statusValues = facts => Object.fromEntries(factFields.map(field => [field, [facts[field].status, facts[field].value]]));
test("facts use resolution.sampler_id for detailer-stage nodes", () => {
// Resolved (the stages' prompts agree): the final stage the server named supplies every fact.
const resolved = factsFor(detailerFactGraph(), { resolution: { state: "resolved", sampler_id: "d" } });
assert.deepEqual(statusValues(resolved), {
seed: ["known", "7"], steps: ["known", "12"], cfg: ["known", "6.5"],
sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"],
});
assert.ok(factFields.every(field => resolved[field].sampler_id === "d" && resolved[field].evidence === "configuration"));
// The upstream KSampler is also accepted when it is the one resolved.
assert.equal(factsFor(detailerFactGraph(), { resolution: { state: "resolved", sampler_id: "s" } }).seed.value, "42");
// Ambiguous stages: merged per field, never the detailer's or the upstream value alone.
const ambiguous = factsFor(detailerFactGraph(), { resolution: { state: "ambiguous", sampler_id: "d", reason_code: "multistage-differs" } });
assert.deepEqual(statusValues(ambiguous), {
seed: ["ambiguous", null], steps: ["ambiguous", null], cfg: ["known", "6.5"],
sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"],
});
assert.ok(factFields.every(field => ambiguous[field].sampler_id === null), "a merged fact names no single sampler");
// A field one stage cannot read and the others agree on stays unresolved; not-recorded is not agreement.
const unreadable = factsFor(detailerFactGraph({ cfg: ["missing", 0] }), { resolution: { state: "ambiguous", sampler_id: "d" } });
assert.deepEqual([unreadable.cfg.status, unreadable.cfg.value], ["unresolved", null]);
const unrecorded = detailerFactGraph(); delete unrecorded.d.inputs.scheduler;
assert.equal(factsFor(unrecorded, { resolution: { state: "ambiguous", sampler_id: "d" } }).scheduler.status, "unresolved");
// Two detailer stages over one KSampler: three stages merge.
const chained = detailerFactGraph({ seed: "42" });
chained.d2 = { class_type: "FaceDetailer", inputs: { image: ["d", 0], model: ["model", 0], seed: "42", steps: 25, cfg: "3", sampler_name: "euler", scheduler: "normal" } };
assert.deepEqual(statusValues(factsFor(chained, { resolution: { state: "ambiguous", sampler_id: "d2" } })), {
seed: ["known", "42"], steps: ["ambiguous", null], cfg: ["ambiguous", null],
sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"],
});
// Without a resolution, two samplers stay ambiguous (today's rules, now over the samplers table).
assert.ok(Object.values(factsFor(detailerFactGraph())).every(fact => fact.status === "ambiguous"));
// A sampler_id the graph or the samplers table does not hold never falls back to a guess.
for (const sampler_id of ["missing", "decode", "model", "__proto__"]) {
for (const state of ["resolved", "ambiguous"]) {
const facts = factsFor(detailerFactGraph(), { resolution: { state, sampler_id } });
assert.ok(Object.values(facts).every(fact => fact.status === "unresolved" && fact.value === null), `${state} ${sampler_id}`);
}
}
// Stage ancestry that cannot be walked is unresolved, not a partial merge.
const cyclic = detailerFactGraph(); cyclic.decode.inputs.samples = ["d", 0];
const unlinked = detailerFactGraph({ image: "not a link" });
for (const graph of [cyclic, unlinked]) {
assert.ok(Object.values(factsFor(graph, { resolution: { state: "ambiguous", sampler_id: "d" } })).every(fact => fact.status === "unresolved"));
}
const wide = detailerFactGraph();
let previous = ["s", 0];
for (let index = 0; index < 600; index++) { wide[`hop${index}`] = { class_type: "ImageScale", inputs: { image: previous } }; previous = [`hop${index}`, 0]; }
wide.decode.inputs.samples = ["s", 0]; wide.d.inputs.image = previous;
assert.ok(Object.values(factsFor(wide, { resolution: { state: "ambiguous", sampler_id: "d" } })).every(fact => fact.status === "unresolved"), "the stage walk is bounded by MAX_VISITS");
});
test("ambiguous results without a sampler_id merge facts across every listed stage", () => {
const ambiguous = { resolution: { state: "ambiguous", sampler_id: null, reason: "Multiple sampler branches" } };
// f01b's shape: an upstream sampler and a detailer whose settings partly differ.
assert.deepEqual(statusValues(factsFor(detailerFactGraph(), ambiguous)), {
seed: ["ambiguous", null], steps: ["ambiguous", null], cfg: ["known", "6.5"],
sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"],
});
assert.ok(Object.values(factsFor(detailerFactGraph(), ambiguous)).every(fact => fact.sampler_id === null));
// Equal independent branches agree on every field once the server says ambiguous;
// without a resolution they stay ambiguous (today's rules, reversed in PR3).
const equal = factGraph(); equal.other = structuredClone(equal.s);
assert.ok(Object.values(factsFor(equal, ambiguous)).every(fact => fact.status === "known"));
assert.ok(Object.values(factsFor(equal)).every(fact => fact.status === "ambiguous"));
// A walk that fails keeps every field unresolved; so does a graph with no listed sampler.
const cyclic = detailerFactGraph(); cyclic.decode.inputs.samples = ["d", 0];
const broken = detailerFactGraph(); broken.d.inputs.image = ["gone", 0];
for (const graph of [cyclic, broken, { model: factGraph().model }]) {
assert.ok(Object.values(factsFor(graph, ambiguous)).every(fact => fact.status === "unresolved" && fact.value === null));
}
const many = factGraph();
for (let index = 0; index < 40; index++) many[`s${index}`] = structuredClone(many.s);
assert.ok(Object.values(factsFor(many, ambiguous)).every(fact => fact.status === "unresolved"), "more stages than the candidate cap");
});
test("stage walks fail on unlisted sampler-shaped nodes like the resolver", () => {
const shaped = [
{ positive: ["model", 0] }, { latent_image: ["model", 0] }, { guider: ["model", 0] }, { negative: ["model", 0] },
{ pipe: ["model", 0] }, { basic_pipe: ["model", 0] }, { detailer_pipe: ["model", 0] }, { sampler: ["model", 0] },
{ sigmas: ["model", 0] }, { noise: ["model", 0] }, { text_embeds: ["model", 0] }, { hyvid_embeds: ["model", 0] },
{ model: ["model", 0], conditioning: ["model", 0] }, { model: ["model", 0], neg_conditioning: ["model", 0] },
{ model: ["model", 0], samples: ["model", 0] }, { model: ["model", 0], latent: ["model", 0] },
];
for (const inputs of shaped) {
const graph = detailerFactGraph();
graph.efficient = { class_type: "MysterySampler", inputs: { ...inputs, image: ["decode", 0] } };
graph.d.inputs.image = ["efficient", 0];
for (const sampler_id of ["d", null]) {
const facts = factsFor(graph, { resolution: { state: "ambiguous", sampler_id } });
assert.ok(Object.values(facts).every(fact => fact.status === "unresolved"), `${JSON.stringify(inputs)} ${sampler_id}`);
}
}
// Model patches (model alone, or with clip) and listed table classes are walked through.
for (const [class_type, inputs] of [["ModelPatch", { model: ["model", 0] }], ["LoraPatch", { model: ["model", 0], clip: ["model", 1] }], ["ImageScale", { samples: ["model", 0] }], ["CFGGuider", { model: ["model", 0], positive: ["model", 0] }]]) {
const graph = detailerFactGraph();
graph.hop = { class_type, inputs: { ...inputs, image: ["decode", 0] } };
graph.d.inputs.image = ["hop", 0];
assert.equal(factsFor(graph, { resolution: { state: "ambiguous", sampler_id: "d" } }).seed.status, "ambiguous", class_type);
}
});
test("unresolved and absent results over several stages merge facts per field", () => {
// Review I-1: the run's settings are facts even when its prompt is not, e.g. an
// upstream prompt behind an unsupported node (LoRAfromText) leaves the server unresolved.
const merged = {
seed: ["ambiguous", null], steps: ["ambiguous", null], cfg: ["known", "6.5"],
sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"],
};
for (const resolution of [
{ state: "unresolved", sampler_id: "d", reason_code: "unsupported-node" },
{ state: "unresolved", sampler_id: null, reason_code: "malformed" },
{ state: "absent", sampler_id: "d" },
]) {
const facts = factsFor(detailerFactGraph(), { resolution });
assert.deepEqual(statusValues(facts), merged, JSON.stringify(resolution));
assert.ok(factFields.every(field => facts[field].sampler_id === null));
}
// A stage missing a field leaves it unresolved; a failed walk leaves everything unresolved.
const unrecorded = detailerFactGraph(); delete unrecorded.d.inputs.scheduler;
assert.equal(factsFor(unrecorded, { resolution: { state: "unresolved", sampler_id: "d" } }).scheduler.status, "unresolved");
const cyclic = detailerFactGraph(); cyclic.decode.inputs.samples = ["d", 0];
assert.ok(Object.values(factsFor(cyclic, { resolution: { state: "unresolved", sampler_id: "d" } })).every(fact => fact.status === "unresolved"));
// One listed sampler keeps today's rule: its own facts, attributed to it.
const single = factsFor(factGraph(), { resolution: { state: "unresolved", sampler_id: "s" } });
assert.ok(factFields.every(field => single[field].status === "known" && single[field].sampler_id === "s"));
});
test("a named sampler limits absent and unresolved facts to its own stages", () => {
// Codex P2: the server dropped a preview-only detailer and named the saved sampler, so
// the detailer's settings never leak into the saved file's facts.
const graph = detailerFactGraph();
graph.save = { class_type: "SaveImage", inputs: { images: ["decode", 0] } };
graph.preview = { class_type: "PreviewImage", inputs: { images: ["d", 0] } };
const own = {
seed: ["known", "42"], steps: ["known", "25"], cfg: ["known", "6.5"],
sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"],
};
for (const state of ["absent", "unresolved"]) assert.deepEqual(statusValues(factsFor(graph, { resolution: { state, sampler_id: "s" } })), own, state);
// Without a named sampler every listed sampler still merges.
assert.equal(factsFor(graph, { resolution: { state: "absent", sampler_id: null } }).seed.status, "ambiguous");
});
test("stage walks follow only the resolver's links", () => {
// Review M-2: `["eff", -1]` is not a link to the server, so it is not walked.
for (const slot of [-1, 0.5, "0"]) {
const graph = detailerFactGraph();
graph.eff = { class_type: "MysterySampler", inputs: { positive: ["model", 0], model: ["model", 0] } };
graph.decode.inputs.extra = ["eff", slot];
assert.equal(factsFor(graph, { resolution: { state: "ambiguous", sampler_id: "d" } }).seed.status, "ambiguous", String(slot));
const shaped = detailerFactGraph();
shaped.eff = { class_type: "Unlisted", inputs: { positive: ["model", slot], image: ["decode", 0] } };
shaped.d.inputs.image = ["eff", 0];
assert.equal(factsFor(shaped, { resolution: { state: "ambiguous", sampler_id: "d" } }).seed.status, "ambiguous", `shaped ${slot}`);
}
const linked = detailerFactGraph();
linked.eff = { class_type: "MysterySampler", inputs: { positive: ["model", 0], model: ["model", 0] } };
linked.decode.inputs.extra = ["eff", 0];
assert.equal(factsFor(linked, { resolution: { state: "ambiguous", sampler_id: "d" } }).seed.status, "unresolved");
});
test("Flux2Scheduler provides steps and scheduler is its node type", () => {
const graph = customFactGraph();
graph.sigmas = { class_type: "Flux2Scheduler", inputs: { steps: 4, width: 1024, height: 1024 } };
const facts = factsFor(graph);
assert.deepEqual([facts.steps.status, facts.steps.value], ["known", "4"]);
assert.deepEqual([facts.scheduler.status, facts.scheduler.value, facts.scheduler.evidence], ["known", "Flux2Scheduler", "configuration"]);
for (const slot of [1, -1]) {
const linked = customFactGraph(); linked.sigmas = graph.sigmas; linked.s.inputs.sigmas = ["sigmas", slot];
assert.equal(factsFor(linked).scheduler.status, "unresolved", `output${slot}`);
}
// Its own inputs cannot replace the node type, and steps keep exact-integer rules.
const named = customFactGraph(); named.sigmas = { class_type: "Flux2Scheduler", inputs: { steps: 0, scheduler: "karras" } };
assert.equal(factsFor(named).scheduler.value, "Flux2Scheduler");
assert.equal(factsFor(named).steps.status, "unresolved");
});
test("ModelSampling passthroughs reach UNETLoader", () => {
const graph = customFactGraph();
graph.g.inputs.model = ["flux", 0];
graph.flux = { class_type: "ModelSamplingFlux", inputs: { model: ["aura", 0], max_shift: "1.15", base_shift: "0.5" } };
graph.aura = { class_type: "ModelSamplingAuraFlow", inputs: { model: ["sd3", 0], shift: "1.73" } };
graph.sd3 = { class_type: "ModelSamplingSD3", inputs: { model: ["lora", 0], shift: "3" } };
graph.lora = { class_type: "LoraLoaderModelOnly", inputs: { model: ["m", 0], lora_name: "style", strength_model: "1" } };
assert.deepEqual([factsFor(graph).model.status, factsFor(graph).model.value], ["known", "flux.safetensors"]);
for (const node of ["flux", "aura", "sd3"]) {
const wrongSlot = structuredClone(graph);
const parent = { flux: "g", aura: "flux", sd3: "aura" }[node];
wrongSlot[parent].inputs.model = [node, 1];
assert.equal(factsFor(wrongSlot).model.status, "unresolved", `${node} output1`);
const unlisted = structuredClone(graph); unlisted[node].class_type = "ModelSamplingUnknown";
assert.equal(factsFor(unlisted).model.status, "unresolved", `${node} unlisted`);
}
});
test("vectors.json pins full facts for every inspection fixture", () => {
const fs = require("node:fs");
const path = require("node:path");
const vectors = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "fixtures", "inspection_parity", "vectors.json"), "utf8"));
const inspection = vectors.filter(vector => vector.profile === "inspection");
assert.ok(inspection.length > 0);
const statuses = new Set(["known", "ambiguous", "unresolved", "not-recorded"]);
for (const vector of inspection) {
const facts = vector.expect.facts;
assert.deepEqual(Object.keys(facts), factFields, vector.name);
for (const field of factFields) {
assert.ok(statuses.has(facts[field].status), `${vector.name} ${field}`);
assert.equal(facts[field].value === null, facts[field].status !== "known", `${vector.name} ${field} value`);
}
}
// f19: KSampler seed 42 / steps 25 / cfg 6.5 then FaceDetailer seed 7 / steps 12 / cfg 4.5, different prompts.
const f19 = inspection.find(vector => vector.fixture === "f19");
assert.equal(f19.expect.state, "ambiguous");
assert.deepEqual(Object.fromEntries(factFields.map(field => [field, f19.expect.facts[field].status])), {
seed: "ambiguous", steps: "ambiguous", cfg: "ambiguous", sampler: "known", scheduler: "known", model: "known",
});
});
test("wide workflow lookups retain selected core sampler facts", () => {
const nodes = [{ id: "text", type: "PrimitiveString", widgets_values_named: { value: "unrelated text" } }], links = [];
let linkId = 1;
-119
View File
@@ -1,119 +0,0 @@
// Run with: node --test tests/js/
// Legacy parser rules ported to the inspection facts (#240, Task 2.7b). Synthetic texts.
const test = require("node:test");
const assert = require("node:assert/strict");
const { extractGenerationFacts } = require("../../web/js/comfy-metadata.js");
const fields = ["seed", "steps", "cfg", "sampler", "scheduler", "model"];
const FOOTER = "Steps: 20, Sampler: Euler, CFG scale: 7, Seed: 5";
function parameterFacts(parameters) {
const facts = extractGenerationFacts({
prompt_graph: { status: "absent", value: null },
workflow_graph: { status: "absent", value: null },
parameters,
recordFormats: { parameters: "original-text" },
metadataStatus: "available",
});
return Object.fromEntries(fields.map(field => [field, [facts[field].status, facts[field].value]]));
}
const FOUR_KNOWN = {
seed: ["known", "5"], steps: ["known", "20"], cfg: ["known", "7"], sampler: ["known", "Euler"],
scheduler: ["not-recorded", null], model: ["not-recorded", null],
};
const ALL_UNRESOLVED = Object.fromEntries(fields.map(field => [field, ["unresolved", null]]));
test("R4: text starting with the negative delimiter has no positive, as with a leading newline", () => {
for (const text of [`Negative prompt: bad\n${FOOTER}`, `\nNegative prompt: bad\n${FOOTER}`, `Negative prompt: a\nNegative prompt: b\n${FOOTER}`]) {
assert.deepEqual(parameterFacts(text), ALL_UNRESOLVED, text);
}
assert.deepEqual(parameterFacts(`a cat\nNegative prompt: bad\n${FOOTER}`), FOUR_KNOWN);
});
test("R5: template records after the footer and a bare Steps footer", () => {
const base = `a cat\nNegative prompt: bad\n${FOOTER}`;
for (const trailer of ["\nTemplate: a {cat|dog}", "\nTemplate: a {cat|dog}\nNegative Template: bad", "\nNegative Template: bad\n"]) {
assert.deepEqual(parameterFacts(base + trailer), FOUR_KNOWN, trailer);
}
for (const trailer of ["\nTemplate: a\nTemplate: b", "\nNegative Template: bad\nTemplate: a", "\nTemplate: a\nmore", "\ntrailing"]) {
assert.deepEqual(parameterFacts(base + trailer), ALL_UNRESOLVED, trailer);
}
const bare = parameterFacts("a cat\nNegative prompt: bad\nSteps: 20\nTemplate: a {cat|dog}");
assert.deepEqual(bare.steps, ["known", "20"]);
assert.ok(["seed", "cfg", "sampler", "scheduler", "model"].every(field => bare[field][0] === "not-recorded"));
// A full footer wins over a bare `Steps: N` line in the prompt; two bare ones are not unique.
assert.deepEqual(parameterFacts(`a cat\nSteps: 3\nNegative prompt: bad\n${FOOTER}`), FOUR_KNOWN);
assert.deepEqual(parameterFacts("a cat\nSteps: 3\nNegative prompt: bad\nSteps: 20"), ALL_UNRESOLVED);
});
test("R10: commas inside quoted settings values do not split them", () => {
assert.deepEqual(parameterFacts(`a cat\nNegative prompt: bad\n${FOOTER}, ADetailer prompt: "face, smile"`), FOUR_KNOWN);
assert.deepEqual(parameterFacts(`a cat\n${FOOTER}, Note: "say \\"hi, there\\"", Model: base`), { ...FOUR_KNOWN, model: ["known", "base"] });
// A quoted value of a fact is its JSON string.
assert.deepEqual(parameterFacts(`a cat\n${FOOTER}, Model: "base, v2"`).model, ["known", "base, v2"]);
// Unquoted JSON (comfy-image-saver hashes) keeps the historical split, field by field.
assert.deepEqual(parameterFacts(`a cat\n${FOOTER}, Hashes: {"model": "abc", "lora:x": "def"}, Model: base`).model, ["known", "base"]);
// An unbalanced quote is not a quoted value: the historical split, as A1111 reads it.
assert.deepEqual(parameterFacts(`a cat\n${FOOTER}, Note: "open, Model: base`).model, ["known", "base"]);
});
function graphFacts(graph, resolution) {
const facts = extractGenerationFacts({
prompt_graph: { status: "parsed", value: graph },
workflow_graph: { status: "absent", value: null },
metadataStatus: "available",
resolution,
});
return Object.fromEntries(fields.map(field => [field, [facts[field].status, facts[field].value]]));
}
function modelGraph(between) {
return {
ckpt: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "base.safetensors" } },
hop: { class_type: between, inputs: { model: ["ckpt", 0], clip: ["ckpt", 1] } },
s: { class_type: "KSampler", inputs: { seed: 5, steps: 20, cfg: 7, sampler_name: "euler", scheduler: "normal", model: ["hop", 0] } },
};
}
test("R9: model pass-throughs verified in their sources lead to the loader", () => {
for (const name of ["Lora Loader (LoraManager)", "Power Lora Loader (rgthree)", "ModelPatchTorchSettings", "CFGNorm", "PerturbedAttentionGuidance"]) {
assert.deepEqual(graphFacts(modelGraph(name)).model, ["known", "base.safetensors"], name);
// Only output 0 is the model.
const other = modelGraph(name); other.s.inputs.model = ["hop", 1];
assert.deepEqual(graphFacts(other).model, ["unresolved", null], name);
}
assert.deepEqual(graphFacts(modelGraph("ModelMergeSDXL")).model, ["unresolved", null]);
});
test("R6: upscale and SEGS detailers supply stage facts like FaceDetailer", () => {
for (const [class_type, image] of [["UltimateSDUpscale", "image"], ["UltimateSDUpscaleNoUpscale", "upscaled_image"], ["DetailerForEach", "image"]]) {
const graph = modelGraph("CFGNorm");
graph.decode = { class_type: "VAEDecode", inputs: { samples: ["s", 0], vae: ["ckpt", 2] } };
graph.d = { class_type, inputs: { [image]: ["decode", 0], model: ["hop", 0], seed: 7, steps: 12, cfg: 7, sampler_name: "euler", scheduler: "normal" } };
assert.deepEqual(graphFacts(graph, { state: "resolved", sampler_id: "d" }).seed, ["known", "7"], class_type);
const merged = graphFacts(graph, { state: "ambiguous", sampler_id: "d" });
assert.deepEqual([merged.seed[0], merged.steps[0], merged.cfg], ["ambiguous", "ambiguous", ["known", "7"]], class_type);
}
});
test("R6: upstream-verified samplers supply facts unless an override input is set", () => {
for (const [class_type, overrides] of [["ClownsharKSampler_Beta", ["options", "sigmas"]], ["KSampler (Efficient)", ["script"]], ["ttN KSampler_v2", ["adv_xyPlot"]]]) {
const graph = modelGraph("CR Apply LoRA Stack");
graph.s.class_type = class_type;
assert.deepEqual(graphFacts(graph), {
seed: ["known", "5"], steps: ["known", "20"], cfg: ["known", "7"], sampler: ["known", "euler"],
scheduler: ["known", "normal"], model: ["known", "base.safetensors"],
}, class_type);
for (const name of overrides) {
const overridden = structuredClone(graph);
overridden.s.inputs[name] = ["script", 0];
overridden.script = { class_type: "SomeScript", inputs: {} };
assert.deepEqual(graphFacts(overridden), ALL_UNRESOLVED, `${class_type} ${name}`);
for (const unset of [null, ""]) {
overridden.s.inputs[name] = unset;
assert.equal(graphFacts(overridden).seed[0], "known", `${class_type} ${name} ${unset}`);
}
}
}
});
@@ -1,79 +0,0 @@
// Run with: node --test tests/js/
// comfy-metadata.js stays a classic script for the legacy pages, so its facts
// tables are inline literals. This test keeps them equal to the one semantics
// table (web/js/inspection-semantics.json) that Python also loads.
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const metadata = require("../../web/js/comfy-metadata.js");
const tablePath = path.join(__dirname, "..", "..", "web", "js", "inspection-semantics.json");
const semantics = JSON.parse(fs.readFileSync(tablePath, "utf8"));
function plain(value) {
if (value instanceof Set) return [...value];
if (Array.isArray(value)) return value.map(plain);
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, plain(item)]));
return value;
}
test("facts tables equal semantics.facts", () => {
assert.equal(typeof metadata._factsTables, "object", "comfy-metadata exposes its facts tables for this test");
assert.deepStrictEqual(plain(metadata._factsTables), semantics.facts);
});
test("facts guider providers are guiders in the semantics table", () => {
const providers = Object.entries(semantics.facts.configuration_outputs).filter(([, adapter]) => adapter.kind === "guider");
assert.ok(providers.length > 0);
for (const [name] of providers) assert.ok(Object.hasOwn(semantics.guiders, name), name);
});
test("the facts tables are not part of the legacy page contract", () => {
assert.deepStrictEqual(Object.keys(metadata).filter(key => !key.startsWith("_")).sort(), ["PLACEHOLDERS", "extractGenerationFacts", "extractGenerationParams"]);
assert.ok(Object.isFrozen(metadata._factsTables));
});
// Task 2.1 checked only `facts`; the facts code also reads the samplers table
// (which classes are samplers, their facts shape and a detailer's image input).
test("sampler facts table equals semantics.samplers", () => {
assert.equal(typeof metadata._samplerTable, "object", "comfy-metadata exposes its sampler table for this test");
// Task 2.7b: plus `fact_overrides`; `prompt_overrides` is read only by the resolver.
const expected = Object.fromEntries(Object.entries(semantics.samplers).map(([name, entry]) => [name, {
facts: entry.facts,
...(entry.image_stage === undefined ? {} : { image_stage: entry.image_stage }),
...(entry.fact_overrides === undefined ? {} : { fact_overrides: entry.fact_overrides }),
}]));
assert.deepStrictEqual(plain(metadata._samplerTable), expected);
assert.ok(Object.isFrozen(metadata._samplerTable));
});
test("detailer-stage samplers, and only they, name an image stage", () => {
for (const [name, entry] of Object.entries(semantics.samplers)) {
assert.equal(Object.hasOwn(metadata._samplerTable[name], "image_stage"), entry.strategy === "detailer-stage", name);
}
});
// The stage walk treats every table class as known (the resolver's `all_classes`).
test("stage walk table classes equal every class in the semantics table", () => {
const classes = new Set();
for (const category of ["samplers", "guiders", "selectors", "composites", "savers"]) Object.keys(semantics[category]).forEach(name => classes.add(name));
for (const category of ["conditioning", "encoders", "strings"]) semantics[category].forEach(entry => classes.add(entry.class));
for (const category of ["runtime", "non_file_outputs"]) semantics[category].forEach(name => classes.add(name));
assert.deepStrictEqual([...metadata._tableClasses].sort(), [...classes].sort());
});
// Review I-2: one source for the stage-walk input lists (Python reads them there too).
test("stage walk inputs equal semantics.stage_inputs", () => {
assert.equal(typeof semantics.stage_inputs, "object");
assert.equal(typeof metadata._stageInputs, "object", "comfy-metadata exposes its stage inputs for this test");
assert.deepStrictEqual(plain(metadata._stageInputs), semantics.stage_inputs);
assert.ok(Object.isFrozen(metadata._stageInputs));
});
// Review M-3: JS treats every samplers entry as a candidate; the server only these strategies.
test("every samplers entry is a candidate strategy", () => {
for (const [name, entry] of Object.entries(semantics.samplers)) {
assert.ok(["ksampler", "custom-advanced", "detailer-stage"].includes(entry.strategy), name);
}
});
@@ -3,10 +3,7 @@
// (web/metadata.html, web/js/gallery.js) show today on the synthetic inspection
// parity fixtures, so the #240 workspace work cannot change them by accident.
// Some answers are known to be wrong (f01b shows the preview-only detailer text
// as both roles; f16 shows the last of two conflicting prompt chunks; f19 shows the
// upstream sampler's prompt and settings, not the detailer's; f23 shows the positive
// text as the zeroed negative; f21 joins a list output; f22 shows PromptSearchList's
// search text as the prompt). They are
// as both roles; f16 shows the last of two conflicting prompt chunks). They are
// recorded as-is and intentionally unchanged (spec section 11).
const test = require("node:test");
const assert = require("node:assert/strict");
@@ -59,12 +56,6 @@ const EXPECTED = {
f15: { positivePrompt: "a red fox in snow, detailed fur", negativePrompt: "blurry, lowres", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 },
f16: { positivePrompt: "a grey wolf in fog", negativePrompt: "blurry, lowres", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 },
f17: { positivePrompt: "No prompt found", negativePrompt: "No negative prompt found", checkpoint: "flux1-dev.safetensors", steps: "Unknown", cfgScale: "Unknown", sampler: "euler", seed: "Unknown" },
f19: { positivePrompt: "a red fox in snow, detailed fur", negativePrompt: "blurry, lowres", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 },
f20: { positivePrompt: "No prompt found", negativePrompt: "blurry, lowres", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 },
f21: { positivePrompt: "a red fox, in snow", negativePrompt: "blurry, lowres", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 },
f22: { positivePrompt: "fox", negativePrompt: "blurry, lowres", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 },
f23: { positivePrompt: "a red fox in snow, detailed fur", negativePrompt: "a red fox in snow, detailed fur", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 },
f24: { positivePrompt: "No prompt found", negativePrompt: "No negative prompt found", checkpoint: "sdxl_base.safetensors", steps: 25, cfgScale: 6.5, sampler: "euler", seed: 42 },
};
test("legacy extractGenerationParams characterization covers every committed fixture", () => {
-73
View File
@@ -70,76 +70,3 @@ test('frozen fields disclose their exact source and keep null, empty and whitesp
container.click(f.walk(container,node=>node.dataset.sendRole==='pair')[0]);assert.equal(sent.length,2);
}finally{scope.dispose();f.restore();}
});
test('a zeroed conditioning negative shows as an explicit empty negative with its own source',()=>{
const f=dom(),scope=createScope(),container=new f.Element('div'),sent=[];
try{
module.mountPromptBlocks(container,{parentScope:scope,sender:{open:roles=>sent.push(roles)},prompts:{positive:{status:'resolved',text:'positive',source:'graph-inferred'},negative:{status:'resolved',text:'',source:'zeroed-conditioning'}}});
assert.equal(f.walk(container,node=>node.textContent==='Zeroed conditioning (no negative text)').length,1);
assert.equal(f.walk(container,node=>node.textContent==='Empty negative prompt').length,1);
container.click(f.walk(container,node=>node.dataset.sendRole==='pair')[0]);
assert.deepEqual(sent,[{positive:'positive',negative:''}]);
}finally{scope.dispose();f.restore();}
});
const unresolved=message=>({status:'unresolved',text:null,source:null,...(message===undefined?{}:{message})});
const statusLines=(f,container)=>f.walk(container,node=>node.tagName==='p'&&node.className==='muted').map(node=>node.textContent);
test('unsupported node status names the class',()=>{
const f=dom(),scope=createScope(),container=new f.Element('div');
const line='Prompt uses a node the inspector does not support (DualCFGGuider).';
try{
module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:unresolved(line),negative:unresolved(line)}});
assert.deepEqual(statusLines(f,container),[line,line]);
assert.equal(f.walk(container,node=>node.dataset.sendRole!==undefined||node.dataset.copyRole!==undefined).length,0,'no action implies a prompt exists');
}finally{scope.dispose();f.restore();}
});
test('runtime and composite statuses',()=>{
const f=dom(),scope=createScope(),container=new f.Element('div');
const runtime='Prompt is chosen at run time (PromptSearchList).',composite='Prompt combines several texts (ConditioningCombine).';
try{
const view=module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:unresolved(runtime),negative:unresolved(runtime)}});
assert.deepEqual(statusLines(f,container),[runtime,runtime]);
view.update({positive:unresolved(composite),negative:{status:'resolved',text:'kept',source:'execution'}});
assert.deepEqual(statusLines(f,container),[composite]);
assert.equal(f.walk(container,node=>node.tagName==='pre'&&node.textContent==='kept').length,1);
}finally{scope.dispose();f.restore();}
});
test('limit status',()=>{
const f=dom(),scope=createScope(),container=new f.Element('div');
const line='Prompt exceeds inspection limits.';
try{
module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:unresolved(line),negative:unresolved(line)}});
assert.deepEqual(statusLines(f,container),[line,line]);
}finally{scope.dispose();f.restore();}
});
test('roles without a usable line keep the status copy, and a candidate is never shown as a prompt',()=>{
const f=dom(),scope=createScope(),container=new f.Element('div');
try{
module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:unresolved(''),negative:{status:'ambiguous',text:'candidate text',source:null}}});
assert.deepEqual(statusLines(f,container),['Prompt could not be resolved.','Multiple possible prompts. Source could not be determined.']);
assert.equal(f.walk(container,node=>node.textContent==='candidate text').length,0);
}finally{scope.dispose();f.restore();}
});
test('class names in status lines render as text, never markup',()=>{
const f=dom(),scope=createScope(),container=new f.Element('div');
const line='Prompt uses a node the inspector does not support (<img src=x onerror=alert(1)>).';
try{
module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:unresolved(line),negative:unresolved(line)}});
assert.deepEqual(statusLines(f,container),[line,line]);
assert.equal(f.walk(container,node=>node.tagName==='img').length,0);
assert.equal(f.walk(container,node=>Object.hasOwn(node,'innerHTML')).length,0);
}finally{scope.dispose();f.restore();}
});
test('a context label names nested prompt regions and actions apart from the frozen blocks while the visible heading stays',()=>{
const f=dom(),scope=createScope(),container=new f.Element('div');
try{
module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},contextLabel:'Current file',prompts:{positive:record('p'),negative:record('n')}});
const labels=f.walk(container,node=>node.attributes['aria-label']).map(node=>node.attributes['aria-label']);
for(const name of ['Current file positive prompt','Current file negative prompt','Copy current file positive prompt','Send current file positive prompt','Copy current file negative prompt','Send current file negative prompt']) assert.ok(labels.includes(name),name);
assert.equal(labels.filter(name=>/^(Positive|Negative) prompt$/.test(name)).length,0);
assert.deepEqual(f.walk(container,node=>node.tagName==='h3').map(node=>node.textContent),['Positive prompt','Negative prompt']);
const plain=new f.Element('div');module.mountPromptBlocks(plain,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:record('p')}});
assert.ok(f.walk(plain,node=>node.attributes['aria-label']==='Positive prompt').length);
}finally{scope.dispose();f.restore();}
});
@@ -1,32 +0,0 @@
// Runs the legacy browser parser (comfy-metadata.js extractGenerationParams) on
// one JSON case per input line and prints one JSON result per line. The Python
// port (utils/generation_params.py) must produce the same results.
//
// Input line: {"prompt": <text or null>, "workflow": <text or null>}
// Each present record is parsed with JSON.parse; a record that fails
// to parse is treated as absent, as the browser pages do.
// Output line: {"ok": true, "result": {...}} or {"ok": false} when the parser throws.
import path from 'node:path';
import fs from 'node:fs';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
const { extractGenerationParams } = require(path.join(here, '..', '..', '..', 'web', 'js', 'comfy-metadata.js'));
function parsed(text) {
if (typeof text !== 'string') return undefined;
try { return JSON.parse(text); } catch { return undefined; }
}
for (const line of fs.readFileSync(0, 'utf8').split('\n')) {
if (!line.trim()) continue;
const item = JSON.parse(line), data = {};
const prompt = parsed(item.prompt), workflow = parsed(item.workflow);
if (prompt !== undefined) data.prompt = prompt;
if (workflow !== undefined) data.workflow = workflow;
let out;
try { out = { ok: true, result: extractGenerationParams(data) }; } catch { out = { ok: false }; }
process.stdout.write(JSON.stringify(out) + '\n');
}
-133
View File
@@ -225,48 +225,6 @@ test('record accepts closed reason codes and rejects contradictory ones', () =>
}
});
test('resolution validator accepts closed reason codes and a bounded node record, rejects others', () => {
const toRecord = contract('toInspectionRecord');
for (const reason_code of ['unsupported-node', 'runtime-value', 'composite']) {
for (const node of [{ id: '7', class_type: 'N'.repeat(200) }, { id: '7'.repeat(200), class_type: null }, null]) {
const data = envelope(); Object.assign(data.resolution, { state: 'unresolved', reason_code, node });
assert.doesNotThrow(() => toRecord(data, image(), extractGenerationFacts), JSON.stringify({ reason_code, node }));
}
}
for (const change of [
{ reason_code: 'unsupported-node', node: { id: '7', class_type: 'N'.repeat(201) } },
{ reason_code: 'unsupported-node', node: { id: '7'.repeat(201), class_type: 'X' } },
{ reason_code: 'unsupported-node', node: { id: '', class_type: 'X' } },
{ reason_code: 'unsupported-node', node: { id: '7', class_type: 'X', extra: true } },
{ reason_code: 'unsupported-node', node: [] },
{ reason_code: 'malformed', node: { id: '7', class_type: 'X' } },
{ reason_code: 'work-limit', node: { id: '7', class_type: 'X' } },
{ reason_code: 'no-sampler', node: { id: '7', class_type: 'X' } },
{ reason_code: 'Unsupported-Node', node: null },
{ reason_code: '', node: null },
]) {
const data = envelope(); Object.assign(data.resolution, { state: 'unresolved', ...change });
assert.throws(() => toRecord(data, image(), extractGenerationFacts), TypeError, JSON.stringify(change));
}
});
test('role outcome lines are bounded text on non-resolved roles only', () => {
const toRecord = contract('toInspectionRecord');
const line = 'Prompt uses a node the inspector does not support (<img src=x onerror=alert(1)>).';
const data = envelope(); data.prompts.negative = { status: 'unresolved', text: null, source: null, message: line };
assert.equal(toRecord(data, image(), extractGenerationFacts).prompts.negative.message, line);
for (const [role, record] of [
['positive', { status: 'resolved', text: 'exact', source: 'execution', message: 'Hidden reason.' }],
['negative', { status: 'unresolved', text: null, source: null, message: '' }],
['negative', { status: 'unresolved', text: null, source: null, message: 7 }],
['negative', { status: 'unresolved', text: null, source: null, message: null }],
['negative', { status: 'unresolved', text: null, source: null, message: 'x'.repeat(513) }],
]) {
const bad = envelope(); bad.prompts[role] = record;
assert.throws(() => toRecord(bad, image(), extractGenerationFacts), TypeError, JSON.stringify(record));
}
});
test('record rejects root path media and invalid versioned fields with retryable TypeError', () => {
const toRecord = contract('toInspectionRecord');
for (const mutate of [
@@ -464,94 +422,3 @@ test('rooted controller keeps legacy descriptors with ignored noncloneable field
let pending;assert.doesNotThrow(()=>{pending=controller.select(selected);});
requests[0].resolve(envelope());await pending;assert.equal(controller.state.record.identity.key,'0:folder/a #%.png');controller.dispose();
});
// The maintainer's standard pipeline: one PromptManager pair feeds a base pass, an
// upscale pass and a FaceDetailer, saved by a custom saver. Text and seeds are invented.
function detailerPipelineGraph() {
return {
1: { class_type: 'CheckpointLoaderSimple', inputs: { ckpt_name: 'Base/example_model.safetensors' } },
5: { class_type: 'KSampler', inputs: { seed: 111, steps: 4, cfg: 1.0, sampler_name: 'lcm', scheduler: 'normal', denoise: 1.0, model: ['47', 0], positive: ['25', 0], negative: ['26', 0], latent_image: ['20', 0] } },
6: { class_type: 'VAEDecode', inputs: { samples: ['5', 0], vae: ['1', 2] } },
7: { class_type: 'PreviewImage', inputs: { images: ['6', 0] } },
8: { class_type: 'UpscaleModelLoader', inputs: { model_name: 'upscaler.pth' } },
9: { class_type: 'ImageUpscaleWithModel', inputs: { upscale_model: ['8', 0], image: ['6', 0] } },
10: { class_type: 'ImageScaleBy', inputs: { upscale_method: 'lanczos', scale_by: 0.75, image: ['9', 0] } },
11: { class_type: 'VAEEncode', inputs: { pixels: ['10', 0], vae: ['1', 2] } },
12: { class_type: 'KSampler', inputs: { seed: 222, steps: 6, cfg: 1.0, sampler_name: 'lcm', scheduler: 'normal', denoise: 0.45, model: ['47', 0], positive: ['25', 0], negative: ['26', 0], latent_image: ['11', 0] } },
13: { class_type: 'VAEDecode', inputs: { samples: ['12', 0], vae: ['1', 2] } },
17: { class_type: 'FaceDetailer', inputs: { seed: 333, steps: 4, cfg: 1.0, sampler_name: 'lcm', scheduler: 'normal', denoise: 0.4, image: ['13', 0], model: ['47', 0], clip: ['47', 1], vae: ['1', 2], positive: ['25', 0], negative: ['26', 0] } },
20: { class_type: 'EmptyLatentBatch', inputs: { width: 640, height: 1536, batch_size: 1 } },
25: { class_type: 'PromptManager', inputs: { text: 'a lighthouse at dusk, oil painting', category: '', tags: '', search_text: '', prepend_text: '', append_text: '', clip: ['47', 1] } },
26: { class_type: 'PromptManager', inputs: { text: 'blurry, watermark', category: '', tags: '', search_text: '', prepend_text: '', append_text: '', clip: ['47', 1] } },
47: { class_type: 'Lora Loader (LoraManager)', inputs: { text: '', model: ['1', 0], clip: ['1', 1] } },
48: { class_type: 'KikoSaveImage', inputs: { filename_prefix: ['49', 0], format: 'PNG', images: ['17', 0] } },
49: { class_type: 'KikoProjectFilePath', inputs: { project_name: 'example', filename: 'image' } },
};
}
test('workflow prompts and settings match the legacy parser when the server cannot decide', t => {
const parser = require('../../web/js/comfy-metadata.js'), { extractGenerationParams } = parser;
globalThis.ComfyMetadata = parser;
t.after(() => { delete globalThis.ComfyMetadata; });
const entry = image(), data = envelope(entry);
data.prompts = { positive: { status: 'ambiguous', text: null, source: null }, negative: { status: 'ambiguous', text: null, source: null } };
data.metadata.prompt_graph = { status: 'parsed', value: detailerPipelineGraph() };
data.resolution = { state: 'ambiguous', sampler_id: null, reason: 'Multiple sampler branches' };
const record = contract('toInspectionRecord')(data, entry, extractGenerationFacts);
const legacy = extractGenerationParams({ prompt: detailerPipelineGraph() });
assert.deepEqual(record.prompts.positive, { status: 'resolved', text: legacy.positivePrompt, source: 'graph-inferred' });
assert.deepEqual(record.prompts.negative, { status: 'resolved', text: legacy.negativePrompt, source: 'graph-inferred' });
assert.equal(record.prompts.positive.text, 'a lighthouse at dusk, oil painting');
assert.equal(record.prompts.negative.text, 'blurry, watermark');
for (const [field, key] of [['seed', 'seed'], ['steps', 'steps'], ['cfg', 'cfgScale'], ['sampler', 'sampler'], ['model', 'checkpoint']]) {
assert.equal(record.generation[field].status, 'known', field);
assert.equal(record.generation[field].value, String(legacy[key]), field);
}
});
test('recorded and saved prompts are never replaced by the workflow parse', t => {
globalThis.ComfyMetadata = require('../../web/js/comfy-metadata.js');
t.after(() => { delete globalThis.ComfyMetadata; });
const entry = image(), data = envelope(entry);
data.metadata.prompt_graph = { status: 'parsed', value: detailerPipelineGraph() };
const record = contract('toInspectionRecord')(data, entry, extractGenerationFacts);
assert.deepEqual(record.prompts.positive, { status: 'resolved', text: 'exact\npositive', source: 'execution' });
assert.equal(record.prompts.negative.text, 'blurry, watermark');
});
test('a definite server prompt is kept even when the legacy parser reads it differently', t => {
globalThis.ComfyMetadata = require('../../web/js/comfy-metadata.js');
t.after(() => { delete globalThis.ComfyMetadata; });
const entry = image(), data = envelope(entry);
// The legacy parser follows ConditioningZeroOut back to the positive text node.
const graph = detailerPipelineGraph();
graph[30] = { class_type: 'ConditioningZeroOut', inputs: { conditioning: ['25', 0] } };
graph[5].inputs.negative = ['30', 0];
data.metadata.prompt_graph = { status: 'parsed', value: graph };
data.prompts = { positive: { status: 'ambiguous', text: null, source: null }, negative: { status: 'resolved', text: '', source: 'zeroed-conditioning' } };
const record = contract('toInspectionRecord')(data, entry, extractGenerationFacts);
assert.deepEqual(record.prompts.negative, { status: 'resolved', text: '', source: 'zeroed-conditioning' });
assert.equal(record.prompts.positive.text, 'a lighthouse at dusk, oil painting');
});
test('callers without the browser global pass the legacy parser explicitly', () => {
const parser = require('../../web/js/comfy-metadata.js');
const entry = image(), data = envelope(entry);
data.prompts = { positive: { status: 'ambiguous', text: null, source: null }, negative: { status: 'ambiguous', text: null, source: null } };
data.metadata.prompt_graph = { status: 'parsed', value: detailerPipelineGraph() };
const identity = { kind: 'rooted-file', key: '0:x', root_index: 0, path: entry.path, root_scope: scopeA };
const record = contract('projectInspectionPayload')(data, identity, null, extractGenerationFacts, false, parser);
assert.equal(record.prompts.positive.text, 'a lighthouse at dusk, oil painting');
assert.equal(record.prompts.negative.text, 'blurry, watermark');
});
test('a setting the file does not record is never filled from the legacy parse', t => {
globalThis.ComfyMetadata = require('../../web/js/comfy-metadata.js');
t.after(() => { delete globalThis.ComfyMetadata; });
const entry = image(), data = envelope(entry);
data.metadata.prompt_graph = { status: 'parsed', value: detailerPipelineGraph() };
const extract = input => ({ ...extractGenerationFacts(input), cfg: { status: 'not-recorded', value: null, source: null, evidence: null, reason: null, sampler_id: null } });
const record = contract('toInspectionRecord')(data, entry, extract);
assert.equal(record.generation.cfg.status, 'not-recorded');
assert.equal(record.generation.cfg.value, null);
});
-31
View File
@@ -158,34 +158,3 @@ test('real inspection navigation preserves slideshow across two advances and def
assert.equal(f.container.children.length,0);assert.equal(controller.state.record.identity.occurrence.output_node_id,'3');
} finally {view.dispose();controller.dispose();parentScope.dispose();globalThis.setTimeout=oldSetTimeout;globalThis.clearTimeout=oldClearTimeout;f.restore();}
});
test('an inline Viewer still loading its image is destroyed only after it has viewed it', async () => {
const {reportedLocationAdapter:adapter} = await import('../../web/js/workspace/history-media.mjs');
const f = mountedDom(), parentScope = createScope(), controller = borrowedController(), items = [occurrenceMedia('1',1,'c')];
const instances = [];
// Viewer.js's inline destroy() leaves an in-flight view running; its callback then needs a live instance.
f.win.Viewer = class { constructor(node,options) { this.node = node; this.options = options; this.destroyed = 0; this.viewed = false; instances.push(this); }
destroy() { this.destroyed++; } resize() {} };
const globals = {ResizeObserver:globalThis.ResizeObserver, requestAnimationFrame:globalThis.requestAnimationFrame, cancelAnimationFrame:globalThis.cancelAnimationFrame};
Object.assign(globalThis, {ResizeObserver:class { observe() {} disconnect() {} }, requestAnimationFrame:() => 1, cancelAnimationFrame() {}});
const view = module.createMediaViewer({container:f.container,parentScope,inspectionController:controller,adapter,sender:{open(){}}});
try {
view.open({items,selectedKey:'c'.repeat(64)});
const [loading] = instances, image = loading.node, removed = [];
image.removeAttribute = key => removed.push(key);
f.walk(f.container,n => n.attributes['aria-label'] === 'Close preview')[0].fire('click');
assert.equal(loading.destroyed, 0, 'not destroyed while its image view is pending');
// Viewer.js lists only images that still have a src; the pending view needs it.
assert.deepEqual(removed, [], 'the image keeps its src while the view is pending');
loading.viewed = true; loading.options.viewed.call(loading);
assert.equal(loading.destroyed, 1, 'destroyed once its pending view settles');
assert.deepEqual(removed, ['src'], 'the src is released after the viewer');
loading.options.viewed.call(loading);
assert.equal(loading.destroyed, 1, 'never destroyed twice');
view.open({items,selectedKey:'c'.repeat(64)});
const shown = instances[1]; shown.viewed = true; shown.options.viewed.call(shown);
f.walk(f.container,n => n.attributes['aria-label'] === 'Close preview')[0].fire('click');
assert.equal(shown.destroyed, 1, 'a viewed image is destroyed at once');
} finally { view.dispose(); parentScope.dispose(); f.restore(); Object.assign(globalThis, globals); }
});
-15
View File
@@ -12,21 +12,6 @@ test('workspace routes resolve only supported deep links', async () => {
}
});
test('tags is a native route titled Tags; only unbuilt routes hand off to legacy tools', async () => {
const { resolveRoute, routeTitle, isHandoff } = await source;
const tags = resolveRoute('#/tags');
assert.equal(tags.label, 'Tags & categories');
assert.equal(routeTitle(tags), 'Tags');
assert.equal(tags.href, '/prompt_manager/admin#/tags', 'Overview keeps the existing tag manager card');
for (const id of ['images', 'library', 'inspect', 'overview', 'system']) {
const route = resolveRoute(`#/${id}`);
assert.equal(routeTitle(route), route.label);
}
assert.equal(routeTitle(resolveRoute('#/library/7')), 'Prompt library');
for (const id of ['images', 'library', 'tags', 'overview']) assert.equal(isHandoff(resolveRoute(`#/${id}`)), false, id);
for (const id of ['inspect', 'system']) assert.equal(isHandoff(resolveRoute(`#/${id}`)), true, id);
});
test('statistics preserve zero and reject incomplete or invalid counts', async () => {
const { summarizeStats } = await source;
assert.deepEqual(summarizeStats({total_prompts:0, total_images:12, total_tags:3}), [
-516
View File
@@ -1,516 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const {ApiError} = require('../../web/js/api-client.js');
// Guarded import: a missing module fails the assertions, not the file load.
const imported = import('../../web/js/workspace/vocabulary-state.mjs').catch(() => ({}));
const source = imported.then(module => {
for (const name of ['createVocabularyStream', 'createVocabularyCommand', 'browseFilters']) {
assert.equal(typeof module[name], 'function', `vocabulary-state.mjs exports ${name}`);
}
return module;
});
const EPOCH = '11111111-2222-4333-8444-555555555555';
const OTHER_EPOCH = '99999999-2222-4333-8444-555555555555';
const TOKEN = 'a'.repeat(64);
const tag = (id, name = `tag-${id}`, prompt_count = id) => ({id, name, prompt_count});
const category = (value, prompt_count = 1) => ({value, prompt_count});
const tagPage = (tags, cursor = null, {epoch = EPOCH, total = 3} = {}) =>
({success: true, library_epoch: epoch, total, has_more: cursor !== null, next_cursor: cursor, tags});
const categoryPage = (categories, cursor = null, {epoch = EPOCH, total = 3} = {}) =>
({success: true, library_epoch: epoch, total, has_more: cursor !== null, next_cursor: cursor, categories});
const queued = pages => {
const calls = [];
return {calls, api: {get: async (url, options) => {calls.push({url, options}); return pages.shift();}}};
};
const deferred = () => {
const requests = [];
return {requests, api: {get: (url, {signal}) => new Promise((resolve, reject) => requests.push({url, signal, resolve, reject}))}};
};
const params = url => new URL(url, 'https://x').searchParams;
const impact = {positive_prompts: 3, other_prompts: 1, negative_variants: 2, archived_negative_variants: 1};
const preview = (overrides = {}) => ({success: true, library_epoch: EPOCH, source: 'Landscapes', target: 'Scenery',
target_exists: false, impact, impact_token: TOKEN, ...overrides});
const tagReceipt = (overrides = {}) => ({success: true, library_epoch: EPOCH, created: true, tag: tag(7, 'sunset', 0), ...overrides});
const renameReceipt = (overrides = {}) => ({success: true, library_epoch: EPOCH, merged: false, tag: tag(1, 'dusk', 2),
components_changed: 2, pairs_changed: 1, ...overrides});
test('tag stream reads bounded fresh pages, carries the first epoch and dedupes appended ids', async () => {
const {createVocabularyStream} = await source;
const {calls, api} = queued([tagPage([tag(1), tag(2)], 'next'), tagPage([tag(2), tag(3)])]);
const states = [];
const stream = createVocabularyStream({api, kind: 'tags', onChange: state => states.push(state)});
assert.equal(Object.isFrozen(stream.state), true);
assert.equal(await stream.loadMore(), 2);
assert.equal(stream.state.epoch, EPOCH);
assert.equal(await stream.loadMore(), 1);
assert.deepEqual(stream.state.items.map(row => row.id), [1, 2, 3]);
assert.equal(Object.isFrozen(stream.state.items[0]), true);
assert.equal(stream.state.hasMore, false);
assert.equal(stream.state.total, 3);
assert.equal(await stream.loadMore(), 0);
assert.equal(calls.length, 2);
assert.match(calls[0].url, /^\/prompt_manager\/vocabulary\/tags\?/);
assert.equal(params(calls[0].url).get('limit'), '100');
assert.equal(params(calls[0].url).has('cursor'), false);
assert.equal(params(calls[1].url).get('cursor'), 'next');
assert.equal(calls[0].options.cache, 'no-store');
assert.ok(states.some(state => state.loading));
stream.dispose();
});
test('category stream dedupes by value and keeps the first occurrence when keyset order shifts', async () => {
const {createVocabularyStream} = await source;
const {calls, api} = queued([categoryPage([category('Landscapes', 3), category('Portraits', 2)], 'c1'),
categoryPage([category('Landscapes', 9), category('Studio', 1)])]);
const stream = createVocabularyStream({api, kind: 'categories'});
await stream.loadMore();
assert.equal(await stream.loadMore(), 1);
assert.deepEqual(stream.state.items.map(row => [row.value, row.prompt_count]),
[['Landscapes', 3], ['Portraits', 2], ['Studio', 1]]);
assert.match(calls[0].url, /^\/prompt_manager\/vocabulary\/categories\?/);
stream.dispose();
});
test('malformed and nonprogressing pages are rejected atomically', async () => {
const {createVocabularyStream} = await source;
const malformedTags = [
null, {}, {...tagPage([]), success: undefined}, {...tagPage([]), library_epoch: 'nope'},
tagPage([tag(1, 'a', 1.5)]), tagPage([tag(1, 'a', -1)]), tagPage([tag(1, 'a', '3')]),
tagPage([tag(0)]), tagPage([{...tag(1), name: 2}]), tagPage([tag(1), tag(1, 'other')]),
{...tagPage([]), has_more: true}, {...tagPage([], 'next'), has_more: false},
{...tagPage([]), next_cursor: ''}, {...tagPage([]), total: 1.5}, {...tagPage([]), tags: 'x'},
{...tagPage([]), tags: undefined, categories: []},
];
for (const malformed of malformedTags) {
const stream = createVocabularyStream({api: {get: async () => malformed}, kind: 'tags'});
await stream.loadMore();
assert.ok(stream.state.error, JSON.stringify(malformed));
assert.equal(stream.state.items.length, 0);
assert.equal(stream.state.epoch, null);
stream.dispose();
}
for (const malformed of [categoryPage([category('A'), category('A')]), categoryPage([category('A', 1.5)]),
categoryPage([{value: 3, prompt_count: 1}]), categoryPage([category('')])]) {
const stream = createVocabularyStream({api: {get: async () => malformed}, kind: 'categories'});
await stream.loadMore();
assert.ok(stream.state.error, JSON.stringify(malformed));
stream.dispose();
}
const repeated = createVocabularyStream({api: {get: async () => tagPage([tag(1)], 'same')}, kind: 'tags'});
await repeated.loadMore();
await repeated.loadMore();
assert.ok(repeated.state.error);
assert.deepEqual(repeated.state.items.map(row => row.id), [1]);
assert.equal(repeated.state.cursor, 'same');
repeated.dispose();
assert.throws(() => createVocabularyStream({api: {}, kind: 'images'}), TypeError);
});
test('reset aborts the older request and its late response cannot replace the first page', async () => {
const {createVocabularyStream} = await source;
const {requests, api} = deferred();
const stream = createVocabularyStream({api, kind: 'tags'});
const first = stream.loadMore();
const second = stream.reset();
assert.equal(requests[0].signal.aborted, true);
assert.equal(params(requests[1].url).has('cursor'), false);
requests[1].resolve(tagPage([tag(2)]));
assert.equal(await second, 1);
requests[0].resolve(tagPage([tag(1)], 'old', {epoch: OTHER_EPOCH}));
assert.equal(await first, 0);
assert.deepEqual(stream.state.items.map(row => row.id), [2]);
assert.equal(stream.state.epoch, EPOCH);
const third = stream.reset();
requests[2].reject(new Error('late failure'));
const fourth = stream.reset();
await third;
assert.equal(stream.state.error, null);
requests[3].resolve(tagPage([tag(5)]));
await fourth;
assert.deepEqual(stream.state.items.map(row => row.id), [5]);
stream.dispose();
});
test('dispose aborts the in-flight read and ignores its late result', async () => {
const {createVocabularyStream} = await source;
const {requests, api} = deferred();
const states = [];
const stream = createVocabularyStream({api, kind: 'categories', onChange: state => states.push(state)});
const pending = stream.loadMore();
stream.dispose();
assert.equal(requests[0].signal.aborted, true);
const count = states.length;
requests[0].resolve(categoryPage([category('A')]));
assert.equal(await pending, 0);
assert.equal(states.length, count);
assert.equal(await stream.loadMore(), 0);
assert.equal(await stream.reset(), 0);
assert.equal(requests.length, 1);
});
test('a changed epoch on append is an error that requires a reset', async () => {
const {createVocabularyStream} = await source;
const {calls, api} = queued([tagPage([tag(1)], 'next'), tagPage([tag(2)], null, {epoch: OTHER_EPOCH}),
tagPage([tag(4)], null, {epoch: OTHER_EPOCH})]);
const stream = createVocabularyStream({api, kind: 'tags'});
await stream.loadMore();
assert.equal(await stream.loadMore(), 0);
assert.equal(stream.state.error.code, 'storage-changed');
assert.equal(stream.state.resetRequired, true);
assert.deepEqual(stream.state.items.map(row => row.id), [1]);
assert.equal(stream.state.epoch, EPOCH);
assert.equal(await stream.loadMore(), 0);
assert.equal(calls.length, 2);
assert.equal(await stream.reset(), 1);
assert.equal(stream.state.resetRequired, false);
assert.equal(stream.state.epoch, OTHER_EPOCH);
assert.deepEqual(stream.state.items.map(row => row.id), [4]);
stream.dispose();
});
test('failed reads keep loaded rows and cursor so Retry continues', async () => {
const {createVocabularyStream} = await source;
let calls = 0;
const stream = createVocabularyStream({kind: 'tags', api: {get: async () => {
calls += 1;
if (calls === 1) return tagPage([tag(1)], 'next');
if (calls === 2) throw new Error('offline');
return tagPage([tag(2)]);
}}});
await stream.loadMore();
await stream.loadMore();
assert.match(stream.state.error.message, /offline/);
assert.equal(stream.state.resetRequired, false);
assert.equal(stream.state.cursor, 'next');
assert.equal(await stream.loadMore(), 1);
assert.equal(stream.state.error, null);
stream.dispose();
});
test('create tag sends the epoch once, completes with the receipt and re-reads streams from the first page', async () => {
const {createVocabularyCommand, createVocabularyStream} = await source;
const reads = queued([tagPage([tag(1)], 'next'), tagPage([tag(1), tag(7, 'sunset', 0)])]);
const stream = createVocabularyStream({api: reads.api, kind: 'tags'});
await stream.loadMore();
const posts = [];
const invalidations = [];
const command = createVocabularyCommand({
api: {post: async (url, body) => {posts.push({url, body}); return tagReceipt();}},
onInvalidate: event => {invalidations.push(event); stream.reset();},
});
assert.equal(command.state.phase, 'idle');
await command.createTag('sunset', EPOCH);
assert.deepEqual(posts, [{url: '/prompt_manager/vocabulary/tags', body: {expected_epoch: EPOCH, name: 'sunset'}}]);
assert.equal(command.state.phase, 'done');
assert.equal(command.state.kind, 'create-tag');
assert.equal(command.state.receipt.tag.name, 'sunset');
assert.equal(Object.isFrozen(command.state.receipt), true);
assert.equal(invalidations.length, 1);
assert.equal(invalidations[0].phase, 'done');
await new Promise(resolve => setImmediate(resolve));
assert.equal(params(reads.calls[1].url).has('cursor'), false);
assert.deepEqual(stream.state.items.map(row => row.id), [1, 7]);
await command.createTag('again', EPOCH);
assert.equal(posts.length, 1, 'done is terminal until reset()');
command.reset();
assert.equal(command.state.phase, 'idle');
stream.dispose();
});
test('rename and delete carry the exact tag identity and invalidate after success', async () => {
const {createVocabularyCommand} = await source;
const calls = [];
const invalidations = [];
const command = createVocabularyCommand({
api: {
patch: async (url, body) => {calls.push({method: 'patch', url, body}); return renameReceipt({merged: true});},
post: async (url, body) => {calls.push({method: 'post', url, body});
return {success: true, library_epoch: EPOCH, deleted: true, components_changed: 1, pairs_changed: 0};},
},
onInvalidate: event => invalidations.push(event),
});
await command.renameTag({id: 1, name: 'dawn', prompt_count: 2}, 'dusk', EPOCH);
assert.deepEqual(calls[0], {method: 'patch', url: '/prompt_manager/vocabulary/tags/1',
body: {expected_epoch: EPOCH, name: 'dawn', new_name: 'dusk'}});
assert.equal(command.state.receipt.merged, true);
command.reset();
await command.deleteTag({id: 1, name: 'dawn'}, EPOCH);
assert.deepEqual(calls[1], {method: 'post', url: '/prompt_manager/vocabulary/tags/1/delete',
body: {expected_epoch: EPOCH, name: 'dawn'}});
assert.equal(command.state.phase, 'done');
assert.equal(command.state.kind, 'delete-tag');
assert.deepEqual(invalidations.map(event => event.phase), ['done', 'done']);
command.reset();
assert.throws(() => command.renameTag({id: 0, name: 'x'}, 'y', EPOCH), TypeError);
assert.throws(() => command.createTag('x', 'not-an-epoch'), TypeError);
assert.throws(() => command.deleteTag({id: 1}, EPOCH), TypeError);
assert.equal(calls.length, 2);
});
test('409 storage-changed and vocabulary-conflict map to recovery phases; other failures stay honest', async () => {
const {createVocabularyCommand} = await source;
const cases = [
[new ApiError(409, 'restored', 'storage-changed'), 'storage-changed', true],
[new ApiError(409, 'changed', 'vocabulary-conflict'), 'conflict', true],
[new ApiError(409, 'other', null), 'write-error', false],
[new ApiError(400, 'Tag names cannot contain commas', null), 'write-error', false],
[new ApiError(502, 'Bad gateway'), 'unknown-outcome', true],
[new ApiError(200, 'Invalid JSON response'), 'unknown-outcome', true],
];
for (const [error, phase, invalidates] of cases) {
const invalidations = [];
const command = createVocabularyCommand({api: {post: async () => {throw error;}}, onInvalidate: event => invalidations.push(event)});
await command.createTag('sunset', EPOCH);
assert.equal(command.state.phase, phase, `${error.status} ${error.code}`);
assert.equal(command.state.error, error);
assert.equal(invalidations.length, invalidates ? 1 : 0);
command.dispose();
}
const invalid = createVocabularyCommand({api: {post: async () => tagReceipt({library_epoch: OTHER_EPOCH})}});
await invalid.createTag('sunset', EPOCH);
assert.equal(invalid.state.phase, 'unknown-outcome');
const retryable = createVocabularyCommand({api: {post: async () => {throw new ApiError(400, 'bad');}}});
await retryable.createTag('a,b', EPOCH);
await retryable.createTag('ab', EPOCH);
assert.equal(retryable.state.phase, 'write-error');
});
test('a network TypeError is an unknown outcome and is never retried automatically', async () => {
const {createVocabularyCommand} = await source;
let calls = 0;
const command = createVocabularyCommand({api: {patch: async () => {calls += 1; throw new TypeError('Failed to fetch');}}});
await command.renameTag({id: 3, name: 'dawn'}, 'dusk', EPOCH);
assert.equal(command.state.phase, 'unknown-outcome');
assert.equal(calls, 1);
await command.renameTag({id: 3, name: 'dawn'}, 'dusk', EPOCH);
await new Promise(resolve => setImmediate(resolve));
assert.equal(calls, 1);
command.reset();
assert.equal(command.state.phase, 'idle');
assert.equal(command.state.error, null);
});
test('a second Apply while saving is ignored', async () => {
const {createVocabularyCommand} = await source;
const posts = [];
let release;
const command = createVocabularyCommand({api: {post: (url, body) => {
posts.push({url, body});
if (url.endsWith('/preview')) return Promise.resolve(preview());
return new Promise(resolve => {release = resolve;});
}}});
await command.previewCategory('Landscapes', 'Scenery', EPOCH);
const shown = command.state.preview;
const first = command.applyCategory(shown);
assert.equal(command.state.phase, 'saving');
await command.applyCategory(shown);
await command.createTag('x', EPOCH);
await command.previewCategory('Landscapes', 'Other', EPOCH);
command.edit();
command.reset();
assert.equal(command.state.phase, 'saving');
assert.equal(posts.length, 2);
release({success: true, library_epoch: EPOCH, source: 'Landscapes', target: 'Scenery', consolidated: false, impact});
await first;
assert.equal(command.state.phase, 'done');
assert.deepEqual(posts[1], {url: '/prompt_manager/vocabulary/categories/apply',
body: {expected_epoch: EPOCH, source: 'Landscapes', target: 'Scenery', impact_token: TOKEN}});
});
test('category preview shows real impact; editing its inputs returns to idle and a stale preview cannot apply', async () => {
const {createVocabularyCommand} = await source;
const posts = [];
const invalidations = [];
const responses = [preview(), preview({target: 'Scenery', target_exists: true})];
const command = createVocabularyCommand({
api: {post: async (url, body) => {posts.push({url, body}); return responses.shift();}},
onInvalidate: event => invalidations.push(event),
});
await command.previewCategory('Landscapes', ' Scenery ', EPOCH);
assert.deepEqual(posts[0], {url: '/prompt_manager/vocabulary/categories/preview',
body: {expected_epoch: EPOCH, source: 'Landscapes', target: ' Scenery '}});
assert.equal(command.state.phase, 'previewed');
assert.equal(command.state.kind, 'preview-category');
const stale = command.state.preview;
assert.deepEqual(stale.impact, impact);
assert.equal(invalidations.length, 0, 'a preview writes nothing');
command.edit();
assert.equal(command.state.phase, 'idle');
assert.equal(command.state.preview, null);
await command.applyCategory(stale);
assert.equal(posts.length, 1);
await command.previewCategory('Landscapes', 'Scenery', EPOCH);
assert.equal(command.state.preview.target_exists, true);
await command.applyCategory(stale);
assert.equal(posts.length, 2, 'only the preview on screen can be applied');
command.edit();
command.edit();
assert.equal(command.state.phase, 'idle');
for (const malformed of [preview({source: 'Portraits'}), preview({impact_token: 'short'}),
preview({impact: {...impact, positive_prompts: 1.5}}), preview({library_epoch: OTHER_EPOCH})]) {
const check = createVocabularyCommand({api: {post: async () => malformed}});
await check.previewCategory('Landscapes', 'Scenery', EPOCH);
assert.equal(check.state.phase, 'write-error', JSON.stringify(malformed));
assert.equal(check.state.preview, null);
}
const lost = createVocabularyCommand({api: {post: async () => {throw new TypeError('Failed to fetch');}}});
await lost.previewCategory('Landscapes', 'Scenery', EPOCH);
assert.equal(lost.state.phase, 'write-error', 'a lost preview read wrote nothing');
const conflicted = createVocabularyCommand({api: {post: async () => {throw new ApiError(409, 'gone', 'vocabulary-conflict');}}});
await conflicted.previewCategory('Landscapes', 'Scenery', EPOCH);
assert.equal(conflicted.state.phase, 'conflict');
assert.throws(() => command.previewCategory(3, 'Scenery', EPOCH), TypeError);
});
test('an edit while a preview is pending discards its late result so Apply cannot send the old values', async () => {
const {createVocabularyCommand} = await source;
const posts = [];
const pending = [];
const command = createVocabularyCommand({api: {post: (url, body) => {
posts.push({url, body});
return new Promise((resolve, reject) => pending.push({resolve, reject}));
}}});
const first = command.previewCategory('Landscapes', 'Scenery', EPOCH);
assert.equal(command.state.phase, 'saving');
command.edit();
assert.equal(command.state.phase, 'saving', 'the request is still in flight');
pending.shift().resolve(preview());
await first;
assert.equal(command.state.phase, 'idle', 'the late preview describes values no longer on screen');
assert.equal(command.state.preview, null);
await command.applyCategory(preview());
assert.equal(posts.length, 1, 'nothing is applied from a discarded preview');
// A fresh preview of the edited values is applicable as usual.
const second = command.previewCategory('Landscapes', 'Vistas', EPOCH);
pending.shift().resolve(preview({target: 'Vistas'}));
await second;
assert.equal(command.state.phase, 'previewed');
assert.equal(command.state.preview.target, 'Vistas');
// A discarded preview's plain failure is not shown either; a recovery outcome still is, since it describes storage.
command.edit();
const failed = command.previewCategory('Landscapes', 'Vistas', EPOCH);
command.edit();
pending.shift().reject(new TypeError('Failed to fetch'));
await failed;
assert.equal(command.state.phase, 'idle');
assert.equal(command.state.error, null);
const restored = command.previewCategory('Landscapes', 'Vistas', EPOCH);
command.edit();
pending.shift().reject(new ApiError(409, 'restored', 'storage-changed'));
await restored;
assert.equal(command.state.phase, 'storage-changed');
});
test('a token conflict on apply requires a new preview', async () => {
const {createVocabularyCommand} = await source;
const responses = [() => preview(), () => {throw new ApiError(409, 'changed', 'vocabulary-conflict');}, () => preview()];
const command = createVocabularyCommand({api: {post: async () => responses.shift()()}});
await command.previewCategory('Landscapes', 'Scenery', EPOCH);
await command.applyCategory(command.state.preview);
assert.equal(command.state.phase, 'conflict');
assert.equal(command.state.preview, null);
await command.previewCategory('Landscapes', 'Scenery', EPOCH);
assert.equal(command.state.phase, 'previewed');
});
test('dispose ignores late command results but a landed write still invalidates once', async () => {
const {createVocabularyCommand} = await source;
let release;
const states = [];
const invalidations = [];
const command = createVocabularyCommand({api: {post: () => new Promise(resolve => {release = resolve;})},
onChange: state => states.push(state), onInvalidate: event => invalidations.push(event)});
const pending = command.createTag('sunset', EPOCH);
command.dispose();
const count = states.length;
const receipt = tagReceipt();
release(receipt);
await pending;
assert.equal(states.length, count, 'a disposed dialog is never rendered again');
// The write committed after its dialog went away: cached Library views must still be dropped.
assert.equal(invalidations.length, 1);
assert.equal(invalidations[0].phase, 'done');
assert.equal(invalidations[0].kind, 'create-tag');
assert.deepEqual(invalidations[0].receipt, receipt);
assert.ok(Object.isFrozen(invalidations[0]));
assert.equal(command.state.phase, 'disposed');
await command.createTag('x', EPOCH);
assert.equal(invalidations.length, 1);
});
test('after dispose an unconfirmed write invalidates and a failed write does not', async () => {
const {createVocabularyCommand} = await source;
const cases = [
[new TypeError('Failed to fetch'), 'unknown-outcome', 1],
[new ApiError(409, 'changed', 'vocabulary-conflict'), 'conflict', 1],
[new ApiError(400, 'Tag names cannot contain commas'), null, 0],
];
for (const [error, phase, expected] of cases) {
let fail;
const invalidations = [];
const command = createVocabularyCommand({api: {patch: () => new Promise((resolve, reject) => {fail = reject;})},
onInvalidate: event => invalidations.push(event)});
const pending = command.renameTag({id: 3, name: 'dawn'}, 'dusk', EPOCH);
command.dispose();
fail(error);
await pending;
assert.equal(invalidations.length, expected, error.message);
if (expected) assert.deepEqual({...invalidations[0]}, {phase, kind: 'rename-tag', receipt: null});
assert.equal(command.state.phase, 'disposed');
}
});
test('browseFilters hands Library one exact constraint', async () => {
const {browseFilters} = await source;
assert.deepEqual(browseFilters('tags', 'a'), {text: '', category: '', tags: ['a'], sort: 'newest'});
assert.deepEqual(browseFilters('categories', ' A '), {text: '', category: ' A ', tags: [], sort: 'newest'});
assert.throws(() => browseFilters('images', 'a'), TypeError);
assert.throws(() => browseFilters('tags', 2), TypeError);
assert.throws(() => browseFilters('categories', ''), TypeError);
});
const helpers = imported.then(module => {
// Count wording comes from the shared plural.mjs helper (#243); this module no longer exports its own.
assert.equal(module.noun, undefined);
for (const name of ['readTagImpact', 'categoryPreviewText', 'deleteTagText']) {
assert.equal(typeof module[name], 'function', `vocabulary-state.mjs exports ${name}`);
}
return module;
});
const zero = {positive_prompts: 0, other_prompts: 0, negative_variants: 0, archived_negative_variants: 0};
test('readTagImpact reads one fresh, validated impact for the exact tag id', async () => {
const {readTagImpact} = await helpers;
const calls = [];
const signal = new AbortController().signal;
const api = {get: async (url, options) => {calls.push({url, options}); return {success: true, library_epoch: EPOCH, impact};}};
const value = await readTagImpact(api, {id: 4, name: 'mist'}, {signal});
assert.deepEqual(calls, [{url: '/prompt_manager/vocabulary/tags/4/impact', options: {signal, cache: 'no-store'}}]);
assert.deepEqual(value, {library_epoch: EPOCH, impact});
assert.ok(Object.isFrozen(value) && Object.isFrozen(value.impact));
for (const malformed of [null, {success: false}, {success: true, library_epoch: 'x', impact},
{success: true, library_epoch: EPOCH, impact: {...impact, other_prompts: -1}}, {success: true, library_epoch: EPOCH}]) {
await assert.rejects(readTagImpact({get: async () => malformed}, {id: 4, name: 'mist'}), TypeError, JSON.stringify(malformed));
}
assert.throws(() => readTagImpact(api, {id: 0, name: 'mist'}), TypeError);
});
test('category preview text states the real impact and whether it renames or consolidates', async () => {
const {categoryPreviewText} = await helpers;
assert.equal(categoryPreviewText(preview()),
'“Landscapes” is used by 3 positive prompts, 3 negative variants (1 archived) and 1 other saved prompt. Apply renames it to “Scenery”.');
assert.equal(categoryPreviewText(preview({target: 'Architecture', target_exists: true,
impact: {...zero, positive_prompts: 1, negative_variants: 1}})),
'“Landscapes” is used by 1 positive prompt and 1 negative variant. Apply consolidates it into the existing “Architecture”.');
assert.equal(categoryPreviewText(preview({impact: {...zero, other_prompts: 2}})),
'“Landscapes” is used by 2 other saved prompts. Apply renames it to “Scenery”.');
});
test('delete text names the tag, its real links and what stays unchanged', async () => {
const {deleteTagText} = await helpers;
assert.equal(deleteTagText('mist', impact),
'Deleting “mist” removes the name and its links from 3 positive prompts, 3 negative variants (1 archived) and 1 other saved prompt. The prompts, negative variants, images and history themselves are kept.');
assert.equal(deleteTagText('unused', zero),
'“unused” is not linked to any saved prompt. Deleting it removes only the name.');
});
+10 -29
View File
@@ -244,10 +244,10 @@ class TestPromptInspector(ImageAPITestCase):
async def test_corrupt_malformed_and_no_metadata_do_not_invent_text(self):
path = self.output_dir / "image.png"
for content, expected, message in (
(b"broken", "unresolved", "Metadata in this file could not be read."),
(None, "absent", None),
("bad-json", "unresolved", "Prompt metadata in this file is malformed."),
for content, expected in (
(b"broken", "unresolved"),
(None, "absent"),
("bad-json", "unresolved"),
):
if isinstance(content, bytes):
path.write_bytes(content)
@@ -256,7 +256,6 @@ class TestPromptInspector(ImageAPITestCase):
_, result = await self.inspect()
self.assertEqual(result["prompts"]["positive"]["status"], expected)
self.assertIsNone(result["prompts"]["positive"]["text"])
self.assertEqual(result["prompts"]["negative"].get("message"), message)
async def test_compressed_international_and_post_pixel_metadata(self):
import struct
@@ -774,11 +773,8 @@ class TestPromptInspector(ImageAPITestCase):
self.assertEqual(result["metadata"]["prompt_graph"]["status"], "parsed")
async def test_inspect_route_reports_no_sampler_instead_of_absent(self):
# f17 with a sampler class the inspector does not know: a prompt graph
# is a record, never "not recorded".
built = parity.build("f17")
built["prompt"]["13"]["class_type"] = "FutureSampler"
parity.write_png(self.output_dir / "f17.png", built)
# f17: a BasicGuider-only prompt graph is a record, never "not recorded".
self.parity_fixture("f17")
response, result = await self.inspect("f17.png")
self.assertEqual(response.status, 200)
self.assertEqual(
@@ -789,32 +785,17 @@ class TestPromptInspector(ImageAPITestCase):
self.assertEqual(result["resolution"]["reason_code"], "no-sampler")
self.assertEqual(
result["resolution"]["reason"],
"No supported sampler pair found (saw FutureSampler)",
)
self.assertEqual(
result["prompts"]["positive"]["message"], "No supported sampler was found."
"No supported sampler pair found (saw SamplerCustomAdvanced)",
)
async def test_inspect_route_exposes_reason_code_and_blocking_node(self):
# f06a's encoder is supported since Task 2.4; an unknown encoder is not.
built = parity.build("f06a")
built["prompt"]["6"]["class_type"] = "FutureTextEncode"
parity.write_png(self.output_dir / "f06a.png", built)
self.parity_fixture("f06a")
_, result = await self.inspect("f06a.png")
resolution = result["resolution"]
self.assertEqual(resolution["state"], "unresolved")
self.assertEqual(resolution["reason_code"], "unsupported-node")
self.assertEqual(
resolution["node"], {"id": "6", "class_type": "FutureTextEncode"}
)
self.assertEqual(
{role: side.get("message") for role, side in result["prompts"].items()},
dict.fromkeys(
("positive", "negative"),
"Prompt uses a node the inspector does not support "
"(FutureTextEncode).",
),
)
self.assertEqual(set(resolution["node"]), {"id", "class_type"})
self.assertIsInstance(resolution["node"]["class_type"], str)
png(self.output_dir / "image.png", prompt="{broken")
_, result = await self.inspect()
self.assertEqual(result["resolution"]["reason_code"], "malformed")
-221
View File
@@ -1,221 +0,0 @@
"""Tests for utils.generation_params, the Python port of the browser parser.
Expected values are what web/js/comfy-metadata.js extractGenerationParams
returns for the same input (tests/test_generation_params_js_parity.py checks
that directly when Node is available).
"""
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from utils.generation_params import ( # noqa: E402
PLACEHOLDERS,
GenerationParamsError,
extract_generation_params,
extract_from_records,
parse_js_json,
)
try:
from tests.generation_params_cases import CASES # noqa: E402
except ImportError: # discovered with tests/ as the top-level directory
from generation_params_cases import CASES # noqa: E402
def run(name):
return extract_from_records(CASES[name]["prompt"], CASES[name]["workflow"])
class TracesPrompts(unittest.TestCase):
def test_sampler_prompts_and_settings(self):
self.assertEqual(
run("basic"),
{
"positivePrompt": "a cat on a windowsill",
"negativePrompt": "blurry, watermark",
"checkpoint": "example.safetensors",
"steps": 20,
"cfgScale": 7,
"sampler": "euler",
"seed": 5,
},
)
def test_prompt_manager_joins_prepend_and_append_once(self):
result = run("prompt-manager-prepend-append")
self.assertEqual(result["positivePrompt"], "masterpiece a cat 4k")
# The text already starts with the prepend text, so it is not repeated.
self.assertEqual(result["negativePrompt"], "masterpiece, a dog 4k")
def test_linked_text_joins_text_inputs_with_the_delimiter(self):
result = run("linked-text-concat")
self.assertEqual(result["positivePrompt"], "first part, second part")
self.assertEqual(result["negativePrompt"], PLACEHOLDERS["negativePrompt"])
def test_prompt_manager_pair_outputs(self):
result = run("prompt-manager-pair")
self.assertEqual(result["positivePrompt"], "pair positive")
self.assertEqual(result["negativePrompt"], "pair negative")
def test_conditioning_passthrough_follows_the_role(self):
result = run("conditioning-passthrough")
self.assertEqual(result["positivePrompt"], "through a controlnet")
self.assertEqual(result["negativePrompt"], "negative through it")
def test_several_samplers_sharing_one_prompt_pair(self):
result = run("shared-prompt-pipeline")
self.assertEqual(result["positivePrompt"], "a lighthouse at dusk, oil painting")
self.assertEqual(result["negativePrompt"], "blurry, watermark")
self.assertEqual(
(result["seed"], result["steps"], result["sampler"]), (111, 4, "lcm")
)
def test_overwritten_prompt_manager_text_is_recovered_from_widgets(self):
result = run("overwritten-prompt-manager-text")
self.assertEqual(result["positivePrompt"], "the real positive")
self.assertEqual(result["negativePrompt"], "the real negative")
class ChoosesTheSampler(unittest.TestCase):
def test_first_sampler_follows_javascript_key_order(self):
# Integer-like IDs come first in ascending order, then the others.
result = run("first-sampler-in-js-key-order")
self.assertEqual(result["positivePrompt"], "from sampler five")
self.assertEqual(result["seed"], 55)
def test_custom_sampler_reads_its_guider_and_split_settings(self):
result = run("custom-sampler-cfg-guider")
self.assertEqual(result["positivePrompt"], "a red fox in snow")
self.assertEqual(result["negativePrompt"], "lowres")
self.assertEqual(
(result["seed"], result["steps"], result["cfgScale"], result["sampler"]),
(42, 8, 3.5, "dpmpp_2m"),
)
def test_a_guider_without_prompt_inputs_leaves_prompts_unknown(self):
result = run("custom-sampler-basic-guider")
self.assertEqual(result["positivePrompt"], PLACEHOLDERS["positivePrompt"])
# KSamplerSelect still matches the loose /ksampler/ settings lookup.
self.assertEqual(result["sampler"], "dpmpp_2m")
self.assertEqual(result["seed"], PLACEHOLDERS["seed"])
def test_no_sampler_reads_loose_settings(self):
result = run("no-sampler-loose-settings")
self.assertEqual(result["positivePrompt"], PLACEHOLDERS["positivePrompt"])
self.assertEqual(
(result["seed"], result["steps"], result["cfgScale"], result["sampler"]),
(9, 12, 2, "ddim"),
)
self.assertEqual(result["checkpoint"], "example.safetensors")
def test_linked_seed_prefers_the_input_of_the_same_name(self):
self.assertEqual(run("primitive-seed")["seed"], 77)
class ReadsTheWorkflow(unittest.TestCase):
def test_workflow_only_file(self):
self.assertEqual(
run("workflow-only"),
{
"positivePrompt": "a tidepool at noon",
"negativePrompt": "noisy",
"checkpoint": "example.safetensors",
"steps": 25,
"cfgScale": 6.5,
"sampler": "dpmpp_2m",
"seed": 314,
},
)
class StaysBounded(unittest.TestCase):
def test_cycles_terminate(self):
result = run("conditioning-cycle")
self.assertEqual(result["positivePrompt"], PLACEHOLDERS["positivePrompt"])
def test_long_chains_resolve_within_the_visit_limit(self):
self.assertEqual(
run("chain-within-visit-limit")["positivePrompt"], "deep chain text"
)
def test_chains_beyond_the_visit_limit_stop_without_recursion_errors(self):
result = run("chain-beyond-visit-limit")
self.assertEqual(result["positivePrompt"], PLACEHOLDERS["positivePrompt"])
self.assertEqual(result["negativePrompt"], "deep chain text")
def test_the_visit_limit_boundary_matches_the_browser_parser(self):
# 499 pass-through nodes and the encoder are exactly 500 visits.
self.assertEqual(
run("chain-at-last-visit")["positivePrompt"], "deep chain text"
)
self.assertEqual(
run("chain-one-past-last-visit")["positivePrompt"],
PLACEHOLDERS["positivePrompt"],
)
def test_very_long_chains_do_not_exhaust_the_python_stack(self):
graph = {"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "t"}}}
for index in range(5000):
graph[str(10 + index)] = {
"class_type": "P",
"inputs": {"conditioning": [str(9 + index if index else 1), 0]},
}
graph["2"] = {
"class_type": "KSampler",
"inputs": {
"positive": ["5009", 0],
"negative": ["1", 0],
"model": ["1", 0],
},
}
result = extract_generation_params({"prompt": graph})
self.assertEqual(result["positivePrompt"], PLACEHOLDERS["positivePrompt"])
self.assertEqual(result["negativePrompt"], "t")
class Placeholders(unittest.TestCase):
def test_blank_text_is_a_placeholder_and_zero_seed_is_kept(self):
result = run("blank-text-and-zero-seed")
self.assertEqual(result["positivePrompt"], PLACEHOLDERS["positivePrompt"])
self.assertEqual(result["negativePrompt"], "kept negative")
self.assertEqual(result["seed"], 0)
def test_nothing_recorded(self):
self.assertEqual(run("nothing"), dict(PLACEHOLDERS))
self.assertEqual(run("unparsable-records"), dict(PLACEHOLDERS))
class MalformedInput(unittest.TestCase):
def test_a_null_workflow_node_fails_like_the_browser_parser(self):
with self.assertRaises(GenerationParamsError):
run("null-workflow-node")
class ParsesLikeJavaScript(unittest.TestCase):
def test_rejects_non_standard_numbers(self):
for text in ('{"a": NaN}', '{"a": Infinity}', "[-Infinity]", "not json", ""):
with self.subTest(text=text):
self.assertIsNone(parse_js_json(text))
def test_large_integers_round_like_doubles(self):
self.assertEqual(
parse_js_json('{"seed": 9007199254740993}'), {"seed": 9007199254740992.0}
)
self.assertEqual(
parse_js_json('{"seed": 9007199254740991}'), {"seed": 9007199254740991}
)
def test_seeds_beyond_double_precision_round_like_the_browser(self):
self.assertEqual(
run("seed-beyond-double-precision")["seed"], 4974052037430000001.0
)
def test_duplicate_keys_keep_the_last_value(self):
self.assertEqual(parse_js_json('{"a": 1, "a": 2}'), {"a": 2})
if __name__ == "__main__":
unittest.main()
-637
View File
@@ -1,637 +0,0 @@
"""Randomized differential test: the Python parser equals the browser parser.
Generates seeded random API graphs and UI workflows, from realistic to
malformed, and requires utils.generation_params to return exactly what
web/js/comfy-metadata.js extractGenerationParams returns, or to fail where it
throws. Runs only where Node is available. Set PM_PARAMS_FUZZ_CASES to run more.
"""
import json
import os
import random
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
try:
from tests.test_generation_params_js_parity import (
NODE,
js_equal,
run_oracle,
run_python,
) # noqa: E402
except ImportError: # discovered with tests/ as the top-level directory
from test_generation_params_js_parity import (
NODE,
js_equal,
run_oracle,
run_python,
) # noqa: E402
CASE_COUNT = int(os.environ.get("PM_PARAMS_FUZZ_CASES", "3000"))
SEEDS = (20261020, 7, 424242)
NODE_IDS = [
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"9",
"10",
"12",
"25",
"26",
"100",
"1300:5",
"1300:8",
"x",
"05",
"4294967295",
"-1",
"constructor",
]
CLASS_TYPES = [
"KSampler",
"KSamplerAdvanced",
"SamplerCustomAdvanced",
"CFGGuider",
"BasicGuider",
"CLIPTextEncode",
"PromptManager",
"PromptManagerPair",
"PromptManagerText",
"StringConcatenate",
"PrimitiveString",
"ControlNetApplyAdvanced",
"ConditioningCombine",
"CheckpointLoaderSimple",
"UNETLoader",
"LoraLoader",
"RandomNoise",
"KSamplerSelect",
"BasicScheduler",
"Reroute",
"MyKSamplerThing",
"SaveImage",
"FaceDetailer",
"toString",
"",
]
INPUT_KEYS = [
"text",
"positive",
"negative",
"model",
"conditioning",
"conditioning_1",
"conditioning_to",
"guider",
"sigmas",
"noise",
"sampler",
"seed",
"noise_seed",
"steps",
"cfg",
"sampler_name",
"ckpt_name",
"unet_name",
"model_name",
"prepend_text",
"append_text",
"negative_text",
"delimiter",
"string_a",
"string_b",
"value",
"text_g",
"prompt",
"0",
"10",
"clip",
]
TEXTS = [
"a cat",
" padded text ",
"",
" ",
"bom text ",
"line one\nline two",
"masterpiece",
"masterpiece, a dog",
"4k",
"a dog 4k",
"ünïcödé ✓",
", ",
]
WORKFLOW_TYPES = [
"KSampler",
"KSamplerAdvanced",
"CLIPTextEncode",
"PromptManager",
"PromptManagerPair",
"CheckpointLoaderSimple",
"CheckpointLoader",
"UNETLoader",
"MyUnetLoader",
"Reroute",
"GetNode",
"SetNode",
"Note",
"toString",
"valueOf",
]
class Generator:
def __init__(self, seed):
self.random = random.Random(seed)
def pick(self, items):
return self.random.choice(items)
def chance(self, probability):
return self.random.random() < probability
def number(self):
return self.pick(
[
0,
1,
2,
3,
5,
20,
7,
1.0,
1.5,
6.5,
0.45,
1.5e-7,
2.5e21,
-3,
9007199254740991,
]
)
def link(self):
return [
self.pick(NODE_IDS + [3, 12, 5.0]),
self.pick([0, 1, 2, 3, 1.0, 0, 0, 0, -1]),
]
def value(self, key):
roll = self.random.random()
if (
key
in (
"positive",
"negative",
"model",
"conditioning",
"conditioning_1",
"conditioning_to",
"guider",
"sigmas",
"noise",
"sampler",
"clip",
)
and roll < 0.8
):
return self.link()
if roll < 0.3:
return self.link()
if roll < 0.6:
return self.pick(TEXTS)
if roll < 0.85:
return self.number()
return self.pick(
[True, False, None, [1, 2, 3], {"nested": "x"}, [], "randomize"]
)
def inputs(self):
if self.chance(0.04):
return self.pick([[], ["text"], "text", 3, None, True])
keys = self.random.sample(INPUT_KEYS, self.random.randint(0, 9))
return {key: self.value(key) for key in keys}
def node(self):
if self.chance(0.03):
return self.pick([None, "node", 5, [1, 2]])
node = {"class_type": self.pick(CLASS_TYPES), "inputs": self.inputs()}
if self.chance(0.05):
del node["class_type"]
return node
def graph(self):
ids = self.random.sample(NODE_IDS, self.random.randint(1, 12))
if self.chance(0.03):
return [self.node() for _ in ids]
return {node_id: self.node() for node_id in ids}
def workflow_node(self, node_id):
if self.chance(0.02):
return self.pick([None, 3, "node"])
node = {
"id": node_id,
"type": self.pick(WORKFLOW_TYPES),
"mode": self.pick([0, 0, 0, 2, 4]),
}
inputs = []
for _ in range(self.random.randint(0, 4)):
item = {
"name": self.pick(INPUT_KEYS),
"link": self.pick([None, 1, 2, 3, 4, 5, 6, 7, 8, "2"]),
"type": self.pick(["CONDITIONING", "MODEL", "CLIP", "*", "STRING"]),
}
if self.chance(0.2):
item["widget"] = {"name": self.pick(INPUT_KEYS)}
inputs.append(item)
node["inputs"] = (
inputs if not self.chance(0.03) else self.pick([{"a": 1}, "abc", None])
)
node["widgets_values"] = [
self.value(self.pick(INPUT_KEYS)) for _ in range(self.random.randint(0, 6))
]
if self.chance(0.1):
node["widgets_values"].insert(1, "randomize")
if self.chance(0.05):
node["widgets_values_named"] = {
"text": self.pick(TEXTS),
"seed": self.number(),
}
return node
def workflow(self):
if self.chance(0.03):
return self.pick([[], "workflow", {"nodes": "x"}, {"links": []}])
ids = self.random.sample(
[1, 2, 3, 4, 5, 6, 7, 8, "9", 10.0], self.random.randint(1, 8)
)
links = []
for link_id in range(1, self.random.randint(1, 9)):
origin, slot = self.pick(ids), self.pick([0, 1, 2])
if self.chance(0.5):
links.append(
[
link_id,
origin,
slot,
self.pick(ids),
0,
self.pick(["CONDITIONING", "MODEL", "*", "STRING"]),
]
)
else:
links.append(
{
"id": link_id,
"origin_id": origin,
"origin_slot": slot,
"type": self.pick(["CONDITIONING", "MODEL", "*"]),
}
)
return {
"nodes": [self.workflow_node(node_id) for node_id in ids],
"links": links,
}
# --- coherent pipelines, then corrupted ------------------------------------
def text_source(self, graph, ids, depth=0):
"""A node id producing text: an encoder, PromptManager, pair or a text chain."""
node_id = ids.pop()
kind = self.pick(
["encoder", "manager", "pair", "concat", "primitive"]
if depth < 2
else ["encoder", "manager"]
)
if kind == "encoder":
text = (
self.pick(TEXTS)
if self.chance(0.7)
else [self.text_source(graph, ids, depth + 1), 0]
)
graph[node_id] = {
"class_type": "CLIPTextEncode",
"inputs": {"text": text, "clip": ["4", 1]},
}
elif kind == "manager":
graph[node_id] = {
"class_type": self.pick(["PromptManager", "PromptManagerText"]),
"inputs": {
"text": self.pick(TEXTS),
"prepend_text": self.pick(TEXTS + ["masterpiece"]),
"append_text": self.pick(TEXTS + ["4k"]),
"clip": ["4", 1],
},
}
elif kind == "pair":
graph[node_id] = {
"class_type": "PromptManagerPair",
"inputs": {
"text": self.pick(TEXTS),
"negative_text": self.pick(TEXTS),
"prepend_text": "",
"append_text": "",
},
}
elif kind == "concat":
graph[node_id] = {
"class_type": "StringConcatenate",
"inputs": {
"string_a": (
[self.text_source(graph, ids, depth + 1), 0] if ids else "x"
),
"string_b": self.pick(TEXTS),
"delimiter": self.pick([", ", " ", 3, "|"]),
},
}
else:
graph[node_id] = {
"class_type": "PrimitiveString",
"inputs": {"value": self.pick(TEXTS)},
}
return node_id
def conditioning(self, graph, ids, role):
source = self.text_source(graph, ids)
slot = (
self.pick([0, 1, 2, 3])
if graph[source]["class_type"] == "PromptManagerPair"
else 0
)
link = [source, slot]
for _ in range(self.pick([0, 0, 1, 2, 3])):
if not ids:
break
node_id = ids.pop()
key = self.pick([role, "conditioning", "conditioning_to", "conditioning_1"])
graph[node_id] = {
"class_type": self.pick(
[
"ControlNetApplyAdvanced",
"ConditioningCombine",
"ConditioningSetArea",
]
),
"inputs": {key: link, "strength": 1.0},
}
link = [node_id, 0]
return link
def pipeline(self):
ids = [str(i) for i in self.random.sample(range(1, 400), 120)] + [
"1300:7",
"x",
"constructor",
]
self.random.shuffle(ids)
graph = {
"4": {
"class_type": self.pick(["CheckpointLoaderSimple", "UNETLoader"]),
"inputs": {
self.pick(
["ckpt_name", "unet_name", "gguf_name"]
): "model.safetensors"
},
}
}
for _ in range(self.pick([1, 1, 2, 3])):
positive, negative = self.conditioning(
graph, ids, "positive"
), self.conditioning(graph, ids, "negative")
settings = {
"seed": self.number() if self.chance(0.8) else [ids.pop(), 0],
"steps": self.number(),
"cfg": self.number(),
"sampler_name": self.pick(["euler", "lcm", "dpmpp_2m"]),
}
if settings["seed"] and isinstance(settings["seed"], list):
graph[settings["seed"][0]] = {
"class_type": "Seed",
"inputs": {"seed": self.number(), "other": "x"},
}
if self.chance(0.25):
guider = ids.pop()
graph[guider] = {
"class_type": "CFGGuider",
"inputs": {
"model": ["4", 0],
"positive": positive,
"negative": negative,
"cfg": self.number(),
},
}
noise, chooser, sigmas = ids.pop(), ids.pop(), ids.pop()
graph[noise] = {
"class_type": "RandomNoise",
"inputs": {"noise_seed": self.number()},
}
graph[chooser] = {
"class_type": "KSamplerSelect",
"inputs": {"sampler_name": "euler"},
}
graph[sigmas] = {
"class_type": "BasicScheduler",
"inputs": {"steps": self.number()},
}
graph[ids.pop()] = {
"class_type": "SamplerCustomAdvanced",
"inputs": {
"noise": [noise, 0],
"guider": [guider, 0],
"sampler": [chooser, 0],
"sigmas": [sigmas, 0],
},
}
else:
graph[ids.pop()] = {
"class_type": self.pick(
["KSampler", "KSamplerAdvanced", "FaceDetailer"]
),
"inputs": {
"model": ["4", 0],
"positive": positive,
"negative": negative,
**settings,
},
}
for _ in range(self.pick([0, 0, 1, 3])): # corruption
node_id = self.pick(list(graph))
node = graph[node_id]
if (
self.chance(0.5)
and isinstance(node, dict)
and isinstance(node.get("inputs"), dict)
and node["inputs"]
):
key = self.pick(list(graph[node_id]["inputs"]))
graph[node_id]["inputs"][key] = self.value(key)
else:
graph[node_id] = self.node()
return graph
def pipeline_workflow(self):
"""A connected workflow: loader, encoders, Reroute or Set/Get hops, sampler."""
nodes, links = [], []
def link(origin, slot, kind):
links.append(
[len(links) + 1, origin, slot, 0, 0, kind]
if self.chance(0.6)
else {
"id": len(links) + 1,
"origin_id": origin,
"origin_slot": slot,
"type": kind,
}
)
return len(links)
nodes.append(
{
"id": 4,
"type": self.pick(
["CheckpointLoaderSimple", "UNETLoader", "MyUnetLoader"]
),
"mode": 0,
"inputs": [],
"widgets_values": ["model.safetensors"],
}
)
refs = []
for index, role in enumerate(("positive", "negative")):
encoder = 10 + index
nodes.append(
{
"id": encoder,
"type": self.pick(
["CLIPTextEncode", "PromptManager", "PromptManagerText"]
),
"mode": self.pick([0, 0, 0, 4]),
"inputs": [
{"name": "clip", "link": link(4, 1, "CLIP"), "type": "CLIP"}
],
"widgets_values": [
self.pick(TEXTS),
"",
"",
"",
self.pick(TEXTS),
self.pick(TEXTS),
],
}
)
ref = link(encoder, 0, "CONDITIONING")
hop = self.pick(["none", "none", "reroute", "setget"])
if hop == "reroute":
nodes.append(
{
"id": 20 + index,
"type": "Reroute",
"mode": 0,
"inputs": [{"name": "", "link": ref}],
}
)
ref = link(20 + index, 0, "*")
elif hop == "setget":
name = self.pick(["cond_" + role, "shared"])
nodes.append(
{
"id": 30 + index,
"type": "SetNode",
"mode": 0,
"inputs": [{"name": "c", "link": ref}],
"widgets_values": [name],
}
)
nodes.append(
{
"id": 40 + index,
"type": "GetNode",
"mode": 0,
"inputs": [],
"widgets_values": [name],
}
)
ref = link(40 + index, 0, "CONDITIONING")
refs.append(ref)
widgets = [
self.number(),
self.pick(["randomize", "fixed", "x"]),
self.number(),
self.number(),
"euler",
"normal",
1,
]
nodes.append(
{
"id": 3,
"type": self.pick(["KSampler", "KSamplerAdvanced"]),
"mode": 0,
"inputs": [
{"name": "model", "link": link(4, 0, "MODEL"), "type": "MODEL"},
{"name": "positive", "link": refs[0], "type": "CONDITIONING"},
{"name": "negative", "link": refs[1], "type": "CONDITIONING"},
],
"widgets_values": widgets,
}
)
if self.chance(0.3):
nodes[self.random.randrange(len(nodes))] = self.workflow_node(
self.pick([1, 2, 99])
)
return {"nodes": nodes, "links": links}
def case(self):
roll = self.random.random()
structured = self.chance(0.7)
prompt = (
json.dumps(self.pipeline() if structured else self.graph())
if roll < 0.75
else None
)
workflow = (
json.dumps(self.pipeline_workflow() if structured else self.workflow())
if roll > 0.45
else None
)
if self.chance(0.02):
prompt = self.pick(['{"1": NaN}', "{", "null", "[]", '"text"', "12"])
return {"prompt": prompt, "workflow": workflow}
@unittest.skipUnless(NODE, "Node is required to run the browser parser")
class RandomGraphs(unittest.TestCase):
def test_random_graphs_match_the_browser_parser(self):
for seed in SEEDS:
generator = Generator(seed)
cases = [generator.case() for _ in range(CASE_COUNT)]
expected = run_oracle(cases)
self.assertEqual(len(expected), len(cases))
mismatches = []
for index, (case, result) in enumerate(zip(cases, expected)):
actual = run_python(case)
if not js_equal(actual, result):
mismatches.append((index, case, actual, result))
with self.subTest(seed=seed):
outcomes = {
"ok": sum(r["ok"] for r in expected),
"throws": sum(not r["ok"] for r in expected),
}
self.assertEqual(
mismatches[:3],
[],
f"{len(mismatches)} of {len(cases)} differ; outcomes {outcomes}",
)
if __name__ == "__main__":
unittest.main()
-155
View File
@@ -1,155 +0,0 @@
"""The Python generation-parameter parser equals the browser parser.
Golden vectors (tests/fixtures/generation_params/vectors.json) hold the browser
parser's results for every named case and every PNG fixture, so this runs on
machines without Node. With Node, the live browser parser is compared too and
the vectors are checked for staleness. Regenerate them with:
python tests/test_generation_params_js_parity.py --write-vectors
"""
import json
import math
import shutil
import subprocess # nosec B404 - runs the repository's own Node oracle
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from utils.generation_params import ( # noqa: E402
GenerationParamsError,
extract_from_records,
)
try:
from tests.generation_params_cases import CASES # noqa: E402
except ImportError: # discovered with tests/ as the top-level directory
from generation_params_cases import CASES # noqa: E402
VECTORS = ROOT / "tests" / "fixtures" / "generation_params" / "vectors.json"
ORACLE = ROOT / "tests" / "js" / "support" / "generation-params-oracle.mjs"
FIXTURE_DIRS = [ROOT / "tests" / "fixtures"]
NODE = shutil.which("node")
def fixture_cases():
"""Text records of every PNG fixture, keyed by its repository path."""
from PIL import Image
cases = {}
for directory in FIXTURE_DIRS:
for path in sorted(directory.rglob("*.png")):
with Image.open(path) as image:
info = {k: v for k, v in image.info.items() if isinstance(v, str)}
if "prompt" in info or "workflow" in info:
name = path.relative_to(ROOT).as_posix()
cases[name] = {
"prompt": info.get("prompt"),
"workflow": info.get("workflow"),
}
return cases
def all_cases():
return {**{f"case:{k}": v for k, v in CASES.items()}, **fixture_cases()}
def run_oracle(cases):
"""Browser parser results for case records, in order."""
lines = "".join(json.dumps(case) + "\n" for case in cases)
completed = subprocess.run( # nosec B603 - fixed argv, no shell
[NODE, str(ORACLE)],
input=lines,
capture_output=True,
text=True,
encoding="utf-8",
check=True,
timeout=600,
)
return [json.loads(line) for line in completed.stdout.splitlines()]
def run_python(case):
try:
return {
"ok": True,
"result": extract_from_records(case["prompt"], case["workflow"]),
}
except GenerationParamsError:
return {"ok": False}
def js_equal(left, right):
"""Equality as JavaScript values: 1 == 1.0, but true is not 1."""
if isinstance(left, bool) or isinstance(right, bool):
return isinstance(left, bool) and isinstance(right, bool) and left == right
if isinstance(left, (int, float)) and isinstance(right, (int, float)):
# Every JavaScript number is a double; compare as doubles.
left, right = float(left), float(right)
return left == right or (math.isnan(left) and math.isnan(right))
if isinstance(left, list) and isinstance(right, list):
return len(left) == len(right) and all(
js_equal(a, b) for a, b in zip(left, right)
)
if isinstance(left, dict) and isinstance(right, dict):
return left.keys() == right.keys() and all(
js_equal(left[k], right[k]) for k in left
)
return type(left) is type(right) and left == right
def write_vectors():
cases = all_cases()
results = run_oracle(cases.values())
vectors = [
{"name": name, **case, "expected": result}
for (name, case), result in zip(cases.items(), results)
]
VECTORS.parent.mkdir(parents=True, exist_ok=True)
VECTORS.write_text(json.dumps(vectors, indent=1, sort_keys=True) + "\n", "utf-8")
print(f"wrote {len(vectors)} vectors to {VECTORS.relative_to(ROOT)}")
class GoldenVectors(unittest.TestCase):
def test_every_vector_matches(self):
vectors = json.loads(VECTORS.read_text("utf-8"))
self.assertGreater(len(vectors), len(CASES))
for vector in vectors:
with self.subTest(vector=vector["name"]):
actual = run_python(vector)
self.assertTrue(
js_equal(actual, vector["expected"]), (actual, vector["expected"])
)
def test_vectors_cover_every_case_and_fixture(self):
names = {vector["name"] for vector in json.loads(VECTORS.read_text("utf-8"))}
self.assertEqual(names, set(all_cases()))
@unittest.skipUnless(NODE, "Node is required to run the browser parser")
class LiveBrowserParser(unittest.TestCase):
def test_live_results_match_python_and_the_vectors(self):
cases = all_cases()
stored = {
vector["name"]: vector["expected"]
for vector in json.loads(VECTORS.read_text("utf-8"))
}
for (name, case), expected in zip(cases.items(), run_oracle(cases.values())):
with self.subTest(case=name):
self.assertTrue(
js_equal(run_python(case), expected), (run_python(case), expected)
)
self.assertTrue(
js_equal(stored[name], expected),
"vectors are stale: regenerate them",
)
if __name__ == "__main__":
if "--write-vectors" in sys.argv:
write_vectors()
else:
unittest.main()
-40
View File
@@ -449,46 +449,6 @@ class CurrentLocationTests(unittest.TestCase):
)
self.assertEqual(reads, [], "Unsupported media must not reach the parser")
def test_preview_files_in_the_host_temp_folder_keep_preview_only_stages(self):
# Saved raw image plus a FaceDetailer that only previews (Codex P2).
import json
from PIL import Image
from PIL.PngImagePlugin import PngInfo
from tests import inspection_parity as parity
service = self.module("py.api.inspection_service")
graph = parity.build("f19")["prompt"]
graph["9"]["class_type"] = "PreviewImage"
graph["14"] = {"class_type": "SaveImage", "inputs": {"images": ["8", 0]}}
info = PngInfo()
info.add_text("prompt", json.dumps(graph))
observed = {}
for name, folder in (("output", self.output), ("temp", self.temp)):
path = folder / "nested" / "same.png"
path.parent.mkdir()
with Image.new("RGB", (4, 4)) as image:
image.save(path, pnginfo=info)
observed[name] = service.inspect_current_file(
path, source_label=name, saved=None, library={"status": "none"}
)
saved = observed["output"]["prompts"]
self.assertEqual(saved["positive"]["status"], "resolved")
self.assertEqual(saved["positive"]["text"], parity.POS)
self.assertEqual(observed["output"]["resolution"]["sampler_id"], "3")
preview = observed["temp"]["prompts"]
self.assertEqual(preview["positive"]["status"], "ambiguous")
self.assertIsNone(preview["positive"]["text"])
# An unknown host temp folder treats the file as saved output.
self.host_temp = None
again = service.inspect_current_file(
self.output / "nested" / "same.png",
source_label="output",
saved=None,
library={"status": "none"},
)
self.assertEqual(again["prompts"]["positive"]["status"], "resolved")
class HistoryImportTests(unittest.TestCase):
def test_api_import_does_not_require_a_running_host_server(self):
+3 -11
View File
@@ -8,7 +8,6 @@ leaves it unresolved, while the inspection profile resolves it.
import logging
import unittest
from pathlib import Path
from unittest.mock import Mock, patch
from utils import prompt_pair_resolver as resolver
@@ -73,19 +72,12 @@ class TestIngestionKeepsDefaultProfile(unittest.TestCase):
mixin = admin.AdminRoutesMixin.__new__(admin.AdminRoutesMixin)
mixin.db = Mock()
mixin.db.get_prompt_by_hash.return_value = None
mixin.db.save_prompt.return_value = 7
spy = _Spy()
with patch.object(admin, "resolve_prompt_pair", spy):
status = mixin._ingest_scanned_file(Path("/out/f03.png"), _f03_metadata())
status = mixin._ingest_scanned_file("/out/f03.png", _f03_metadata())
self.assertEqual(status, "unresolved")
spy.assert_default_and_gated(self)
# The resolver leaves f03 unresolved; the scan links the prompt the
# browser parser traces, as the inspector shows it.
self.assertEqual(status, "added")
self.assertEqual(
mixin.db.save_prompt.call_args.args[0], "a red fox in snow, detailed fur"
)
mixin.db.link_image_to_prompt.assert_called_once()
mixin.db.assert_not_called()
if __name__ == "__main__":
-943
View File
@@ -1,943 +0,0 @@
"""Legacy parser rules ported into the inspection resolver (#240, Task 2.7b).
Every graph here is synthetic (neutral texts, generic names). Each class names
the rule from the parser reconciliation (R1, R3, ...) it ports. The default
(ingestion) profile must stay exactly as it was, so each rule also checks that
``DEFAULT_PROFILE`` ignores it.
"""
import io
import json
import tempfile
import unittest
import unittest.mock
import zlib
from pathlib import Path
from PIL import Image
from utils import prompt_pair_resolver as resolver
from utils.prompt_inspector import (
DUPLICATE_ROLE_BYTES,
MetadataReadError,
inspect_prompt_evidence,
read_inspection_file,
)
from utils.prompt_pair_resolver import resolve_prompt_pair
try:
from tests import inspection_parity as parity
except ImportError: # discovered with ``-s tests``
import inspection_parity as parity
def _inspect(metadata: dict, **options) -> dict:
return resolve_prompt_pair(metadata, profile=resolver.INSPECTION_PROFILE, **options)
def _texts(result: dict) -> tuple:
return tuple(
result[role]["text"] if isinstance(result[role], dict) else None
for role in ("positive", "negative")
)
def _pm_encoder(text: str) -> dict:
"""A PromptManager node whose API class was rewritten to CLIPTextEncode."""
return {
"class_type": "CLIPTextEncode",
"inputs": {
"text": text,
"category": "",
"tags": "",
"search_text": "",
"clip": ["4", 1],
},
}
def _overwritten(widgets=("a cat", "a dog", "a bird"), api_text="a bird") -> dict:
"""Pre-3.2.3 shape: three PromptManager nodes, every API ``text`` overwritten.
KSampler 3 reads node 17 (widget A) for its positive; the saved API text of
17, 113 and 119 is the last-run node's prompt (C) for all three.
"""
prompt = parity.build("f02")["prompt"]
prompt["3"]["inputs"]["positive"] = ["17", 0]
del prompt["6"]
for node_id in ("17", "113", "119"):
prompt[node_id] = _pm_encoder(api_text)
workflow = {
"nodes": [
{"id": int(node_id), "type": "PromptManager", "widgets_values": [widget]}
for node_id, widget in zip(("17", "113", "119"), widgets)
]
+ [{"id": 7, "type": "CLIPTextEncode", "widgets_values": [parity.NEG]}],
"links": [],
}
return {"prompt": prompt, "workflow": workflow}
class TestOverwrittenPromptManagerText(unittest.TestCase):
"""R1 (comfy-metadata.js:52-86): never resolve to the overwritten text."""
def test_overwritten_text_is_refused_not_resolved(self):
result = _inspect(_overwritten())
self.assertEqual(result["state"], "unresolved")
self.assertEqual(result["reason_code"], "malformed")
self.assertEqual(result["reason"], resolver.OVERWRITTEN_PROMPT_REASON)
self.assertIsNone(result["positive"])
def test_the_inspector_passes_the_workflow_to_the_resolver(self):
metadata = _overwritten()
evidence = inspect_prompt_evidence(
{
"prompt": json.dumps(metadata["prompt"]),
"workflow": json.dumps(metadata["workflow"]),
}
)
self.assertEqual(evidence["resolution"]["reason_code"], "malformed")
self.assertEqual(evidence["prompts"]["positive"]["status"], "unresolved")
self.assertNotEqual(evidence["prompts"]["positive"]["text"], "a bird")
self.assertEqual(
evidence["prompts"]["positive"]["message"],
"Prompt metadata in this file is malformed.",
)
def test_untouched_shapes_still_resolve(self):
cases = {
# One PromptManager node, or several whose widgets agree.
"single": ("a bird",),
"agreeing widgets": ("a bird", "a bird", "a bird"),
}
for label, widgets in cases.items():
with self.subTest(label):
metadata = _overwritten()
metadata["workflow"]["nodes"] = [
node
for node in metadata["workflow"]["nodes"]
if node["type"] != "PromptManager"
] + [
{"id": node_id, "type": "PromptManager", "widgets_values": [text]}
for node_id, text in zip((17, 113, 119), widgets)
]
self.assertEqual(_inspect(metadata)["state"], "resolved")
# Distinct API texts are what ran, whatever the widgets say.
metadata = _overwritten()
metadata["prompt"]["113"]["inputs"]["text"] = "a dog"
metadata["prompt"]["119"]["inputs"]["text"] = "a cat"
metadata["prompt"]["17"]["inputs"]["text"] = "a fish"
self.assertEqual(_texts(_inspect(metadata)), ("a fish", parity.NEG))
# A PromptManagerPair widget is its positive and negative: not the signature.
metadata = _overwritten()
for node in metadata["workflow"]["nodes"]:
node["type"] = node["type"].replace("PromptManager", "PromptManagerPair")
self.assertEqual(_inspect(metadata)["state"], "resolved")
# No workflow, or a malformed one, cannot show the signature.
orphans = {
"nodes": [{"id": 5, "type": "PromptManager", "widgets_values": ["a"]}]
}
for workflow in (None, "nodes", {"nodes": "x"}, {"nodes": [1, None]}, orphans):
with self.subTest(workflow=workflow):
metadata = _overwritten()
metadata["workflow"] = workflow
self.assertEqual(_inspect(metadata)["state"], "resolved")
def test_an_untouched_branch_beside_overwritten_nodes_resolves(self):
# Only the overwritten nodes are refused: the negative is not one.
metadata = _overwritten()
metadata["prompt"]["3"]["inputs"]["positive"] = ["7", 0]
self.assertEqual(_texts(_inspect(metadata)), (parity.NEG, parity.NEG))
def test_overwritten_nodes_inside_subgraphs_are_refused(self):
# Review M-1: subgraph nodes run as "<instance>:<inner>" API IDs.
def nested(depth: int = 1) -> dict:
metadata = _overwritten()
prompt, workflow = metadata["prompt"], metadata["workflow"]
prefix = ":".join(["50", "60", "70"][:depth]) + ":"
for node_id in ("17", "113", "119"):
prompt[prefix + node_id] = prompt.pop(node_id)
prompt["3"]["inputs"]["positive"] = [prefix + "17", 0]
inner = [n for n in workflow["nodes"] if n["type"] == "PromptManager"]
outer = [n for n in workflow["nodes"] if n["type"] != "PromptManager"]
subgraphs = []
for level in range(depth, 0, -1):
nodes = (
inner
if level == depth
else [{"id": [50, 60, 70][level], "type": f"sub-{level + 1}"}]
)
subgraphs.append({"id": f"sub-{level}", "nodes": nodes})
metadata["workflow"] = {
"nodes": outer + [{"id": 50, "type": "sub-1", "widgets_values": []}],
"links": [],
"definitions": {"subgraphs": subgraphs},
}
return metadata
for depth in (1, 2, 3):
with self.subTest(depth=depth):
for anchor in (None, "9"):
result = _inspect(nested(depth), output_node_id=anchor)
self.assertEqual(
result["reason"], resolver.OVERWRITTEN_PROMPT_REASON, anchor
)
# A subgraph definition with no instance runs nothing.
metadata = nested()
metadata["workflow"]["nodes"] = metadata["workflow"]["nodes"][:-1]
self.assertEqual(_inspect(metadata)["state"], "resolved")
# Malformed definitions never raise.
for definitions in ("x", {"subgraphs": "x"}, {"subgraphs": [1, {"id": 3}]}):
metadata = nested()
metadata["workflow"]["definitions"] = definitions
self.assertEqual(_inspect(metadata)["state"], "resolved", definitions)
# A self-referencing subgraph is bounded.
metadata = nested()
metadata["workflow"]["definitions"]["subgraphs"].append(
{"id": "loop", "nodes": [{"id": 1, "type": "loop"}]}
)
metadata["workflow"]["nodes"].append({"id": 99, "type": "loop"})
self.assertEqual(
_inspect(metadata)["reason"], resolver.OVERWRITTEN_PROMPT_REASON
)
def test_object_widget_values_name_the_text(self):
metadata = _overwritten()
for node in metadata["workflow"]["nodes"]:
node["widgets_values"] = {"text": node["widgets_values"][0]}
self.assertEqual(
_inspect(metadata)["reason"], resolver.OVERWRITTEN_PROMPT_REASON
)
def test_the_workflow_is_parsed_only_for_prompt_manager_graphs(self):
# Review M-4: no PromptManager-like node, no workflow parse.
from utils import prompt_inspector
prompt = parity.build("f02")["prompt"]
with unittest.mock.patch.object(
prompt_inspector,
"_parsed_workflow",
wraps=prompt_inspector._parsed_workflow,
) as parsed:
inspect_prompt_evidence(
{"prompt": json.dumps(prompt), "workflow": json.dumps({"nodes": []})}
)
self.assertEqual(parsed.call_count, 0)
metadata = _overwritten()
inspect_prompt_evidence(
{
"prompt": json.dumps(metadata["prompt"]),
"workflow": json.dumps(metadata["workflow"]),
}
)
self.assertEqual(parsed.call_count, 1)
def test_default_profile_ignores_the_workflow(self):
result = resolve_prompt_pair(_overwritten())
self.assertEqual(result["state"], "resolved")
self.assertEqual(_texts(result), ("a bird", parity.NEG))
def _historical(class_type: str, slot: int, inputs: dict, kind: str) -> dict:
"""``f02`` whose positive reads ``class_type`` output ``slot`` (node 10).
A conditioning output feeds the sampler directly; a string output feeds a
CLIPTextEncode (node 6).
"""
prompt = parity.build("f02")["prompt"]
prompt["10"] = {"class_type": class_type, "inputs": {"clip": ["4", 1], **inputs}}
if kind == "conditioning":
prompt["3"]["inputs"]["positive"] = ["10", slot]
else:
prompt["6"]["inputs"]["text"] = ["10", slot]
return {"prompt": prompt}
class TestHistoricalPromptManagerNodes(unittest.TestCase):
"""R7: this repository's node classes up to 07a5af3, with their own join."""
COMPOSED = {"text": " a cat ", "prepend_text": " trig ", "append_text": "sky "}
def test_v2_outputs_and_comma_join(self):
inputs = {**self.COMPOSED, "negative_text": " bad hands "}
for slot, kind, expected in (
(0, "conditioning", "trig, a cat, sky"),
(1, "conditioning", "bad hands"),
(2, "string", "trig, a cat, sky"),
(3, "string", "bad hands"),
):
with self.subTest(slot=slot):
result = _inspect(_historical("PromptManagerV2", slot, inputs, kind))
self.assertEqual(result["state"], "resolved")
self.assertEqual(result["positive"]["text"], expected)
def test_comma_rule_matches_the_historical_code(self):
cases = (
({"text": "a cat", "prepend_text": "trig,"}, "trig,a cat"),
({"text": "a cat,", "append_text": "sky"}, "a cat,sky"),
({"text": "", "prepend_text": "trig", "append_text": "sky"}, "trig, sky"),
({"text": " ", "prepend_text": " ", "append_text": "sky"}, "sky"),
({"text": "a cat"}, "a cat"),
# Review M-5: an explicit null part is skipped, as ``if prepend_text:``.
({"text": "a cat", "prepend_text": None, "append_text": None}, "a cat"),
)
for inputs, expected in cases:
with self.subTest(inputs=inputs):
result = _inspect(_historical("PromptManagerV2", 2, inputs, "string"))
self.assertEqual(result["positive"]["text"], expected)
def test_single_role_classes(self):
cases = (
("PromptManagerPositive", 0, "conditioning", "trig, a cat, sky"),
("PromptManagerPositive", 1, "string", "trig, a cat, sky"),
("PromptManagerDetailer", 0, "conditioning", "trig, a cat, sky"),
("PromptManagerDetailer", 1, "string", "trig, a cat, sky"),
("PromptManagerV2Text", 0, "string", "trig, a cat, sky"),
("PromptManagerPositiveText", 0, "string", "trig, a cat, sky"),
("PromptManagerNegativeText", 0, "string", "trig, a cat, sky"),
# The negative-only encoder never composes.
("PromptManagerNegative", 0, "conditioning", "a cat"),
("PromptManagerNegative", 1, "string", "a cat"),
)
for class_type, slot, kind, expected in cases:
with self.subTest(node=class_type, slot=slot):
result = _inspect(_historical(class_type, slot, self.COMPOSED, kind))
self.assertEqual(result["state"], "resolved")
self.assertEqual(result["positive"]["text"], expected)
result = _inspect(
_historical(
"PromptManagerV2Text",
1,
{"text": "a cat", "negative_text": " bad "},
"string",
)
)
self.assertEqual(result["positive"]["text"], "bad")
def test_optional_negative_defaults_to_empty(self):
result = _inspect(
_historical("PromptManagerV2", 0, {"text": "a cat"}, "conditioning")
)
self.assertEqual(result["positive"]["text"], "a cat")
prompt = _historical("PromptManagerV2", 0, {"text": "a cat"}, "conditioning")
prompt["prompt"]["3"]["inputs"]["negative"] = ["10", 1]
self.assertEqual(_texts(_inspect(prompt)), ("a cat", ""))
def test_unlisted_outputs_and_kinds_are_not_guessed(self):
# PromptManagerDetailer output 2 is an INT prompt id.
result = _inspect(
_historical("PromptManagerDetailer", 2, self.COMPOSED, "string")
)
self.assertEqual(result["reason_code"], "unsupported-node")
# A STRING output read as conditioning is a malformed link.
result = _inspect(
_historical("PromptManagerV2", 2, self.COMPOSED, "conditioning")
)
self.assertEqual(result["reason_code"], "malformed")
def test_linked_parts_are_traced(self):
source = _historical(
"PromptManagerV2",
0,
{"text": ["11", 0], "prepend_text": ["12", 0]},
"conditioning",
)
source["prompt"]["11"] = {
"class_type": "PrimitiveString",
"inputs": {"value": "a cat"},
}
source["prompt"]["12"] = {
"class_type": "PrimitiveString",
"inputs": {"value": "trig"},
}
result = _inspect(source)
self.assertEqual(result["positive"]["text"], "trig, a cat")
self.assertEqual(
[step["node_id"] for step in result["positive"]["provenance"]["path"]],
["10", "12", "11"],
)
def test_overwritten_historical_nodes_are_still_refused(self):
metadata = _overwritten()
for node_id in ("17", "113", "119"):
metadata["prompt"][node_id]["class_type"] = "PromptManagerV2"
for node in metadata["workflow"]["nodes"]:
if node["type"] == "PromptManager":
node["type"] = "PromptManagerV2"
self.assertEqual(_inspect(metadata)["reason_code"], "malformed")
def test_default_profile_keeps_them_unsupported(self):
result = resolve_prompt_pair(
_historical("PromptManagerV2", 0, self.COMPOSED, "conditioning")
)
self.assertEqual(result["state"], "unresolved")
self.assertEqual(result["reason"], "Unsupported node semantics or output slot")
FOOTER = "Steps: 20, Sampler: Euler, CFG scale: 7, Seed: 5"
def _parameters(text: str, *, legacy: bool = False) -> dict:
profile = resolver.DEFAULT_PROFILE if legacy else resolver.INSPECTION_PROFILE
return resolve_prompt_pair({"parameters": text}, profile=profile)
class TestA1111Text(unittest.TestCase):
"""R4 and R5 (#165, py/api/__init__.py:738-750) for inspection."""
def test_text_starting_with_the_negative_delimiter_has_no_positive(self):
for text in (
f"Negative prompt: bad\n{FOOTER}",
f"\nNegative prompt: bad\n{FOOTER}",
):
with self.subTest(text=text):
result = _parameters(text)
self.assertEqual(result["state"], "unresolved")
self.assertEqual(result["reason"], resolver.EMPTY_POSITIVE_REASON)
self.assertIsNone(result["positive"])
# Two delimiters, one at the start, are still two.
result = _parameters(f"Negative prompt: a\nNegative prompt: b\n{FOOTER}")
self.assertEqual(result["state"], "ambiguous")
def test_dynamic_prompts_template_lines_follow_the_footer(self):
base = f"a cat\nNegative prompt: bad\n{FOOTER}"
for trailer in (
"\nTemplate: a {cat|dog}",
"\nTemplate: a {cat|dog}\nNegative Template: bad",
"\nNegative Template: bad",
"\nTemplate: a {cat|dog}\nNegative Template: bad\n",
):
with self.subTest(trailer=trailer):
result = _parameters(base + trailer)
self.assertEqual(result["state"], "resolved")
self.assertEqual(_texts(result), ("a cat", "bad"))
for trailer in (
"\nTemplate: a\nTemplate: b",
"\nNegative Template: bad\nTemplate: a",
"\nTemplate: a\nmore template text",
"\ntrailing",
):
with self.subTest(trailer=trailer):
result = _parameters(base + trailer)
self.assertEqual(result["reason_code"], "malformed")
def test_footer_with_only_steps(self):
result = _parameters("a cat\nNegative prompt: bad\nSteps: 20")
self.assertEqual(_texts(result), ("a cat", "bad"))
result = _parameters("a cat\nSteps: 20\nTemplate: a {cat|dog}")
self.assertEqual((result["state"], _texts(result)[0]), ("absent", "a cat"))
# A full footer wins over a bare ``Steps: N`` line in the prompt text.
result = _parameters(f"a cat\nSteps: 3\nNegative prompt: bad\n{FOOTER}")
self.assertEqual(_texts(result), ("a cat\nSteps: 3", "bad"))
result = _parameters("a cat\nSteps: 3\nNegative prompt: bad\nSteps: 20")
self.assertEqual(result["reason_code"], "malformed")
def test_default_profile_keeps_the_historical_split(self):
result = _parameters(f"Negative prompt: bad\n{FOOTER}", legacy=True)
self.assertEqual(_texts(result), ("Negative prompt: bad", None))
for text in (
f"a cat\nNegative prompt: bad\n{FOOTER}\nTemplate: a {{cat|dog}}",
"a cat\nNegative prompt: bad\nSteps: 20",
):
with self.subTest(text=text):
result = _parameters(text, legacy=True)
self.assertEqual(
result["reason"], "A1111 settings footer is not unique"
)
def _chunk(keyword: str, text: str, kind: bytes = b"tEXt") -> bytes:
body = text.encode("latin-1")
if kind == b"zTXt":
body = b"\0" + zlib.compress(body)
elif kind == b"iTXt":
body = b"\0\0\0\0" + text.encode("utf-8")
return parity._raw_chunk(kind, keyword.encode("latin-1") + b"\0" + body)
def _batch(index: int, positive: str = "a cat", seed: int = 5) -> str:
return (
f"{positive}\nNegative prompt: bad\nSteps: 20, Sampler: Euler, "
f"CFG scale: 7, Seed: {seed}, Batch index: {index}, Batch size: 12"
)
class TestBatchIndexDuplicates(unittest.TestCase):
"""R3: an accumulating batch saver repeats ``parameters`` per batch item."""
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.directory = Path(temporary.name)
def png(self, chunks, name="batch.png"):
buffer = io.BytesIO()
with Image.new("RGB", (4, 4), (40, 80, 120)) as image:
image.save(buffer, format="PNG")
data = buffer.getvalue()
iend = data.rindex(b"IEND") - 4
path = self.directory / name
path.write_bytes(data[:iend] + b"".join(chunks) + data[iend:])
return path
def test_copies_differing_only_in_batch_index_read_as_the_last(self):
cases = {
"same length": [0, 1, 2],
"growing length": [8, 9, 10, 11],
"any order": [3, 0],
"repeat of an earlier copy": [0, 1, 0],
}
for label, indexes in cases.items():
with self.subTest(label):
path = self.png([_chunk("parameters", _batch(i)) for i in indexes])
read = read_inspection_file(path)
self.assertEqual(read.metadata["parameters"], _batch(indexes[-1]))
self.assertEqual(read.record_formats["parameters"], "original-text")
evidence = inspect_prompt_evidence(read.metadata)
self.assertEqual(evidence["resolution"]["state"], "resolved")
self.assertEqual(evidence["prompts"]["positive"]["text"], "a cat")
# Encodings may differ; the decoded copies are compared.
path = self.png(
[
_chunk("parameters", _batch(0)),
_chunk("parameters", _batch(1), b"zTXt"),
_chunk("parameters", _batch(2), b"iTXt"),
]
)
self.assertEqual(read_inspection_file(path).metadata["parameters"], _batch(2))
def test_any_other_difference_is_still_a_conflict(self):
cases = {
"positive": [_batch(0), _batch(1, positive="a dog")],
"seed": [_batch(0), _batch(1, seed=6)],
"no batch index": [_batch(0), _batch(0).replace(", Batch index: 0", "")],
"two batch indexes": [_batch(0), _batch(1) + ", Batch index: 2"],
"prose": [_batch(0), _batch(1).replace("a cat", "a cat Batch index: 1")],
# Review I-1: the field is matched only in the settings footer.
"prompt field": [
_batch(0).replace("a cat", "a cat, Batch index: 0"),
_batch(0).replace("a cat", "a cat, Batch index: 1"),
],
"prompt only": [
"a cat, Batch index: 0\nNegative prompt: bad\nSteps: 20, Seed: 5",
"a cat, Batch index: 1\nNegative prompt: bad\nSteps: 20, Seed: 5",
],
"negative field": [
_batch(0).replace("bad", "bad, Batch index: 0"),
_batch(1).replace("bad", "bad, Batch index: 1"),
],
"no footer": [
"a cat\nBatch index: 0, Batch size: 2",
"a cat\nBatch index: 1, Batch size: 2",
],
}
for label, texts in cases.items():
with self.subTest(label):
path = self.png([_chunk("parameters", text) for text in texts])
with self.assertRaisesRegex(
MetadataReadError, "Duplicate prompt metadata"
):
read_inspection_file(path)
# The rule is only for ``parameters``: prompt graphs stay byte-identical.
prompt = json.dumps(parity.build("f02")["prompt"])
path = self.png([_chunk("prompt", prompt), _chunk("prompt", prompt + " ")])
with self.assertRaisesRegex(MetadataReadError, "Duplicate prompt metadata"):
read_inspection_file(path)
def test_decoded_copies_are_bounded(self):
# Small compressed copies must not decode unbounded text.
text = _batch(0) + "," + " " * (DUPLICATE_ROLE_BYTES // 8)
chunks = [
_chunk("parameters", text.replace("index: 0", f"index: {i}"), b"zTXt")
for i in range(10)
]
path = self.png(chunks)
with self.assertRaisesRegex(MetadataReadError, "exceeds inspection limits"):
read_inspection_file(path)
# The kept copy still fits the role budget when a later one is longer.
from utils.prompt_inspector import ROLE_METADATA_BYTES
first = _batch(9)
first += "," + " " * (ROLE_METADATA_BYTES - len(first) - 1)
later = first.replace("index: 9", "index: 10")
for copy in (later, later + " " * ROLE_METADATA_BYTES):
with self.subTest(size=len(copy)):
path = self.png(
[
_chunk("parameters", first, b"zTXt"),
_chunk("parameters", copy, b"zTXt"),
]
)
with self.assertRaisesRegex(
MetadataReadError, "exceeds inspection limits"
):
read_inspection_file(path)
def _f02(**changes) -> dict:
"""``f02`` (KSampler 3, encoders 6 and 7) with nodes added or replaced."""
prompt = parity.build("f02")["prompt"]
prompt.update(changes)
return prompt
def _stage(class_type: str, image_input: str, positive=("6", 0), **inputs) -> dict:
"""``f19``'s shape with ``class_type`` as the detailer stage (node 50)."""
prompt = parity.build("f19")["prompt"]
if "upscale_model" in inputs:
prompt["12"] = {"class_type": "UpscaleModelLoader", "inputs": {}}
if "segs" in inputs:
prompt["13"] = {"class_type": "SegsDetector", "inputs": {"image": ["8", 0]}}
prompt["50"] = {
"class_type": class_type,
"inputs": {
image_input: ["8", 0],
"model": ["4", 0],
"vae": ["4", 2],
"positive": list(positive),
"negative": ["7", 0],
"seed": 7,
"steps": 12,
"cfg": 4.5,
"sampler_name": "euler",
"scheduler": "normal",
"denoise": 0.4,
**inputs,
},
}
return prompt
DETAILERS = (
("UltimateSDUpscale", "image", {"upscale_model": ["12", 0], "upscale_by": 2}),
("UltimateSDUpscaleNoUpscale", "upscaled_image", {}),
("DetailerForEach", "image", {"segs": ["13", 0], "wildcard": ""}),
)
class TestInstalledNodeSemantics(unittest.TestCase):
"""R6 entries verified against the installed node sources (see the report)."""
def test_upscale_and_segs_detailers_are_stages(self):
for class_type, image, extra in DETAILERS:
with self.subTest(node=class_type):
same = _inspect({"prompt": _stage(class_type, image, **extra)})
self.assertEqual(same["state"], "resolved")
self.assertEqual(same["sampler_id"], "50")
self.assertEqual(_texts(same), (parity.POS, parity.NEG))
other = _inspect(
{"prompt": _stage(class_type, image, ("10", 0), **extra)}
)
self.assertEqual(other["state"], "ambiguous")
self.assertEqual(other["reason_code"], "multistage-differs")
self.assertEqual(
sorted(item["sampler_id"] for item in other["candidates"]),
["3", "50"],
)
def test_a_detailer_wildcard_replaces_its_prompt(self):
# Impact Pack core.py: a non-empty wildcard is encoded instead of positive.
for class_type in ("DetailerForEach", "FaceDetailer"):
with self.subTest(node=class_type):
result = _inspect(
{"prompt": _stage(class_type, "image", wildcard="a smiling face")}
)
self.assertEqual(result["state"], "unresolved")
self.assertEqual(result["reason_code"], "runtime-value")
self.assertEqual(result["node"], {"id": "50", "class_type": class_type})
linked = _stage(class_type, "image", wildcard=["14", 0])
linked["14"] = {"class_type": "WildcardSource", "inputs": {}}
result = _inspect({"prompt": linked})
self.assertEqual(result["reason_code"], "unsupported-node")
self.assertEqual(
result["node"], {"id": "14", "class_type": "WildcardSource"}
)
for empty in ("", None):
result = _inspect(
{"prompt": _stage(class_type, "image", wildcard=empty)}
)
self.assertEqual(result["state"], "resolved", empty)
def test_conditioning_passthroughs_keep_each_role(self):
pair = {"positive": ["6", 0], "negative": ["7", 0]}
cases = {
"ControlNetInpaintingAliMamaApply": (
{
**pair,
"control_net": ["11", 0],
"vae": ["4", 2],
"image": ["12", 0],
"mask": ["12", 1],
"strength": 1.0,
},
0,
1,
),
"LTXVConditioning": ({**pair, "frame_rate": 25.0}, 0, 1),
"FluxKontextMultiReferenceLatentMethod": (
{"conditioning": ["6", 0], "reference_latents_method": "offset"},
0,
None,
),
}
for class_type, (inputs, positive, negative) in cases.items():
with self.subTest(node=class_type):
prompt = _f02(**{"20": {"class_type": class_type, "inputs": inputs}})
prompt["3"]["inputs"]["positive"] = ["20", positive]
if negative is not None:
prompt["3"]["inputs"]["negative"] = ["20", negative]
result = _inspect({"prompt": prompt})
self.assertEqual(result["state"], "resolved")
self.assertEqual(_texts(result), (parity.POS, parity.NEG))
self.assertEqual(
result["positive"]["provenance"]["path"][0]["node_id"], "20"
)
if negative is None:
prompt["3"]["inputs"]["negative"] = ["20", 1]
self.assertEqual(
_inspect({"prompt": prompt})["reason_code"], "unsupported-node"
)
def test_text_nodes(self):
prompt = _f02(
**{
"6": {
"class_type": "Text to Conditioning",
"inputs": {"clip": ["4", 1], "text": ["21", 0]},
},
"21": {"class_type": "DisplayText", "inputs": {"text": ["22", 0]}},
"22": {"class_type": "TextInput", "inputs": {"text": parity.POS}},
}
)
result = _inspect({"prompt": prompt})
self.assertEqual(_texts(result), (parity.POS, parity.NEG))
self.assertEqual(
[step["node_id"] for step in result["positive"]["provenance"]["path"]],
["6", "21", "22"],
)
# Text to Conditioning has one output; TextInput reads only ``text``.
prompt["3"]["inputs"]["positive"] = ["6", 1]
self.assertEqual(
_inspect({"prompt": prompt})["reason_code"], "unsupported-node"
)
def test_overwritten_text_behind_a_new_passthrough_is_still_refused(self):
# R1 before R6: the pass-through must not let overwritten text resolve.
metadata = _overwritten()
metadata["prompt"]["20"] = {
"class_type": "FluxKontextMultiReferenceLatentMethod",
"inputs": {"conditioning": ["17", 0], "reference_latents_method": "offset"},
}
metadata["prompt"]["3"]["inputs"]["positive"] = ["20", 0]
result = _inspect(metadata)
self.assertEqual(result["reason"], resolver.OVERWRITTEN_PROMPT_REASON)
def test_default_profile_keeps_them_unsupported(self):
for class_type, image, extra in DETAILERS:
with self.subTest(node=class_type):
result = resolve_prompt_pair(
{"prompt": _stage(class_type, image, **extra)}
)
# The historical rule: the one KSampler, whatever reads its image.
self.assertEqual(result["sampler_id"], "3")
prompt = _f02(
**{"6": {"class_type": "Text to Conditioning", "inputs": {"text": "a"}}}
)
self.assertEqual(
resolve_prompt_pair({"prompt": prompt})["reason"],
"Unsupported node semantics or output slot",
)
def _sampled(class_type: str, latent: str = "latent_image", **inputs) -> dict:
"""``f02`` with its KSampler (node 3) replaced by ``class_type``."""
prompt = _f02()
sampler = dict(prompt["3"]["inputs"], **inputs)
sampler[latent] = sampler.pop("latent_image")
prompt["3"] = {"class_type": class_type, "inputs": sampler}
return prompt
SAMPLERS = (
("ClownsharKSampler_Beta", "latent_image", "options"),
("KSampler (Efficient)", "latent_image", "script"),
("ttN KSampler_v2", "latent", "adv_xyPlot"),
)
class TestUpstreamVerifiedNodeSemantics(unittest.TestCase):
"""R6 entries for nodes not installed here, read from their pinned sources.
Each source is the commit the maintainer's saved workflows name in the
node's ``cnr_id``/``ver`` properties (the report lists repository, commit,
file and line).
"""
def test_ksampler_shaped_samplers_resolve(self):
for class_type, latent, _ in SAMPLERS:
with self.subTest(node=class_type):
result = _inspect({"prompt": _sampled(class_type, latent)})
self.assertEqual(result["state"], "resolved")
self.assertEqual(result["sampler_id"], "3")
self.assertEqual(_texts(result), (parity.POS, parity.NEG))
def test_inputs_that_replace_the_prompt_are_not_followed(self):
# RES4LYF options may carry a guider; Efficiency scripts and the ttN
# XY plot re-run the sampler with other settings.
for class_type, latent, override in SAMPLERS:
with self.subTest(node=class_type):
prompt = _sampled(class_type, latent, **{override: ["30", 0]})
prompt["30"] = {"class_type": "SomeScript", "inputs": {}}
result = _inspect({"prompt": prompt})
self.assertEqual(result["reason_code"], "unsupported-node")
self.assertEqual(
result["node"], {"id": "30", "class_type": "SomeScript"}
)
def test_chained_samplers_are_stages(self):
prompt = _sampled("ClownsharKSampler_Beta")
prompt["31"] = dict(prompt["3"], inputs=dict(prompt["3"]["inputs"]))
prompt["31"]["inputs"]["latent_image"] = ["3", 0]
prompt["8"]["inputs"]["samples"] = ["31", 0]
# Unanchored, two listed samplers are two branches, as for KSampler.
self.assertEqual(_inspect({"prompt": prompt})["state"], "ambiguous")
result = _inspect({"prompt": prompt}, output_node_id="9")
self.assertEqual(result["reason"], resolver.MULTISTAGE_UNATTRIBUTED_REASON)
def test_seed_variance_enhancer_passes_its_conditioning(self):
prompt = _f02(
**{
"20": {
"class_type": "SeedVarianceEnhancer",
"inputs": {"conditioning": ["6", 0], "seed": 1, "strength": 20.0},
}
}
)
prompt["3"]["inputs"]["positive"] = ["20", 0]
self.assertEqual(_texts(_inspect({"prompt": prompt})), (parity.POS, parity.NEG))
def test_overwritten_text_behind_seed_variance_is_still_refused(self):
# R1 before R6: the recon's 24 files differ from this only in names.
metadata = _overwritten()
metadata["prompt"]["20"] = {
"class_type": "SeedVarianceEnhancer",
"inputs": {"conditioning": ["17", 0], "seed": 1},
}
metadata["prompt"]["3"]["inputs"]["positive"] = ["20", 0]
result = _inspect(metadata)
self.assertEqual(result["state"], "unresolved")
self.assertEqual(result["reason"], resolver.OVERWRITTEN_PROMPT_REASON)
def test_instantid_advanced_outputs(self):
prompt = _f02(
**{
"20": {
"class_type": "ApplyInstantIDAdvanced",
"inputs": {
"model": ["4", 0],
"positive": ["6", 0],
"negative": ["7", 0],
"ip_weight": 0.8,
},
}
}
)
prompt["3"]["inputs"].update(positive=["20", 1], negative=["20", 2])
self.assertEqual(_texts(_inspect({"prompt": prompt})), (parity.POS, parity.NEG))
prompt["3"]["inputs"]["positive"] = ["20", 0] # output 0 is the MODEL
self.assertEqual(
_inspect({"prompt": prompt})["reason_code"], "unsupported-node"
)
def test_crystools_conditioning_switch(self):
def switch(condition) -> dict:
prompt = _f02(
**{
"11": _pm_encoder("a dog"),
"20": {
"class_type": "Switch conditioning [Crystools]",
"inputs": {
"on_true": ["6", 0],
"on_false": ["11", 0],
"boolean": condition,
},
},
"21": {"class_type": "PrimitiveBoolean", "inputs": {"value": True}},
}
)
prompt["3"]["inputs"]["positive"] = ["20", 0]
return _inspect({"prompt": prompt})
self.assertEqual(_texts(switch(True))[0], parity.POS)
self.assertEqual(_texts(switch(False))[0], "a dog")
result = switch(["21", 0])
self.assertEqual(result["state"], "ambiguous")
self.assertEqual(
sorted(_texts(item)[0] for item in result["candidates"]),
sorted([parity.POS, "a dog"]),
)
def test_smz_encoder_reads_text_unless_sdxl_may_apply(self):
def smz(**inputs) -> dict:
node = {
"class_type": "smZ CLIPTextEncode",
"inputs": {
"clip": ["4", 1],
"text": "a cat",
"text_g": "a cat",
"text_l": "a cat",
"with_SDXL": False,
"parser": "comfy",
**inputs,
},
}
return _inspect({"prompt": _f02(**{"6": node})})
self.assertEqual(_texts(smz(text_g="", text_l="x")), ("a cat", parity.NEG))
# with_SDXL: the connected CLIP decides at run time which inputs are read.
self.assertEqual(_texts(smz(with_SDXL=True)), ("a cat", parity.NEG))
for parts in ({"text_g": "x"}, {"text_l": ""}, {"text": "x"}):
with self.subTest(parts=parts):
result = smz(with_SDXL=True, **parts)
self.assertEqual(result["reason_code"], "runtime-value")
self.assertEqual(
result["node"], {"id": "6", "class_type": "smZ CLIPTextEncode"}
)
linked = smz(with_SDXL=["21", 0])
self.assertEqual(linked["reason_code"], "unsupported-node")
for broken in ({"with_SDXL": "yes"}, {"with_SDXL": None}):
with self.subTest(flag=broken):
self.assertEqual(smz(**broken)["reason_code"], "malformed")
def test_string_sources(self):
cases = (
("ttN text", "text"),
("CR Text", "text"),
("CR Prompt Text", "prompt"),
("TextBoxMira", "text"),
)
for class_type, name in cases:
with self.subTest(node=class_type):
prompt = _f02(
**{"10": {"class_type": class_type, "inputs": {name: parity.POS}}}
)
prompt["6"]["inputs"]["text"] = ["10", 0]
self.assertEqual(
_texts(_inspect({"prompt": prompt})), (parity.POS, parity.NEG)
)
# Output 1 of the Comfyroll nodes is their help URL.
prompt["6"]["inputs"]["text"] = ["10", 1]
self.assertEqual(
_inspect({"prompt": prompt})["reason_code"], "unsupported-node"
)
def test_default_profile_keeps_them_unsupported(self):
for class_type, latent, _ in SAMPLERS:
with self.subTest(node=class_type):
result = resolve_prompt_pair({"prompt": _sampled(class_type, latent)})
self.assertEqual(result["reason"], "No supported sampler pair found")
if __name__ == "__main__":
unittest.main()
-486
View File
@@ -1,486 +0,0 @@
"""Non-resolved prompt outcomes carry one short, truthful line per role."""
import ast
import json
import unittest
import unittest.mock
from pathlib import Path
from dataclasses import replace
from utils import prompt_pair_resolver
from utils.inspection_outcomes import (
_UNCODED_LINES,
CLASS_NAME_LENGTH,
REASON_CODES,
outcome_message,
project_resolution,
)
from utils.inspection_response import encode_inspection_response
from utils.prompt_inspector import inspect_prompt_evidence
from utils.prompt_pair_resolver import (
EMPTY_POSITIVE_REASON,
INSPECTION_PROFILE,
MULTISTAGE_UNATTRIBUTED_REASON,
SEMANTICS_UNAVAILABLE_REASON,
)
LIMITS = "Prompt exceeds inspection limits."
def _encode(text: str) -> dict:
return {"class_type": "CLIPTextEncode", "inputs": {"text": text, "clip": ["9", 0]}}
def _latent() -> dict:
return {
"class_type": "EmptyLatentImage",
"inputs": {"width": 8, "height": 8, "batch_size": 1},
}
def _ksampler(positive: str = "7") -> dict:
return {
"class_type": "KSampler",
"inputs": {
"positive": [positive, 0],
"negative": ["6", 0],
"latent_image": ["4", 0],
"model": ["9", 0],
"seed": 1,
"steps": 2,
"cfg": 1.0,
"sampler_name": "euler",
"scheduler": "normal",
"denoise": 1.0,
},
}
def _dual_cfg() -> dict:
return {
"1": _encode("positive"),
"6": _encode("negative"),
"2": {
"class_type": "DualCFGGuider",
"inputs": {
"cond1": ["1", 0],
"cond2": ["1", 0],
"negative": ["6", 0],
"model": ["9", 0],
},
},
"3": {
"class_type": "SamplerCustomAdvanced",
"inputs": {
"guider": ["2", 0],
"noise": ["9", 0],
"sampler": ["9", 0],
"sigmas": ["9", 0],
"latent_image": ["4", 0],
},
},
"4": _latent(),
}
def _runtime() -> dict:
return {
"1": {"class_type": "PromptSearchList", "inputs": {"search": "cat"}},
"7": {
"class_type": "CLIPTextEncode",
"inputs": {"text": ["1", 0], "clip": ["9", 0]},
},
"6": _encode("negative"),
"3": _ksampler(),
"4": _latent(),
}
def _composite() -> dict:
graph = _runtime()
graph["7"] = {
"class_type": "ConditioningCombine",
"inputs": {"conditioning_1": ["6", 0], "conditioning_2": ["8", 0]},
}
graph["8"] = _encode("other")
return graph
def _messages(evidence: dict) -> dict:
return {role: side.get("message") for role, side in evidence["prompts"].items()}
def _statuses(evidence: dict) -> dict:
return {role: side["status"] for role, side in evidence["prompts"].items()}
class TestOutcomeMessages(unittest.TestCase):
def assert_unresolved_with(self, evidence: dict, message: str) -> None:
self.assertEqual(
_statuses(evidence), {"positive": "unresolved", "negative": "unresolved"}
)
self.assertEqual(
_messages(evidence), {"positive": message, "negative": message}
)
def test_unsupported_node_status_names_the_class(self):
evidence = inspect_prompt_evidence({"prompt": _dual_cfg()})
self.assertEqual(evidence["resolution"]["reason_code"], "unsupported-node")
self.assert_unresolved_with(
evidence,
"Prompt uses a node the inspector does not support (DualCFGGuider).",
)
def test_runtime_and_composite_statuses(self):
self.assert_unresolved_with(
inspect_prompt_evidence({"prompt": _runtime()}),
"Prompt is chosen at run time (PromptSearchList).",
)
self.assert_unresolved_with(
inspect_prompt_evidence({"prompt": _composite()}),
"Prompt combines several texts (ConditioningCombine).",
)
def test_limit_status(self):
tight = replace(INSPECTION_PROFILE, per_candidate_visits=1, total_visits=1)
evidence = inspect_prompt_evidence({"prompt": _runtime()}, profile=tight)
self.assertEqual(evidence["resolution"]["reason_code"], "work-limit")
self.assert_unresolved_with(evidence, LIMITS)
for code in ("work-limit", "depth-limit", "text-limit", "graph-ceiling"):
self.assertEqual(
outcome_message({"state": "unresolved", "reason_code": code}), LIMITS
)
def test_every_closed_code_and_known_uncoded_reason_has_a_line(self):
for code in REASON_CODES:
message = outcome_message({"state": "unresolved", "reason_code": code})
self.assertIsInstance(message, str, code)
self.assertTrue(message.endswith("."), code)
self.assertNotIn("could not be resolved", message)
for reason, expected in (
(EMPTY_POSITIVE_REASON, "Positive prompt is empty."),
(
SEMANTICS_UNAVAILABLE_REASON,
"The inspector's node table is unavailable.",
),
(
MULTISTAGE_UNATTRIBUTED_REASON,
"Several sampler stages were found; their prompts were not compared.",
),
(
"Media metadata could not be read",
"Metadata in this file could not be read.",
),
):
resolution = {"state": "unresolved", "reason": reason, "reason_code": None}
self.assertEqual(outcome_message(resolution), expected)
def test_named_codes_without_a_class_omit_the_name(self):
for code, line in (
("unsupported-node", "Prompt uses a node the inspector does not support."),
("runtime-value", "Prompt is chosen at run time."),
("composite", "Prompt combines several texts."),
):
for node in (None, {"id": "7", "class_type": None}):
resolution = {"state": "unresolved", "reason_code": code, "node": node}
self.assertEqual(outcome_message(resolution), line)
def test_class_name_is_bounded(self):
resolution = {
"state": "unresolved",
"reason_code": "unsupported-node",
"node": {"id": "7", "class_type": "N" * 500},
}
self.assertEqual(
outcome_message(resolution),
"Prompt uses a node the inspector does not support "
f"({'N' * CLASS_NAME_LENGTH}).",
)
self.assertEqual(CLASS_NAME_LENGTH, 200)
def test_resolved_ambiguous_absent_and_unknown_outcomes_carry_no_line(self):
for resolution in (
{"state": "resolved", "reason_code": None},
{"state": "ambiguous", "reason_code": "multistage-differs"},
{"state": "ambiguous", "reason_code": None, "reason": "Multiple"},
{"state": "absent", "reason_code": None, "reason": "No parsed"},
{"state": "unresolved", "reason_code": "guess"},
{"state": "unresolved", "reason_code": None, "reason": "Anything else"},
None,
):
self.assertIsNone(outcome_message(resolution), resolution)
def test_inspector_lines_for_no_sampler_malformed_and_empty_positive(self):
evidence = inspect_prompt_evidence({"prompt": {"1": _encode("x")}})
self.assert_unresolved_with(evidence, "No supported sampler was found.")
evidence = inspect_prompt_evidence({"prompt": "not-json"})
self.assert_unresolved_with(
evidence, "Prompt metadata in this file is malformed."
)
graph = _runtime()
graph["7"] = _encode(" ")
evidence = inspect_prompt_evidence({"prompt": graph})
self.assertEqual(evidence["resolution"]["reason"], EMPTY_POSITIVE_REASON)
self.assert_unresolved_with(evidence, "Positive prompt is empty.")
def test_a_resolved_pair_with_an_empty_positive_explains_only_that_role(self):
side = {"text": " ", "provenance": {"text_source": "graph-inferred"}}
negative = {"text": "kept", "provenance": {"text_source": "graph-inferred"}}
resolved = {
"state": "resolved",
"positive": side,
"negative": negative,
"sampler_id": "3",
"reason": "",
"reason_code": None,
"node": None,
}
with unittest.mock.patch(
"utils.prompt_inspector.resolve_prompt_pair", return_value=resolved
):
evidence = inspect_prompt_evidence({"prompt": {}})
self.assertEqual(
evidence["prompts"]["positive"],
{
"status": "unresolved",
"text": None,
"source": None,
"message": "Positive prompt is empty.",
},
)
self.assertEqual(evidence["prompts"]["negative"]["text"], "kept")
self.assertNotIn("message", evidence["prompts"]["negative"])
def test_candidates_and_absence_keep_their_status_lines(self):
graph = _runtime()
graph["7"] = _encode("positive")
graph["5"] = _ksampler()
evidence = inspect_prompt_evidence({"prompt": graph})
self.assertEqual(evidence["resolution"]["state"], "ambiguous")
self.assertEqual(_messages(evidence), {"positive": None, "negative": None})
evidence = inspect_prompt_evidence({})
self.assertEqual(_messages(evidence), {"positive": None, "negative": None})
graph = _runtime()
graph["7"] = _encode("positive")
evidence = inspect_prompt_evidence({"prompt": graph})
self.assertEqual(evidence["resolution"]["state"], "resolved")
self.assertEqual(_messages(evidence), {"positive": None, "negative": None})
class TestResolutionProjection(unittest.TestCase):
def test_closed_codes_and_named_nodes_pass_unchanged(self):
for code in REASON_CODES:
resolution = {
"state": "unresolved",
"sampler_id": "3",
"reason": "r",
"reason_code": code,
"node": None,
}
self.assertEqual(project_resolution(resolution), resolution)
named = {
"state": "unresolved",
"sampler_id": None,
"reason": "r",
"reason_code": "composite",
"node": {"id": "7", "class_type": None},
}
self.assertEqual(project_resolution(named), named)
def test_unknown_codes_and_unbounded_nodes_fail_closed(self):
base = {"state": "unresolved", "sampler_id": None, "reason": "r"}
for code, node in (
("guess", None),
(1, None),
("malformed", {"id": "7", "class_type": "X"}),
(None, {"id": "7", "class_type": "X"}),
("unsupported-node", {"id": 7, "class_type": "X"}),
("unsupported-node", {"id": "", "class_type": "X"}),
("unsupported-node", {"id": "7"}),
("unsupported-node", {"id": "7", "class_type": 7}),
("unsupported-node", {"id": "7", "class_type": "X", "extra": 1}),
("unsupported-node", "X"),
):
projected = project_resolution({**base, "reason_code": code, "node": node})
self.assertIsNone(projected["node"], (code, node))
if code not in REASON_CODES:
self.assertIsNone(projected["reason_code"], code)
for state in ("resolved", "absent"):
projected = project_resolution(
{**base, "state": state, "reason_code": "work-limit", "node": None}
)
self.assertIsNone(projected["reason_code"])
long_name = project_resolution(
{
**base,
"reason_code": "runtime-value",
"node": {"id": "7" * 300, "class_type": "N" * 300},
}
)["node"]
self.assertEqual(
long_name, {"id": "7" * CLASS_NAME_LENGTH, "class_type": "N" * 200}
)
unnamed = project_resolution(
{
**base,
"reason_code": "composite",
"node": {"id": "7", "class_type": ""},
}
)["node"]
self.assertEqual(unnamed, {"id": "7", "class_type": None})
def test_wire_carries_role_lines_and_projected_resolution(self):
evidence = inspect_prompt_evidence({"prompt": _dual_cfg()})
resolution = dict(evidence["resolution"], reason_code="guess")
body = json.loads(
encode_inspection_response(
image={"root_index": 0, "path": "a.png", "filename": "a.png"},
root_scope="a" * 64,
file={
"observation": "current-file",
"source_label": "output",
"media_type": "image",
"extension": ".png",
"size": 1,
"modified_time": 1,
},
prompts=evidence["prompts"],
resolution=resolution,
inspection=None,
library={"status": "none", "tags": []},
)
)
self.assertEqual(body["prompts"], evidence["prompts"])
self.assertIsNone(body["resolution"]["reason_code"])
self.assertIsNone(body["resolution"]["node"])
# Uncoded unresolved reasons only the default (ingestion) profile emits; the
# inspector never shows them, so they need no line.
_DEFAULT_PROFILE_ONLY = frozenset(
{"Cyclic or excessive latent ancestry", "Resolution limit reached"}
)
def _uncoded_unresolved_reasons() -> set:
"""Every literal reason the resolver can return as unresolved without a code.
Reads the source: ``_Unresolved(reason)`` without a code, and
``_result("unresolved", ..., reason=...)`` without ``reason_code``. A reason
is a string literal or a module constant naming one.
"""
source = Path(prompt_pair_resolver.__file__).read_text(encoding="utf-8")
tree = ast.parse(source)
def literal(node):
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return node.value
if isinstance(node, ast.Name):
value = getattr(prompt_pair_resolver, node.id, None)
return value if isinstance(value, str) else None
return None
found = set()
for node in ast.walk(tree):
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Name):
continue
keywords = {item.arg: item.value for item in node.keywords}
if node.func.id == "_Unresolved":
if len(node.args) < 2 and "code" not in keywords and node.args:
reason = literal(node.args[0])
if reason is None:
raise AssertionError(f"line {node.lineno}: unreadable reason")
found.add(reason)
elif node.func.id == "_result" and node.args:
state = literal(node.args[0])
coded = "reason_code" in keywords or len(node.args) > 6
reason = keywords.get(
"reason", node.args[4] if len(node.args) > 4 else None
)
if state == "unresolved" and not coded and reason is not None:
text = literal(reason)
if text is None:
raise AssertionError(f"line {node.lineno}: unreadable reason")
found.add(text)
return found
class TestEveryUncodedOutcomeHasALine(unittest.TestCase):
"""Task 2.7 review M1: a new uncoded unresolved reason fails here."""
def test_resolver_reasons_without_a_code_have_a_line(self):
reasons = _uncoded_unresolved_reasons()
self.assertIn(EMPTY_POSITIVE_REASON, reasons, "the scan sees the source")
missing = reasons - set(_UNCODED_LINES) - _DEFAULT_PROFILE_ONLY
self.assertEqual(missing, set(), "add a code or a line in inspection_outcomes")
# The allowance names only reasons the source still emits.
self.assertLessEqual(_DEFAULT_PROFILE_ONLY, reasons)
def test_default_profile_only_reasons_are_coded_in_inspection(self):
# The limit outcome is uncoded only on the default profile.
source = {"prompt": {"1": {"class_type": "KSampler", "inputs": {}}}}
for limited in ({"max_depth": 0}, {"max_text_length": 0}):
with self.subTest(**limited):
default = prompt_pair_resolver.resolve_prompt_pair(source, **limited)
inspected = prompt_pair_resolver.resolve_prompt_pair(
source, profile=INSPECTION_PROFILE, **limited
)
self.assertEqual(default["reason"], "Resolution limit reached")
self.assertIsNone(default["reason_code"])
self.assertEqual(inspected["reason"], "Resolution limit reached")
self.assertIsNotNone(inspected["reason_code"])
class TestRoleMessageBound(unittest.TestCase):
"""Task 2.7 review M4: the encoder bounds role lines as the client does."""
def encode(self, prompts: dict) -> dict:
return json.loads(
encode_inspection_response(
image={"root_index": 0, "path": "a.png", "filename": "a.png"},
root_scope="a" * 64,
file={
"observation": "current-file",
"source_label": "output",
"media_type": "image",
"extension": ".png",
"size": 1,
"modified_time": 1,
},
prompts=prompts,
resolution={"state": "unresolved", "reason": "x"},
inspection=None,
library={"status": "none", "tags": []},
)
)["prompts"]
def test_role_messages_are_bounded_text_on_unresolved_roles(self):
side = {"status": "unresolved", "text": None, "source": None}
prompts = self.encode(
{
"positive": {**side, "message": "x" * 600},
"negative": {**side, "message": "\U0001f600" * 300},
}
)
self.assertEqual(prompts["positive"]["message"], "x" * 512)
# The client counts UTF-16 code units.
self.assertLessEqual(
len(prompts["negative"]["message"].encode("utf-16-le")), 1024
)
self.assertEqual(prompts["negative"]["message"], "\U0001f600" * 256)
for message in ("", None, 7):
with self.subTest(message=message):
prompts = self.encode(
{"positive": {**side, "message": message}, "negative": side}
)
self.assertNotIn("message", prompts["positive"])
resolved = {"status": "resolved", "text": "a", "source": "graph-inferred"}
prompts = self.encode(
{"positive": {**resolved, "message": "line"}, "negative": side}
)
self.assertNotIn("message", prompts["positive"])
self.assertEqual(prompts["negative"], side)
if __name__ == "__main__":
unittest.main()
-838
View File
@@ -1,838 +0,0 @@
"""The declarative inspection node semantics table and its loader (#240, spec 7).
``web/js/inspection-semantics.json`` is the single table. Python validates it at
import and fails loudly; the browser facts tables are checked against its
``facts`` section by ``tests/js/inspection-semantics-sync.test.js``.
"""
import copy
import json
import tempfile
import unittest
import unittest.mock
from pathlib import Path
from types import MappingProxyType
from utils import inspection_semantics as semantics
from utils.inspection_semantics import SemanticsError, load_semantics
REPO = Path(__file__).resolve().parents[1]
TABLE = REPO / "web" / "js" / "inspection-semantics.json"
def _minimal():
"""A small valid table; mutation tests break exactly one thing in a copy."""
return {
"schema": 1,
"samplers": {
"KSampler": {
"strategy": "ksampler",
"conditioning": {"positive": "positive", "negative": "negative"},
"latent": "latent_image",
"facts": "ksampler",
}
},
"guiders": {"BasicGuider": {"strategy": "basic", "positive": "conditioning"}},
"conditioning": [
{
"class": "FluxGuidance",
"slot": 0,
"strategy": "passthrough",
"input": "conditioning",
}
],
"selectors": {},
"composites": {},
"encoders": [
{
"class": "CLIPTextEncode",
"slot": 0,
"strategy": "single",
"inputs": ["text"],
}
],
"strings": [],
"runtime": [],
"savers": {},
"non_file_outputs": [],
"stage_inputs": {"shaped": ["positive"], "with_model": ["samples"]},
"facts": {
"configuration_outputs": {},
"model_outputs": {},
"model_passthroughs": [],
"scalar_outputs": {},
},
}
class _TableCase(unittest.TestCase):
def load_text(self, text):
# Windows: the file handle is closed before the directory is removed.
with tempfile.TemporaryDirectory() as root:
path = Path(root) / "semantics.json"
path.write_text(text, encoding="utf-8")
return load_semantics(path)
def load_doc(self, doc):
return self.load_text(json.dumps(doc))
def assertRejected(self, doc, fragment):
with self.assertRaises(SemanticsError) as caught:
self.load_doc(doc)
self.assertIn(fragment, str(caught.exception))
class SpecEntriesTest(unittest.TestCase):
def test_semantics_file_validates_and_lists_spec_entries(self):
table = load_semantics()
self.assertEqual(semantics.SEMANTICS_PATH, TABLE)
self.assertEqual(
semantics.SEMANTICS_PATH,
Path(semantics.__file__).resolve().parents[1]
/ "web"
/ "js"
/ "inspection-semantics.json",
)
pair = {"positive": "positive", "negative": "negative"}
for name in ("KSampler", "KSamplerAdvanced"):
self.assertEqual(
table.samplers[name],
{
"strategy": "ksampler",
"conditioning": pair,
"latent": "latent_image",
"facts": "ksampler",
},
)
self.assertEqual(
table.samplers["SamplerCustom"],
{
"strategy": "ksampler",
"conditioning": pair,
"latent": "latent_image",
"facts": "custom",
},
)
self.assertEqual(
table.samplers["SamplerCustomAdvanced"],
{
"strategy": "custom-advanced",
"conditioning": {"guider": "guider"},
"latent": "latent_image",
"facts": "custom-advanced",
},
)
# D19: FaceDetailer is a stage over its upstream image, ksampler-shaped facts.
# A non-empty wildcard replaces the detail prompt (Impact core.py:267).
self.assertEqual(
table.samplers["FaceDetailer"],
{
"strategy": "detailer-stage",
"conditioning": pair,
"image_stage": "image",
"facts": "ksampler",
"prompt_overrides": ("wildcard",),
},
)
self.assertEqual(
table.guiders["CFGGuider"],
{"strategy": "cfg", "positive": "positive", "negative": "negative"},
)
self.assertEqual(
table.guiders["BasicGuider"],
{
"strategy": "basic",
"positive": "conditioning",
"absent_reason": "Guider has no negative input",
},
)
# D17: DualCFGGuider has two positives and stays an unsupported node.
self.assertNotIn("DualCFGGuider", table.guiders)
self.assertNotIn("DualCFGGuider", table.all_classes)
single_input = (
"FluxGuidance",
"ReferenceLatent",
"ConditioningSetArea",
"ConditioningSetAreaPercentage",
"ConditioningSetAreaStrength",
"ConditioningSetMask",
"ConditioningSetTimestepRange",
"ControlNetApply",
)
for name in single_input:
self.assertEqual(
table.conditioning[(name, 0)],
{"strategy": "passthrough", "input": "conditioning"},
)
self.assertNotIn((name, 1), table.conditioning)
for name in (
"ControlNetApplyAdvanced",
"ControlNetApplySD3",
"InpaintModelConditioning",
"WanImageToVideo",
):
self.assertEqual(
table.conditioning[(name, 0)],
{"strategy": "passthrough", "input": "positive"},
)
self.assertEqual(
table.conditioning[(name, 1)],
{"strategy": "passthrough", "input": "negative"},
)
# Slot 2 (a latent) is not conditioning; spec 7.4 handles origins.
self.assertNotIn((name, 2), table.conditioning)
self.assertEqual(
table.conditioning[("ConditioningZeroOut", 0)],
{"strategy": "zeroed", "input": "conditioning"},
)
self.assertEqual(
table.selectors["easy ifElse"],
{
"strategy": "boolean-select",
"condition": "boolean",
"branches": {"true": "on_true", "false": "on_false"},
},
)
self.assertEqual(
table.composites["ConditioningCombine"],
{"strategy": "combine", "inputs": ("conditioning_1", "conditioning_2")},
)
for name in ("ConditioningConcat", "ConditioningAverage"):
self.assertEqual(
table.composites[name],
{
"strategy": "combine",
"inputs": ("conditioning_to", "conditioning_from"),
},
)
encoders = {
("CLIPTextEncode", 0): ("single", ("text",)),
("CLIPTextEncodeSDXL", 0): ("parts-equal", ("text_g", "text_l")),
("CLIPTextEncodeFlux", 0): ("parts-equal", ("clip_l", "t5xxl")),
("CLIPTextEncodeSD3", 0): ("parts-equal", ("clip_l", "clip_g", "t5xxl")),
("TextEncodeQwenImageEdit", 0): ("single", ("prompt",)),
("TextEncodeQwenImageEditPlus", 0): ("single", ("prompt",)),
("TextEncodeQwenImage21", 0): ("single", ("prompt",)),
("TextEncodeQwenImage21", 1): ("single", ("negative_prompt",)),
# D11: a plain text encoder, no composition.
("KikoTextEncode", 0): ("single", ("text",)),
}
for key, (strategy, inputs) in encoders.items():
self.assertEqual(
table.encoders[key], {"strategy": strategy, "inputs": inputs}, key
)
self.assertNotIn(("TextEncodeQwenImage21", 2), table.encoders)
self.assertEqual(
table.strings[("StringConstant", 0)],
{"strategy": "literal", "inputs": ("string",)},
)
self.assertEqual(
table.strings[("StringConstantMultiline", 0)],
{
"strategy": "strip-newlines",
"inputs": ("string",),
"params": {"flag": "strip_newlines"},
},
)
self.assertEqual(
table.strings[("JoinStringMulti", 0)],
{
"strategy": "join-multi",
"inputs": ("string_1",),
"params": {
"count": "inputcount",
"delimiter": "delimiter",
"list_flag": "return_list",
"input_prefix": "string_",
},
},
)
# D18: decided at run time.
self.assertEqual(table.runtime, frozenset({"PromptSearchList"}))
comfy = "{name}_{counter:05}_.{ext}"
for name, image in (
("SaveImage", "images"),
("SaveAnimatedWEBP", "images"),
("SaveAnimatedPNG", "images"),
("SaveVideo", "video"),
("SaveWEBM", "images"),
):
self.assertEqual(
table.savers[name],
{
"strategy": "comfy-counter",
"prefix_input": "filename_prefix",
"image_input": image,
"pattern": comfy,
},
)
self.assertEqual(
table.savers["VHS_VideoCombine"],
{
"strategy": "vhs-counter",
"prefix_input": "filename_prefix",
"image_input": "images",
"pattern": "{name}_{counter:05}.{ext}",
},
)
# Until its naming is verified in pinned source it stays terminal-unknown.
self.assertNotIn("KikoSaveImage", table.all_classes)
self.assertEqual(
table.non_file_outputs,
frozenset(
{
"PreviewImage",
"PreviewAny",
"SaveImageWebsocket",
"Image Comparer (rgthree)",
}
),
)
# Not listed this slice: they stay unsupported-node with the class named.
for name in (
"ImpactWildcardProcessor",
"RegexReplace",
"LoRAfromText",
"XYZPrompt",
):
self.assertNotIn(name, table.all_classes)
# Existing code adapters stay in code, unchanged.
for name in ("PromptManager", "PromptManagerPair", "PromptManagerText"):
self.assertNotIn(name, table.all_classes)
def test_legacy_parser_ports_are_listed_with_their_source_inputs(self):
# Task 2.7b (R6): input names and slots read from each node's source;
# the report lists the file and line for every one.
table = load_semantics()
pair = {"positive": "positive", "negative": "negative"}
for name, image in (
("DetailerForEach", "image"),
("UltimateSDUpscale", "image"),
("UltimateSDUpscaleNoUpscale", "upscaled_image"),
):
entry = dict(table.samplers[name])
self.assertEqual(
entry.pop("prompt_overrides", None) is not None,
name == "DetailerForEach",
name,
)
self.assertEqual(
entry,
{
"strategy": "detailer-stage",
"conditioning": pair,
"image_stage": image,
"facts": "ksampler",
},
name,
)
for (name, slot), source in {
("ControlNetInpaintingAliMamaApply", 0): "positive",
("ControlNetInpaintingAliMamaApply", 1): "negative",
("LTXVConditioning", 0): "positive",
("LTXVConditioning", 1): "negative",
("FluxKontextMultiReferenceLatentMethod", 0): "conditioning",
}.items():
self.assertEqual(
table.conditioning[(name, slot)],
{"strategy": "passthrough", "input": source},
)
self.assertNotIn(
("FluxKontextMultiReferenceLatentMethod", 1), table.conditioning
)
self.assertEqual(
table.encoders[("Text to Conditioning", 0)],
{"strategy": "single", "inputs": ("text",)},
)
for name in ("TextInput", "DisplayText"):
self.assertEqual(
table.strings[(name, 0)], {"strategy": "literal", "inputs": ("text",)}
)
def test_stage_inputs_are_the_one_source_for_both_stage_walks(self):
# Review I-2: Python's resolver and comfy-metadata.js read these lists here.
stage_inputs = load_semantics().stage_inputs
self.assertEqual(
stage_inputs["shaped"],
(
"latent_image",
"guider",
"positive",
"negative",
"pipe",
"basic_pipe",
"detailer_pipe",
"sampler",
"sigmas",
"noise",
"text_embeds",
"hyvid_embeds",
),
)
self.assertEqual(
stage_inputs["with_model"],
("conditioning", "neg_conditioning", "samples", "latent"),
)
# The default profile's own sampler-shape names stay a subset of the table's.
from utils.prompt_pair_resolver import _SAMPLER_LIKE_INPUTS
self.assertLessEqual(set(_SAMPLER_LIKE_INPUTS), set(stage_inputs["shaped"]))
def test_facts_section_mirrors_the_browser_tables(self):
facts = load_semantics().facts
self.assertEqual(
facts["configuration_outputs"],
{
"RandomNoise": {"kind": "noise", "fields": ("noise_seed",)},
"KSamplerSelect": {"kind": "sampler", "fields": ("sampler_name",)},
"BasicScheduler": {"kind": "sigmas", "fields": ("steps", "scheduler")},
# D8: Flux2Scheduler has no scheduler input; its type names it.
"Flux2Scheduler": {
"kind": "sigmas",
"fields": ("steps",),
"type_fields": ("scheduler",),
},
"CFGGuider": {"kind": "guider", "fields": ("cfg", "model")},
"BasicGuider": {"kind": "guider", "fields": ("model",)},
},
)
self.assertEqual(
facts["model_outputs"],
{
"CheckpointLoader": "ckpt_name",
"CheckpointLoaderSimple": "ckpt_name",
"unCLIPCheckpointLoader": "ckpt_name",
"UNETLoader": "unet_name",
},
)
self.assertEqual(
facts["model_passthroughs"],
(
"LoraLoader",
"LoraLoaderModelOnly",
"ModelSamplingFlux",
"ModelSamplingAuraFlow",
"ModelSamplingSD3",
# Task 2.7b (R9): model in, model out, verified in their sources.
"Lora Loader (LoraManager)",
"Power Lora Loader (rgthree)",
"ModelPatchTorchSettings",
"CFGNorm",
"PerturbedAttentionGuidance",
"CR Apply LoRA Stack",
),
)
self.assertEqual(
facts["scalar_outputs"],
{
"PrimitiveInt": "number",
"PrimitiveFloat": "number",
"PrimitiveString": "string",
"PrimitiveStringMultiline": "string",
},
)
def test_table_is_loaded_lazily_and_once(self):
# Importing the module reads nothing; the inspection path asks for it.
self.assertFalse(hasattr(semantics, "SEMANTICS"))
semantics.get_semantics.cache_clear()
first = semantics.get_semantics()
self.assertIs(first, semantics.get_semantics())
self.assertEqual(first, load_semantics())
def test_loaded_table_is_frozen(self):
table = load_semantics()
self.assertIsInstance(table.samplers, MappingProxyType)
self.assertIsInstance(table.samplers["KSampler"], MappingProxyType)
with self.assertRaises(TypeError):
table.samplers["Other"] = {}
with self.assertRaises(TypeError):
table.samplers["KSampler"]["latent"] = "x"
with self.assertRaises(AttributeError):
table.samplers = {}
self.assertIsInstance(table.composites["ConditioningCombine"]["inputs"], tuple)
class ValidationTest(_TableCase):
def test_minimal_table_loads(self):
table = self.load_doc(_minimal())
self.assertEqual(table.encoders[("CLIPTextEncode", 0)]["inputs"], ("text",))
self.assertEqual(table.runtime, frozenset())
def test_unknown_strategy_rejected(self):
for category, mutate in (
("samplers", lambda d: d["samplers"]["KSampler"].update(strategy="dual")),
# A known strategy of another category is still unknown here.
("conditioning", lambda d: d["conditioning"][0].update(strategy="single")),
("encoders", lambda d: d["encoders"][0].update(strategy="guess")),
("guiders", lambda d: d["guiders"]["BasicGuider"].update(strategy=None)),
):
with self.subTest(category=category):
doc = _minimal()
mutate(doc)
self.assertRejected(doc, "unknown strategy")
def test_strategies_are_the_closed_spec_set(self):
self.assertEqual(
dict(semantics.STRATEGIES),
{
"samplers": frozenset(
{"ksampler", "custom-advanced", "detailer-stage"}
),
"guiders": frozenset({"cfg", "basic"}),
"conditioning": frozenset({"passthrough", "zeroed"}),
"selectors": frozenset({"boolean-select"}),
"composites": frozenset({"combine"}),
"encoders": frozenset(
{"single", "parts-equal", "pm-compose", "flag-parts"}
),
"strings": frozenset(
{"literal", "strip-newlines", "join-multi", "concat"}
),
"savers": frozenset({"comfy-counter", "vhs-counter"}),
},
)
with self.assertRaises(TypeError):
semantics.STRATEGIES["samplers"] = frozenset()
def test_duplicate_output_key_rejected(self):
doc = _minimal()
doc["encoders"].append(copy.deepcopy(doc["encoders"][0]))
self.assertRejected(doc, "duplicate")
doc = _minimal()
doc["conditioning"].append(
{
"class": "CLIPTextEncode",
"slot": 0,
"strategy": "passthrough",
"input": "conditioning",
}
)
self.assertRejected(doc, "duplicate")
doc = _minimal()
doc["runtime"] = ["KSampler"]
self.assertRejected(doc, "duplicate")
doc = _minimal()
doc["non_file_outputs"] = ["PreviewImage", "PreviewImage"]
self.assertRejected(doc, "duplicate")
# JSON objects with a repeated key would silently keep the last one.
text = json.dumps(_minimal())
entry = json.dumps(_minimal()["samplers"]["KSampler"])
text = text.replace(
'"samplers": {', '"samplers": {"KSampler": ' + entry + ", ", 1
)
with self.assertRaises(SemanticsError) as caught:
self.load_text(text)
self.assertIn("duplicate", str(caught.exception))
def test_slot_must_be_nonnegative_int(self):
for slot in (-1, 1.5, True, "0", None, 2**31):
with self.subTest(slot=slot):
doc = _minimal()
doc["encoders"][0]["slot"] = slot
self.assertRejected(doc, "slot")
doc = _minimal()
del doc["conditioning"][0]["slot"]
self.assertRejected(doc, "slot")
def test_missing_input_name_rejected(self):
cases = (
lambda d: d["samplers"]["KSampler"]["conditioning"].pop("negative"),
lambda d: d["samplers"]["KSampler"].pop("latent"),
lambda d: d["guiders"]["BasicGuider"].update(positive=""),
lambda d: d["conditioning"][0].pop("input"),
lambda d: d["encoders"][0].update(inputs=[]),
lambda d: d["encoders"][0].update(inputs=["text", "text"]),
lambda d: d["encoders"][0].update(inputs=[3]),
lambda d: d["encoders"][0].update(inputs="text"),
)
for index, mutate in enumerate(cases):
with self.subTest(case=index):
doc = _minimal()
mutate(doc)
self.assertRejected(doc, "input")
def test_entry_shape_is_strict(self):
cases = (
(lambda d: d.update(extra={}), "category"),
(lambda d: d.pop("guiders"), "category"),
(lambda d: d.update(schema=2), "schema"),
(lambda d: d["samplers"]["KSampler"].update(note="x"), "field"),
(lambda d: d["samplers"]["KSampler"].update(facts="flux"), "facts"),
(lambda d: d["guiders"]["BasicGuider"].update(negative="n"), "field"),
(lambda d: d["encoders"][0].update({"class": ""}), "class"),
(lambda d: d["facts"].update(extra={}), "facts"),
(
lambda d: d["facts"]["scalar_outputs"].update(PrimitiveInt="int"),
"facts",
),
(lambda d: d.pop("stage_inputs"), "category"),
(lambda d: d["stage_inputs"].update(extra=["x"]), "stage_inputs"),
(lambda d: d["stage_inputs"].pop("with_model"), "stage_inputs"),
(lambda d: d["stage_inputs"].update(shaped=[]), "input name"),
(lambda d: d["stage_inputs"].update(shaped="pipe"), "input name"),
(lambda d: d["stage_inputs"].update(with_model=["positive"]), "both"),
(lambda d: d["stage_inputs"].update(shaped=["model"]), "model"),
(
lambda d: d["facts"]["configuration_outputs"].update(
X={"kind": "sigmas", "fields": ["steps"], "type_fields": []}
),
"input name",
),
(
lambda d: d["facts"]["configuration_outputs"].update(
X={"kind": "sigmas", "fields": ["steps"], "type_fields": ["steps"]}
),
"type_fields",
),
(
lambda d: d["facts"]["configuration_outputs"].update(
X={"kind": "sigmas", "fields": ["steps"], "names": ["x"]}
),
"field",
),
)
for index, (mutate, fragment) in enumerate(cases):
with self.subTest(case=index):
doc = _minimal()
mutate(doc)
self.assertRejected(doc, fragment)
def test_strategy_specific_requirements(self):
doc = _minimal()
doc["samplers"]["X"] = {
"strategy": "custom-advanced",
"conditioning": {"positive": "positive", "negative": "negative"},
"latent": "latent_image",
"facts": "custom-advanced",
}
self.assertRejected(doc, "input")
doc = _minimal()
doc["samplers"]["D"] = {
"strategy": "detailer-stage",
"conditioning": {"positive": "positive", "negative": "negative"},
"facts": "ksampler",
}
self.assertRejected(doc, "input")
doc = _minimal()
doc["guiders"]["G"] = {"strategy": "cfg", "positive": "positive"}
self.assertRejected(doc, "input")
doc = _minimal()
doc["encoders"][0].update(strategy="parts-equal", inputs=["text_g"])
self.assertRejected(doc, "input")
doc = _minimal()
doc["savers"]["S"] = {
"strategy": "comfy-counter",
"prefix_input": "filename_prefix",
"image_input": "images",
"pattern": "{name}_{counter:05}.{ext}",
}
self.assertRejected(doc, "pattern")
doc = _minimal()
doc["selectors"]["easy ifElse"] = {
"strategy": "boolean-select",
"condition": "boolean",
"branches": {"true": "on_true"},
}
self.assertRejected(doc, "input")
doc = _minimal()
doc["strings"].append(
{
"class": "JoinStringMulti",
"slot": 0,
"strategy": "join-multi",
"inputs": ["string_1"],
"params": {"count": "inputcount"},
}
)
self.assertRejected(doc, "params")
def test_unreadable_table_fails_loudly(self):
with self.assertRaises(SemanticsError):
self.load_text("{not json")
with self.assertRaises(SemanticsError):
self.load_text("[]")
with self.assertRaises(SemanticsError):
self.load_text(
json.dumps(_minimal()).replace('"schema": 1', '"schema": NaN')
)
with tempfile.TemporaryDirectory() as root:
with self.assertRaises(SemanticsError):
load_semantics(Path(root) / "missing.json")
def test_oversized_table_is_rejected_before_parsing(self):
text = json.dumps(_minimal()) + " " * semantics.MAX_TABLE_BYTES
with self.assertRaises(SemanticsError) as caught:
self.load_text(text)
self.assertIn("exceeds", str(caught.exception))
def test_deeply_nested_table_is_a_semantics_error(self):
# Past the nesting bound, and far past the interpreter's recursion limit.
for depth in (semantics.MAX_NESTING + 1, 100_000):
with self.subTest(depth=depth):
nested = "[" * depth + "]" * depth
text = json.dumps(_minimal()).replace(
'"selectors": {}', '"selectors": ' + nested
)
with self.assertRaises(SemanticsError) as caught:
self.load_text(text)
self.assertIn("nest", str(caught.exception))
self.assertGreater(semantics.MAX_NESTING, _depth(json.loads(TABLE.read_text())))
# Whichever interpreter limit the parser hits, it is a SemanticsError.
with unittest.mock.patch.object(
semantics.json, "loads", side_effect=RecursionError
):
with self.assertRaises(SemanticsError) as caught:
self.load_doc(_minimal())
self.assertIn("nests deeper", str(caught.exception))
def _depth(value, level=1):
children = value.values() if isinstance(value, dict) else value
if not isinstance(value, (dict, list)):
return level - 1
return max([level] + [_depth(item, level + 1) for item in children])
class SamplerOptionsTest(_TableCase):
"""Task 2.7b: optional sampler lists are input names, or the table fails."""
def test_upstream_verified_ports_are_listed_with_their_source_inputs(self):
# Not installed here: read from the pinned upstream commits the saved
# workflows name (the report lists repository, commit, file and line).
table = load_semantics()
pair = {"positive": "positive", "negative": "negative"}
for name, latent, prompt_overrides, fact_overrides in (
(
"ClownsharKSampler_Beta",
"latent_image",
("options",),
("options", "sigmas"),
),
("KSampler (Efficient)", "latent_image", ("script",), ("script",)),
("ttN KSampler_v2", "latent", ("adv_xyPlot",), ("adv_xyPlot",)),
):
self.assertEqual(
table.samplers[name],
{
"strategy": "ksampler",
"conditioning": pair,
"latent": latent,
"facts": "ksampler",
"prompt_overrides": prompt_overrides,
"fact_overrides": fact_overrides,
},
name,
)
for (name, slot), source in {
("SeedVarianceEnhancer", 0): "conditioning",
("ApplyInstantIDAdvanced", 1): "positive",
("ApplyInstantIDAdvanced", 2): "negative",
}.items():
self.assertEqual(
table.conditioning[(name, slot)],
{"strategy": "passthrough", "input": source},
)
self.assertNotIn(("ApplyInstantIDAdvanced", 0), table.conditioning)
self.assertEqual(
table.selectors["Switch conditioning [Crystools]"],
table.selectors["easy ifElse"],
)
self.assertEqual(
table.encoders[("smZ CLIPTextEncode", 0)],
{
"strategy": "flag-parts",
"inputs": ("text", "text_g", "text_l"),
"params": {"flag": "with_SDXL"},
},
)
for name, source in (
("ttN text", "text"),
("CR Text", "text"),
("CR Prompt Text", "prompt"),
("TextBoxMira", "text"),
):
self.assertEqual(
table.strings[(name, 0)], {"strategy": "literal", "inputs": (source,)}
)
self.assertNotIn((name, 1), table.strings)
def test_flag_parts_needs_a_flag_and_two_inputs(self):
doc = _minimal()
entry = {
"class": "smZ CLIPTextEncode",
"slot": 0,
"strategy": "flag-parts",
"inputs": ["text", "text_g"],
"params": {"flag": "with_SDXL"},
}
doc["encoders"].append(entry)
self.assertEqual(
self.load_doc(doc).encoders[("smZ CLIPTextEncode", 0)]["params"],
{"flag": "with_SDXL"},
)
for change in (
{"params": {}},
{"params": {"flag": "x", "other": "y"}},
{"params": {"flag": ""}},
{"params": None},
{"inputs": ["text"]},
{"compose": True},
):
with self.subTest(change=change):
broken = _minimal()
broken["encoders"].append({**entry, **change})
with self.assertRaises(SemanticsError):
self.load_doc(broken)
broken = _minimal()
broken["encoders"].append(
{key: item for key, item in entry.items() if key != "params"}
)
with self.assertRaises(SemanticsError):
self.load_doc(broken)
def test_prompt_overrides_are_input_names(self):
doc = _minimal()
doc["samplers"]["KSampler"]["prompt_overrides"] = ["wildcard"]
sampler = self.load_doc(doc).samplers["KSampler"]
self.assertEqual(sampler["prompt_overrides"], ("wildcard",))
for value in ([], "wildcard", [""], ["a", "a"], [1]):
with self.subTest(value=value):
doc["samplers"]["KSampler"]["prompt_overrides"] = value
with self.assertRaises(SemanticsError):
self.load_doc(doc)
doc["samplers"]["KSampler"].pop("prompt_overrides")
doc["samplers"]["KSampler"]["fact_overrides"] = ["script", "sigmas"]
sampler = self.load_doc(doc).samplers["KSampler"]
self.assertEqual(sampler["fact_overrides"], ("script", "sigmas"))
doc["samplers"]["KSampler"]["fact_overrides"] = ["script", "script"]
with self.assertRaises(SemanticsError):
self.load_doc(doc)
doc["samplers"]["KSampler"].pop("fact_overrides")
doc["samplers"]["KSampler"]["overrides"] = ["wildcard"]
self.assertRejected(doc, "unknown field")
if __name__ == "__main__":
unittest.main()
+9 -67
View File
@@ -17,14 +17,6 @@ def graph(positive="cat", negative="blur"):
}
def sampled_graph(positive="cat", negative="blur"):
"""A KSampler with a model link, as real workflows have."""
g = graph(positive, negative)
g["4"] = {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "m"}}
g["3"]["inputs"]["model"] = ["4", 0]
return g
class TestPairScan(AdminAPITestCase):
def test_pair_scan_repeated_is_unchanged_and_empty_negative_is_supported(self):
path = self.output_dir / "pair.png"
@@ -36,66 +28,16 @@ class TestPairScan(AdminAPITestCase):
self.assertEqual(pairs[0]["negative_text"], "")
self.assertEqual(pairs[0]["run_count"], 0)
def _scanned(self, name, prompt_graph):
meta = {"prompt": json.dumps(prompt_graph)}
return self.api._ingest_scanned_file(self.output_dir / name, meta)
def _texts(self):
return [p["text"] for p in self.api.db.get_recent_prompts()["prompts"]]
def test_samplers_sharing_one_prompt_link_that_prompt(self):
g = sampled_graph()
g["5"] = {**g["3"]}
self.assertEqual(self._scanned("multi.png", g), "added")
self.assertEqual(self._texts(), ["cat"])
def test_samplers_with_different_prompts_link_the_first_sampler_prompt(self):
# The prompt the inspector and the legacy panels show for this file.
g = sampled_graph()
g["6"] = {"class_type": "CLIPTextEncode", "inputs": {"text": "dog"}}
g["12"] = {**g["3"], "inputs": {**g["3"]["inputs"], "positive": ["6", 0]}}
self.assertEqual(self._scanned("different.png", g), "added")
self.assertEqual(self._texts(), ["cat"])
def test_an_unsupported_pass_through_still_links_the_traced_prompt(self):
g = sampled_graph()
g["7"] = {
"class_type": "MyConditioningTweak",
"inputs": {"conditioning": ["1", 0]},
}
g["3"]["inputs"]["positive"] = ["7", 0]
g["5"] = {**g["3"]}
self.assertEqual(self._scanned("tweak.png", g), "added")
self.assertEqual(self._texts(), ["cat"])
def test_a_graph_without_a_traceable_prompt_saves_nothing(self):
g = sampled_graph()
g["5"] = {**g["3"]}
for sampler in ("3", "5"):
g[sampler]["inputs"]["positive"] = ["9", 0]
g["9"] = {
"class_type": "BasicGuider",
"inputs": {"width": 2048, "height": 2048},
}
self.assertEqual(self._scanned("untraceable.png", g), "ambiguous")
self.assertEqual(self.api.db.list_prompt_pairs()["total"], 0)
self.assertEqual(self._texts(), [])
def test_numeric_widgets_are_never_stored_as_a_prompt(self):
# The pre-redesign scan stored "2048" from a resolution widget here.
g = {
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": ["8", 0]}},
"8": {"class_type": "DisplayText", "inputs": {"text_size": 2048}},
"3": {
"class_type": "SamplerCustomAdvanced",
"inputs": {"guider": ["4", 0], "sigmas": ["5", 0]},
},
"4": {"class_type": "BasicGuider", "inputs": {"conditioning": ["1", 0]}},
}
self.assertIn(
self._scanned("numbers.png", g), ("ambiguous", "unresolved", "found")
def test_ambiguous_sampler_scan_does_not_save_a_guess(self):
g = graph()
g["4"] = {**g["3"]}
meta = {"prompt": json.dumps(g)}
self.assertEqual(
self.api._ingest_scanned_file(self.output_dir / "multi.png", meta),
"ambiguous",
)
self.assertEqual(self._texts(), [])
self.assertEqual(self.api.db.list_prompt_pairs()["total"], 0)
self.assertEqual(self.api.db.get_recent_prompts()["prompts"], [])
def test_batch_exposes_pair_counts_and_actual_unchanged_status(self):
meta = {"parameters": "cat\nNegative prompt: blur\nSteps: 20, Sampler: Euler"}
File diff suppressed because it is too large Load Diff
-30
View File
@@ -1,30 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
// Concept comparisons measure layout, and layout depends on the fonts a browser falls back to (the concept names Inter,
// which neither environment ships). Page-workflow jobs therefore run in the same pinned Playwright image as local runs,
// so CI and a developer machine resolve identical fonts and engines.
const workflow = readFileSync(new URL('../../.github/workflows/viewerjs-check.yml', import.meta.url), 'utf8');
const lock = JSON.parse(readFileSync(new URL('../../tools/vendor/package-lock.json', import.meta.url), 'utf8'));
const version = lock.packages['node_modules/@playwright/test'].version;
function job(name) {
const match = workflow.match(new RegExp(`^ ${name}:\\n([\\s\\S]*?)(?=^ [a-z][a-z-]*:\\n|(?![\\s\\S]))`, 'm'));
assert.ok(match, `${name} job exists`);
return match[1];
}
for (const name of ['frontend-coverage', 'browser-workflows']) {
test(`${name} runs in the pinned Playwright image matching @playwright/test`, () => {
const body = job(name);
assert.match(body, new RegExp(`^ container:\\n image: mcr\\.microsoft\\.com/playwright:v${version.replaceAll('.', '\\.')}-noble$`, 'm'));
assert.doesNotMatch(body, /playwright install/, 'browsers and their fonts come from the image, not a runner install');
// Toolcache interpreters are the runner's own builds: their entry points name host paths that do not exist in
// the container (setup-python failed with `spawn .../bin/pip ENOENT`). Use the image's Python and Node.
assert.doesNotMatch(body, /actions\/setup-(python|node)@/);
assert.match(body, /python3 -m venv --without-pip /, 'a user-space venv on the image Python (no ensurepip, no root)');
assert.match(body, /sha256sum -c/, 'the pip wheel that bootstraps the venv is checksum-pinned');
assert.match(body, /node --version \| grep -q '\^v24\\\.'/, 'the image Node is the Node 24 the project requires');
});
}
+2 -20
View File
@@ -19,7 +19,7 @@
},
"comfy-metadata.js": {
"lines": 97,
"branches": 92,
"branches": 91,
"functions": 98,
"statements": 97
},
@@ -199,7 +199,7 @@
},
"workspace/images.mjs": {
"lines": 97,
"branches": 89,
"branches": 88,
"functions": 98,
"statements": 97
},
@@ -298,23 +298,5 @@
"branches": 95,
"functions": 98,
"statements": 98
},
"workspace/vocabulary-dialog.mjs": {
"lines": 98,
"branches": 88,
"functions": 95,
"statements": 98
},
"workspace/vocabulary-state.mjs": {
"lines": 98,
"branches": 96,
"functions": 98,
"statements": 98
},
"workspace/vocabulary.mjs": {
"lines": 98,
"branches": 91,
"functions": 93,
"statements": 98
}
}
-943
View File
@@ -1,943 +0,0 @@
"""Generation parameters traced from ComfyUI metadata: the browser parser in Python.
A port of ``extractGenerationParams`` in ``web/js/comfy-metadata.js``, the parser
behind the gallery, admin and metadata pages. For the same records it returns
what that function returns; ``tests/test_generation_params_js_parity.py``
enforces it against the JavaScript itself.
It traces the API ``prompt`` graph from the sampler: the positive and negative
inputs back to their text nodes, the model link back to a loader, and the
sampler settings, following primitives and pass-through nodes. Fields the API
graph cannot give are read from the UI ``workflow`` graph.
The result depends on JavaScript value semantics, so those are reproduced
explicitly: property order, truthiness, ``String()``, ``trim()``, strict
equality, ``Map``/``Set`` keys, and the inputs on which the JavaScript throws
(raised here as ``GenerationParamsError``). The recursive tracing runs on an
explicit stack, so a crafted graph cannot exhaust the Python stack.
"""
import json
import math
import re
from types import MappingProxyType
__all__ = [
"PLACEHOLDERS",
"GenerationParamsError",
"extract_generation_params",
"extract_from_records",
"parse_js_json",
]
PLACEHOLDERS = MappingProxyType(
{
"positivePrompt": "No prompt found",
"negativePrompt": "No negative prompt found",
"checkpoint": "Unknown",
"steps": "Unknown",
"cfgScale": "Unknown",
"sampler": "Unknown",
"seed": "Unknown",
}
)
# Upper bound on nodes expanded per lookup; crafted files can contain any graph.
MAX_VISITS = 500
MODEL_NAME_KEYS = ("ckpt_name", "unet_name", "model_name", "gguf_name")
SEED_CONTROL_VALUES = frozenset(("fixed", "increment", "decrement", "randomize"))
PASS_THROUGH_TYPES = frozenset(("Reroute", "GetNode", "SetNode"))
MODE_MUTED = 2
MODE_BYPASSED = 4
# Widget order for core nodes, used when a workflow carries no widget names.
PROMPT_MANAGER_WIDGETS = (
"text",
"category",
"tags",
"search_text",
"prepend_text",
"append_text",
)
WIDGET_POSITIONS = {
"KSampler": ("seed", "steps", "cfg", "sampler_name", "scheduler", "denoise"),
"KSamplerAdvanced": (
"add_noise",
"noise_seed",
"steps",
"cfg",
"sampler_name",
"scheduler",
"start_at_step",
"end_at_step",
"return_with_leftover_noise",
),
"CLIPTextEncode": ("text",),
"PromptManager": PROMPT_MANAGER_WIDGETS,
"PromptManagerText": PROMPT_MANAGER_WIDGETS,
"PromptManagerFocused": PROMPT_MANAGER_WIDGETS,
"PromptManagerSplit": PROMPT_MANAGER_WIDGETS,
"PromptManagerPair": (
"text",
"negative_text",
"category",
"tags",
"search_text",
"prepend_text",
"append_text",
),
"CheckpointLoader": ("config_name", "ckpt_name"),
"UNETLoader": ("unet_name", "weight_dtype"),
}
# JavaScript regular expressions without the ``u`` flag fold case in ASCII only.
_FLAGS = re.IGNORECASE | re.ASCII
TEXT_KEYS = re.compile(r"(text|string|prompt|value)(_?[a-z0-9]+)?", _FLAGS)
_PROMPT_MANAGER = re.compile(r"promptmanager", _FLAGS)
_KSAMPLER = re.compile(r"ksampler", _FLAGS)
_CHECKPOINT_LOADER = re.compile(r"checkpointloader", _FLAGS)
_UNET = re.compile(r"unet", _FLAGS)
_SEED_NAME = re.compile(r"seed\Z")
_ARRAY_INDEX = re.compile(r"(?:0|[1-9][0-9]*)\Z")
_MAX_ARRAY_INDEX = 2**32 - 2
_MAX_SAFE_INTEGER = 2**53
# String.prototype.trim() removes WhiteSpace and LineTerminator code points.
_JS_WHITESPACE = "\t\n\v\f\r          " "    

   "
# Properties every object literal inherits; reading one yields a function or object.
_OBJECT_PROTOTYPE_KEYS = frozenset(
(
"constructor",
"hasOwnProperty",
"isPrototypeOf",
"propertyIsEnumerable",
"toLocaleString",
"toString",
"valueOf",
"__proto__",
"__defineGetter__",
"__defineSetter__",
"__lookupGetter__",
"__lookupSetter__",
)
)
class GenerationParamsError(ValueError):
"""The browser parser throws on this input, so there is no result."""
class _Undefined:
"""JavaScript ``undefined``: a missing property, distinct from JSON ``null``."""
__slots__ = ()
def __repr__(self):
return "undefined"
UNDEFINED = _Undefined()
# --- JavaScript value semantics -------------------------------------------------
def _is_number(value):
return isinstance(value, (int, float)) and not isinstance(value, bool)
def _nullish(value):
return value is None or value is UNDEFINED
def _truthy(value):
if value is None or value is UNDEFINED or value is False:
return False
if _is_number(value):
return value != 0 and not (isinstance(value, float) and math.isnan(value))
if isinstance(value, str):
return value != ""
return True
def _typeof(value):
if value is UNDEFINED:
return "undefined"
if isinstance(value, bool):
return "boolean"
if _is_number(value):
return "number"
if isinstance(value, str):
return "string"
return "object"
def _number_string(value):
"""``String(number)`` for a double, per the ECMAScript Number::toString rules."""
if isinstance(value, int):
return str(value)
if math.isnan(value):
return "NaN"
if math.isinf(value):
return "Infinity" if value > 0 else "-Infinity"
if value == 0:
return "0"
sign = "-" if value < 0 else ""
text = repr(abs(value))
mantissa, _, exponent = text.partition("e")
whole, _, fraction = mantissa.partition(".")
raw = whole + fraction
leading = len(raw) - len(raw.lstrip("0"))
digits = raw.lstrip("0").rstrip("0")
point = len(whole) - leading + int(exponent or 0)
count = len(digits)
if count <= point <= 21:
body = digits + "0" * (point - count)
elif 0 < point <= 21:
body = digits[:point] + "." + digits[point:]
elif -6 < point <= 0:
body = "0." + "0" * -point + digits
else:
power = point - 1
mark = ("+" if power >= 0 else "-") + str(abs(power))
body = (digits if count == 1 else digits[0] + "." + digits[1:]) + "e" + mark
return sign + body
def _js_string(value):
"""``String(value)`` for any JSON-derived value."""
if value is UNDEFINED:
return "undefined"
if value is None:
return "null"
if value is True:
return "true"
if value is False:
return "false"
if _is_number(value):
return _number_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 "[object Object]"
def _key(value):
return value if isinstance(value, str) else _js_string(value)
def _index(key):
"""The array index a property key names, or None."""
if (
_is_number(key)
and not isinstance(key, bool)
and float(key).is_integer()
and key >= 0
):
number = int(key)
return number if number <= _MAX_ARRAY_INDEX else None
text = _key(key)
if _ARRAY_INDEX.match(text) and int(text) <= _MAX_ARRAY_INDEX:
return int(text)
return None
def _get(obj, key):
"""Property read ``obj[key]``; reading from null or undefined throws."""
if obj is None or obj is UNDEFINED:
raise GenerationParamsError(f"cannot read {key!r} of {_js_string(obj)}")
if isinstance(obj, dict):
return obj.get(_key(key), UNDEFINED)
if isinstance(obj, (list, str)):
if key == "length":
return len(obj)
index = _index(key)
return obj[index] if index is not None and index < len(obj) else UNDEFINED
return UNDEFINED
def _has(obj, key):
"""``key in obj``; throws on primitives."""
if isinstance(obj, dict):
return _key(key) in obj
if isinstance(obj, list):
index = _index(key)
return key == "length" or (index is not None and index < len(obj))
raise GenerationParamsError(f"cannot use 'in' on {_typeof(obj)}")
def _own(obj, key):
"""``Object.prototype.hasOwnProperty.call(obj, key)``."""
if obj is None or obj is UNDEFINED:
raise GenerationParamsError("hasOwnProperty on null or undefined")
if isinstance(obj, dict):
return _key(key) in obj
if isinstance(obj, (list, str)):
index = _index(key)
return key == "length" or (index is not None and index < len(obj))
return False
def _ordered_keys(obj):
"""Own property order: array indexes ascending, then other keys as inserted."""
indexes = sorted(
(k for k in obj if _ARRAY_INDEX.match(k) and int(k) <= _MAX_ARRAY_INDEX),
key=int,
)
marked = set(indexes)
return indexes + [k for k in obj if k not in marked]
def _entries(obj):
"""``Object.entries(obj)``."""
if obj is None or obj is UNDEFINED:
raise GenerationParamsError("Object.entries of null or undefined")
if isinstance(obj, dict):
return [(k, obj[k]) for k in _ordered_keys(obj)]
if isinstance(obj, (list, str)):
return [(str(i), item) for i, item in enumerate(obj)]
return []
def _values(obj):
"""``Object.values(obj)``."""
return [value for _, value in _entries(obj)]
def _strict_equal(left, right):
"""``left === right``."""
if left is UNDEFINED or right is UNDEFINED or left is None or right is None:
return left is right
if isinstance(left, bool) or isinstance(right, bool):
return isinstance(left, bool) and isinstance(right, bool) and left == right
if _is_number(left) and _is_number(right):
return left == right
if isinstance(left, str) and isinstance(right, str):
return left == right
return left is right
def _same_value_key(value):
"""A hashable key with ``Map``/``Set`` (SameValueZero) equality."""
if value is UNDEFINED:
return ("undefined", None)
if value is None:
return ("null", None)
if isinstance(value, bool):
return ("boolean", value)
if _is_number(value):
return (
("nan", None)
if isinstance(value, float) and math.isnan(value)
else ("number", value)
)
if isinstance(value, str):
return ("string", value)
return ("object", id(value))
def _trim(text):
return text.strip(_JS_WHITESPACE)
def _first_truthy(*values):
"""``a || b || ...``: the first truthy operand, else the last."""
for value in values:
if _truthy(value):
return value
return values[-1]
def _and_chain(obj, *steps):
"""``a && a[k1] && a[k1][k2] ...``: stops at the first falsy operand."""
value = obj
for step in steps:
if not _truthy(value):
return value
value = _get(value, step)
return value
def _coalesce(value, fallback):
"""``value ?? fallback()``; the fallback runs only when needed."""
return fallback() if _nullish(value) else value
def _array(value):
"""A value used with an Array method; anything else throws."""
if isinstance(value, list):
return value
raise GenerationParamsError(f"{_typeof(value)} has no array methods")
def _iterate(value):
"""``for (const x of value)``: arrays and strings iterate, other values throw."""
if isinstance(value, (list, str)):
return value
raise GenerationParamsError(f"{_typeof(value)} is not iterable")
def _set(obj, key, value):
"""``obj[key] = value`` on an object literal (``__proto__`` is not an own key)."""
name = _key(key)
if name == "__proto__" and name not in obj:
return
obj[name] = value
def _matches(pattern, value):
"""``pattern.test(value || "")``."""
return pattern.search(_js_string(value if _truthy(value) else "")) is not None
def _run(generator):
"""Drive generator-based recursion on an explicit stack, not the call stack."""
stack = [generator]
value = None
while stack:
try:
request = stack[-1].send(value)
except StopIteration as done:
stack.pop()
value = done.value
continue
stack.append(request)
value = None
return value
# --- Tracing ---------------------------------------------------------------------
def _is_link(value):
return (
isinstance(value, list)
and len(value) == 2
and (isinstance(value[0], str) or _is_number(value[0]))
and _is_number(value[1])
)
def _is_sampler(node):
"""A KSampler or a guider (CFGGuider); the model link excludes pass-throughs."""
if not _truthy(node):
return False
inputs = _get(node, "inputs")
return (
_truthy(inputs)
and _is_link(_get(inputs, "positive"))
and _is_link(_get(inputs, "negative"))
and _is_link(_get(inputs, "model"))
)
def _is_custom_sampler(node):
"""SamplerCustomAdvanced-style nodes split settings across noise/sampler/sigmas."""
if not _truthy(node):
return False
inputs = _get(node, "inputs")
return (
_truthy(inputs)
and _is_link(_get(inputs, "guider"))
and _is_link(_get(inputs, "sigmas"))
)
def _workflow_nodes(workflow):
if not _truthy(workflow):
return []
nodes = _get(workflow, "nodes")
return nodes if isinstance(nodes, list) else []
def _widgets_of(node):
values = _get(node, "widgets_values")
return values if isinstance(values, list) else []
class _Context:
"""The graph being traced and recovered PromptManager widget text."""
__slots__ = ("graph", "widget_text")
def __init__(self, graph, widget_text):
self.graph = graph
self.widget_text = widget_text
def _create_context(graph, workflow):
"""Recover PromptManager text overwritten by the pre-3.2.3 SaveImage patch.
The signature is two or more PromptManager nodes with identical API text
whose workflow widgets differ; only those get their widget text back.
Otherwise the API graph is what actually ran and always wins.
"""
widgets = {}
for node in _workflow_nodes(workflow):
text = _get(_widgets_of(node), 0)
node_type = _get(node, "type")
if (
not _strict_equal(node_type, "PromptManagerPair")
and _matches(_PROMPT_MANAGER, node_type)
and isinstance(text, str)
):
widgets[_js_string(_get(node, "id"))] = text
by_api_text = {}
for node_id in widgets:
api_text = _and_chain(graph, node_id, "inputs", "text")
if isinstance(api_text, str):
by_api_text.setdefault(api_text, []).append(node_id)
widget_text = {}
for ids in by_api_text.values():
if len(ids) > 1 and len({widgets[node_id] for node_id in ids}) > 1:
for node_id in ids:
widget_text[node_id] = widgets[node_id]
return _Context(graph, widget_text)
def _enter(context, link, seen):
"""``(node_id, node)`` for a link; None when unresolved or already expanded."""
if not _is_link(link) or len(seen) >= MAX_VISITS:
return None
node_id = _js_string(link[0])
node = _get(context.graph, node_id)
key = f"{node_id}:{_js_string(link[1])}"
if not _truthy(node) or not _truthy(_get(node, "inputs")) or key in seen:
return None
seen.add(key)
return node_id, node
def _resolve_scalar(context, value, name, seen):
"""A scalar input, following a link; an input named like the field wins."""
if not _is_link(value):
return value
entered = _enter(context, value, seen)
if entered is None:
return UNDEFINED
inputs = _get(entered[1], "inputs")
if _truthy(name) and _own(inputs, name):
return (yield _resolve_scalar(context, _get(inputs, name), name, seen))
for item in _values(inputs):
resolved = yield _resolve_scalar(context, item, name, seen)
if resolved is not UNDEFINED and _typeof(resolved) != "object":
return resolved
return UNDEFINED
def _resolve_linked_input(context, link, keys):
"""The first of ``keys`` on the node a link points to, resolving further links."""
seen = set()
entered = _enter(context, link, seen)
if entered is None:
return UNDEFINED
inputs = _get(entered[1], "inputs")
key = next((k for k in keys if _has(inputs, k)), None)
if key is None:
return UNDEFINED
return (yield _resolve_scalar(context, _get(inputs, key), key, seen))
def _resolve_string(context, value, seen):
"""A string input; text-producing nodes may be chained (concat, primitives)."""
if isinstance(value, str):
return value
entered = _enter(context, value, seen)
if entered is None:
return ""
node_id, node = entered
inputs = _get(node, "inputs")
if _strict_equal(_get(node, "class_type"), "PromptManagerPair"):
if _strict_equal(value[1], 3):
return (yield _resolve_string(context, _get(inputs, "negative_text"), seen))
if _strict_equal(value[1], 2):
return (yield _compose_node_text(context, node_id, node, seen))
return ""
if _has(inputs, "text"):
return (yield _compose_node_text(context, node_id, node, seen))
delimiter = _get(inputs, "delimiter")
if not isinstance(delimiter, str):
delimiter = " "
parts = []
for key, item in _entries(inputs):
if TEXT_KEYS.fullmatch(key):
parts.append((yield _resolve_string(context, item, seen)))
return delimiter.join(part for part in parts if _trim(part))
def _compose_node_text(context, node_id, node, seen):
"""An encoder's text, joined like PromptManager: prepend + text + append."""
inputs = _get(node, "inputs")
if node_id in context.widget_text:
raw = context.widget_text[node_id]
else:
raw = yield _resolve_string(context, _get(inputs, "text"), seen)
text = _trim(raw)
if not text:
return ""
# One shared set even across siblings keeps crafted chains linear.
prepend = _trim(
(yield _resolve_string(context, _get(inputs, "prepend_text"), seen))
)
append = _trim((yield _resolve_string(context, _get(inputs, "append_text"), seen)))
# Older saves stored the already-combined prompt in ``text``.
parts = (
prepend if prepend and not text.startswith(prepend) else "",
text,
append if append and not text.endswith(append) else "",
)
return " ".join(part for part in parts if part)
def _trace_conditioning(context, link, role, seen):
"""Follow a conditioning link upstream to the encoder that produced it."""
entered = _enter(context, link, seen)
if entered is None:
return ""
node_id, node = entered
inputs = _get(node, "inputs")
if _strict_equal(_get(node, "class_type"), "PromptManagerPair"):
if _strict_equal(link[1], 1):
return (yield _resolve_string(context, _get(inputs, "negative_text"), seen))
if _strict_equal(link[1], 0):
return (yield _compose_node_text(context, node_id, node, seen))
return ""
if _has(inputs, "text"):
return (yield _compose_node_text(context, node_id, node, seen))
# Pass-through nodes (ControlNet apply, conditioning combine/set area, ...).
following = _first_truthy(
_get(inputs, role),
_get(inputs, "conditioning"),
_get(inputs, "conditioning_to"),
_get(inputs, "conditioning_1"),
)
return (yield _trace_conditioning(context, following, role, seen))
def _trace_model_name(context, link, seen):
"""Follow the model link upstream (through LoRA loaders etc.) to a loader."""
entered = _enter(context, link, seen)
if entered is None:
return UNDEFINED
inputs = _get(entered[1], "inputs")
key = next((k for k in MODEL_NAME_KEYS if isinstance(_get(inputs, k), str)), None)
if key is not None:
return _get(inputs, key)
return (yield _trace_model_name(context, _get(inputs, "model"), seen))
def _find_any_model_name(nodes):
for node in nodes:
inputs = _and_chain(node, "inputs")
if not _truthy(inputs):
inputs = {}
key = next(
(k for k in MODEL_NAME_KEYS if isinstance(_get(inputs, k), str)), None
)
if key is not None:
return _get(inputs, key)
return UNDEFINED
def _from_prompt_graph(graph, workflow):
context = _create_context(graph, workflow)
nodes = _values(graph)
custom = next((node for node in nodes if _is_custom_sampler(node)), UNDEFINED)
guider = UNDEFINED
if custom is not UNDEFINED:
entered = _enter(context, _get(_get(custom, "inputs"), "guider"), set())
guider = entered[1] if entered is not None else UNDEFINED
sampler = (
guider
if _is_sampler(guider)
else next((n for n in nodes if _is_sampler(n)), UNDEFINED)
)
if sampler is UNDEFINED:
loose = next(
(
n
for n in nodes
if _truthy(n)
and _truthy(_get(n, "inputs"))
and _matches(_KSAMPLER, _get(n, "class_type"))
),
UNDEFINED,
)
inputs = _and_chain(loose, "inputs")
if not _truthy(inputs):
inputs = {}
seed = _coalesce(_get(inputs, "seed"), lambda: _get(inputs, "noise_seed"))
return {
"checkpoint": _find_any_model_name(nodes),
"seed": (yield _resolve_scalar(context, seed, "seed", set())),
"steps": (
yield _resolve_scalar(context, _get(inputs, "steps"), "steps", set())
),
"cfgScale": (
yield _resolve_scalar(context, _get(inputs, "cfg"), "cfg", set())
),
"sampler": (
yield _resolve_scalar(
context, _get(inputs, "sampler_name"), "sampler_name", set()
)
),
}
inputs = _get(sampler, "inputs")
def from_custom(field, keys):
if custom is UNDEFINED:
return UNDEFINED
return _run(
_resolve_linked_input(context, _get(_get(custom, "inputs"), field), keys)
)
result = {
"positivePrompt": (
yield _trace_conditioning(
context, _get(inputs, "positive"), "positive", set()
)
),
"negativePrompt": (
yield _trace_conditioning(
context, _get(inputs, "negative"), "negative", set()
)
),
}
model = yield _trace_model_name(context, _get(inputs, "model"), set())
result["checkpoint"] = model if _truthy(model) else _find_any_model_name(nodes)
seed_value = _coalesce(_get(inputs, "seed"), lambda: _get(inputs, "noise_seed"))
seed_name = "seed" if _has(inputs, "seed") else "noise_seed"
seed = yield _resolve_scalar(context, seed_value, seed_name, set())
result["seed"] = _coalesce(
seed, lambda: from_custom("noise", ("noise_seed", "seed"))
)
steps = yield _resolve_scalar(context, _get(inputs, "steps"), "steps", set())
result["steps"] = _coalesce(steps, lambda: from_custom("sigmas", ("steps",)))
result["cfgScale"] = yield _resolve_scalar(
context, _get(inputs, "cfg"), "cfg", set()
)
sampler_name = yield _resolve_scalar(
context, _get(inputs, "sampler_name"), "sampler_name", set()
)
result["sampler"] = _coalesce(
sampler_name, lambda: from_custom("sampler", ("sampler_name",))
)
return result
# --- UI workflow graph -------------------------------------------------------------
def _widget_names(node):
inputs = _first_truthy(_get(node, "inputs"), [])
from_inputs = [
_get(_get(item, "widget"), "name")
for item in _array(inputs)
if _truthy(item)
and _truthy(_get(item, "widget"))
and _truthy(_get(_get(item, "widget"), "name"))
]
if from_inputs:
return from_inputs
node_type = _get(node, "type")
key = _js_string(node_type)
if key in _OBJECT_PROTOTYPE_KEYS:
raise GenerationParamsError(f"widget positions of {key!r} are not iterable")
if key in WIDGET_POSITIONS:
return list(WIDGET_POSITIONS[key])
if _matches(_CHECKPOINT_LOADER, node_type):
return ["ckpt_name"]
if _matches(_UNET, node_type):
return ["unet_name"]
return []
def _named_widgets(node):
"""Map widget values to input names, skipping control_after_generate."""
named = _get(node, "widgets_values_named")
if _truthy(named) and isinstance(named, dict):
return dict(named)
values = _widgets_of(node)
out = {}
position = 0
for name in _iterate(_widget_names(node)):
if position >= len(values):
break
_set(out, name, values[position])
position += 1
following = values[position] if position < len(values) else UNDEFINED
if (
_SEED_NAME.search(_js_string(name))
and isinstance(following, str)
and following in SEED_CONTROL_VALUES
):
position += 1
return out
def _workflow_to_graph(workflow):
"""Convert a UI workflow ({nodes, links}) into the API graph shape.
Set/Get pairs, Reroutes and bypassed nodes resolve to their real source;
muted nodes are dropped.
"""
nodes = [
n
for n in _workflow_nodes(workflow)
if _truthy(n) and not _nullish(_get(n, "id"))
]
by_id = {}
for node in nodes:
by_id[_js_string(_get(node, "id"))] = node
links = {}
raw_links = _get(workflow, "links")
for link in raw_links if isinstance(raw_links, list) else []:
if isinstance(link, list):
links[_same_value_key(_get(link, 0))] = (
_get(link, 1),
_get(link, 2),
_get(link, 5),
)
elif _truthy(link) and _typeof(link) == "object":
links[_same_value_key(_get(link, "id"))] = (
_get(link, "origin_id"),
_get(link, "origin_slot"),
_get(link, "type"),
)
setters = {}
for node in nodes:
if _strict_equal(_get(node, "type"), "SetNode"):
setters[_same_value_key(_get(_widgets_of(node), 0))] = _and_chain(
node, "inputs", 0, "link"
)
def source(link_id):
hops = 0
while True:
link = links.get(_same_value_key(link_id))
node = by_id.get(_js_string(link[0])) if link is not None else None
if (
node is None
or _strict_equal(_get(node, "mode"), MODE_MUTED)
or hops > MAX_VISITS
):
return UNDEFINED
hops += 1
node_type = _get(node, "type")
if _strict_equal(node_type, "Reroute"):
link_id = _and_chain(node, "inputs", 0, "link")
continue
if _strict_equal(node_type, "GetNode"):
link_id = setters.get(
_same_value_key(_get(_widgets_of(node), 0)), UNDEFINED
)
continue
if _strict_equal(_get(node, "mode"), MODE_BYPASSED):
link_type = link[2]
same = next(
(
item
for item in _array(_first_truthy(_get(node, "inputs"), []))
if _truthy(item)
and not _nullish(_get(item, "link"))
and (
not _truthy(link_type)
or _strict_equal(link_type, "*")
or _strict_equal(_get(item, "type"), link_type)
)
),
UNDEFINED,
)
if same is UNDEFINED:
return UNDEFINED
link_id = _get(same, "link")
continue
return [_js_string(_get(node, "id")), link[1]]
graph = {}
for node in nodes:
mode = _get(node, "mode")
node_type = _get(node, "type")
if (
_strict_equal(mode, MODE_MUTED)
or _strict_equal(mode, MODE_BYPASSED)
or (isinstance(node_type, str) and node_type in PASS_THROUGH_TYPES)
):
continue
inputs = _named_widgets(node)
for item in _iterate(_first_truthy(_get(node, "inputs"), [])):
if not _truthy(item) or _nullish(_get(item, "link")):
continue
found = source(_get(item, "link"))
if _truthy(found):
_set(inputs, _get(item, "name"), found)
_set(graph, _get(node, "id"), {"class_type": node_type, "inputs": inputs})
return graph
# --- Public API ----------------------------------------------------------------------
def _has_value(value):
return not _nullish(value) and not (isinstance(value, str) and value == "")
def extract_generation_params(data):
"""Prompts, model and sampler settings from parsed ``prompt``/``workflow`` records.
``data`` holds the parsed records (``{"prompt": ..., "workflow": ...}``). Each
field falls back to its placeholder when neither graph provides it.
Raises ``GenerationParamsError`` where the browser parser throws.
"""
data = data if _truthy(data) else {}
prompt = _get(data, "prompt")
workflow = _get(data, "workflow")
primary = {}
if _truthy(prompt) and _typeof(prompt) == "object":
primary = _run(_from_prompt_graph(prompt, workflow))
fallback = {}
if _truthy(workflow):
fallback = _run(_from_prompt_graph(_workflow_to_graph(workflow), None))
merged = {}
for key, placeholder in PLACEHOLDERS.items():
value = primary.get(key, UNDEFINED)
if not _has_value(value):
value = fallback.get(key, UNDEFINED)
merged[key] = value if _has_value(value) else placeholder
return merged
def _reject_constant(name):
raise ValueError(f"{name} is not JSON")
def _double_integer(text):
"""Integers parse as doubles in JavaScript; beyond 2**53 they round."""
value = int(text)
return value if -_MAX_SAFE_INTEGER <= value <= _MAX_SAFE_INTEGER else float(text)
def parse_js_json(text):
"""``JSON.parse(text)``, or None where it throws (or the text is ``null``)."""
if not isinstance(text, str):
return None
try:
return json.loads(
text, parse_constant=_reject_constant, parse_int=_double_integer
)
except (ValueError, RecursionError):
return None
def extract_from_records(prompt_text, workflow_text):
"""``extract_generation_params`` for raw ``prompt``/``workflow`` record text.
A record that is absent or fails to parse is treated as absent, as the
browser pages do.
"""
data = {}
for key, text in (("prompt", prompt_text), ("workflow", workflow_text)):
if isinstance(text, str):
data[key] = parse_js_json(text)
return extract_generation_params(data)
-111
View File
@@ -1,111 +0,0 @@
"""User-facing lines for non-resolved prompt outcomes, from the closed vocabulary.
The inspector shows one line per unresolved role in place of the generic
"Prompt could not be resolved." Ambiguous and absent roles keep their status
copy, so a candidate is never presented as a confirmed prompt. The wire
projection accepts exactly the closed ``reason_code`` vocabulary and names a
blocking node only beside a code that names one.
"""
from typing import Any, Optional
from .prompt_pair_resolver import (
EMPTY_POSITIVE_REASON,
MULTISTAGE_UNATTRIBUTED_REASON,
SEMANTICS_UNAVAILABLE_REASON,
)
REASON_CODES = frozenset(
{
"work-limit",
"depth-limit",
"text-limit",
"graph-ceiling",
"unsupported-node",
"runtime-value",
"composite",
"no-sampler",
"malformed",
"multistage-differs",
}
)
LIMIT_CODES = frozenset({"work-limit", "depth-limit", "text-limit", "graph-ceiling"})
# Codes whose outcome names the blocking node: ``node: {id, class_type}``.
NAMED_CODES = frozenset({"unsupported-node", "runtime-value", "composite"})
# Bound for a node ID or class name on the wire and in a line.
CLASS_NAME_LENGTH = 200
# Bound for a role's line on the wire, in UTF-16 code units (the client's count).
MESSAGE_LENGTH = 512
UNREADABLE_METADATA_REASON = "Media metadata could not be read"
_NODE_KEYS = frozenset({"id", "class_type"})
_NAMED_LINES = {
"unsupported-node": "Prompt uses a node the inspector does not support",
"runtime-value": "Prompt is chosen at run time",
"composite": "Prompt combines several texts",
}
_CODED_LINES = {
**dict.fromkeys(LIMIT_CODES, "Prompt exceeds inspection limits."),
"no-sampler": "No supported sampler was found.",
"malformed": "Prompt metadata in this file is malformed.",
"multistage-differs": "Sampler stages have different prompts.",
}
# Uncoded outcomes, keyed by the resolver's exact reason.
_UNCODED_LINES = {
EMPTY_POSITIVE_REASON: "Positive prompt is empty.",
MULTISTAGE_UNATTRIBUTED_REASON: (
"Several sampler stages were found; their prompts were not compared."
),
SEMANTICS_UNAVAILABLE_REASON: "The inspector's node table is unavailable.",
UNREADABLE_METADATA_REASON: "Metadata in this file could not be read.",
}
def _text(value: Any) -> Optional[str]:
return value if isinstance(value, str) else None
def _class_name(node: Any) -> Optional[str]:
if not isinstance(node, dict):
return None
name = node.get("class_type")
return name[:CLASS_NAME_LENGTH] if isinstance(name, str) and name else None
def outcome_message(resolution: Any) -> Optional[str]:
"""One line for an unresolved outcome, or ``None`` to keep the status copy."""
if not isinstance(resolution, dict) or resolution.get("state") != "unresolved":
return None
code = _text(resolution.get("reason_code"))
if code in _NAMED_LINES:
name = _class_name(resolution.get("node"))
return f"{_NAMED_LINES[code]} ({name})." if name else f"{_NAMED_LINES[code]}."
if code is None:
return _UNCODED_LINES.get(_text(resolution.get("reason")))
return _CODED_LINES.get(code)
def _node(code: Optional[str], node: Any) -> Optional[dict]:
if code not in NAMED_CODES or not isinstance(node, dict) or set(node) != _NODE_KEYS:
return None
node_id, name = node["id"], node["class_type"]
if not isinstance(node_id, str) or not node_id:
return None
if name is not None and not isinstance(name, str):
return None
return {
"id": node_id[:CLASS_NAME_LENGTH],
"class_type": name[:CLASS_NAME_LENGTH] if name else None,
}
def project_resolution(resolution: dict) -> dict:
"""Fail closed: an unknown or contradictory code and its node are dropped."""
code = _text(resolution.get("reason_code"))
if code not in REASON_CODES or resolution.get("state") in ("resolved", "absent"):
code = None
return {
**resolution,
"reason_code": code,
"node": _node(code, resolution.get("node")),
}
+3 -28
View File
@@ -3,9 +3,7 @@
import json
import math
from decimal import Decimal, InvalidOperation
from typing import TYPE_CHECKING, Any, Literal, Optional
from .inspection_outcomes import MESSAGE_LENGTH, project_resolution
from typing import TYPE_CHECKING, Literal
if TYPE_CHECKING:
from .prompt_inspector import InspectionRead
@@ -314,29 +312,6 @@ def _library_projection(library: dict) -> dict:
return result
def _bounded_message(value: Any) -> Optional[str]:
"""A role line as the client accepts it: 1 to 512 UTF-16 code units."""
if not isinstance(value, str) or not value:
return None
value = value[:MESSAGE_LENGTH]
while len(value.encode("utf-16-le", "surrogatepass")) > 2 * MESSAGE_LENGTH:
value = value[:-1]
return value
def _bounded_messages(prompts: dict) -> dict:
"""Keep a role ``message`` only as bounded text on a non-resolved role."""
bounded = {}
for role, side in prompts.items():
if isinstance(side, dict) and "message" in side:
side = {key: item for key, item in side.items() if key != "message"}
message = _bounded_message(prompts[role]["message"])
if message is not None and side.get("status") != "resolved":
side["message"] = message
bounded[role] = side
return bounded
def _encode_inspection(
*,
identity: dict,
@@ -353,8 +328,8 @@ def _encode_inspection(
"inspection_version": 1,
**identity,
"file": _file_projection(file, inspection),
"prompts": _bounded_messages(prompts),
"resolution": project_resolution(resolution),
"prompts": prompts,
"resolution": resolution,
"metadata": metadata,
"library": _library_projection(library),
}
-492
View File
@@ -1,492 +0,0 @@
"""Load and validate the declarative inspection node semantics table (#240).
The table lives once in ``web/js/inspection-semantics.json`` so a browser-local
resolver can load the same data. It maps node classes (or ``(class, output
slot)`` pairs) to a named strategy from the closed ``STRATEGIES`` set plus the
exact input names that strategy reads. Code implements each strategy; adding a
node that reuses one is a data-only change.
Only the inspection resolver profile consumes this table, through
``get_semantics()``, which reads and validates it on first use and caches only a
valid result. Importing this module reads nothing, and the default (ingestion)
profile never imports it, so a missing or bad table can never break ingestion.
An invalid table raises ``SemanticsError`` so a bad edit fails loudly instead of
silently changing what the inspector resolves. The file is bounded in size and
nesting before it is validated. Every class appears in exactly one category;
classes that are not listed stay ``unsupported-node``.
"""
import json
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from types import MappingProxyType
from typing import Any, Callable, Dict, FrozenSet, Mapping, Optional, Tuple, Union
SEMANTICS_PATH = (
Path(__file__).resolve().parents[1] / "web" / "js" / "inspection-semantics.json"
)
SCHEMA_VERSION = 1
# The real table is a few KiB and four levels deep; these only reject nonsense.
MAX_TABLE_BYTES = 1024 * 1024
MAX_NESTING = 8
_NAME_LENGTH = 128
# Comfy output slots are small; this only rejects nonsense, never a real node.
_MAX_SLOT = 255
STRATEGIES: Mapping[str, FrozenSet[str]] = MappingProxyType(
{
"samplers": frozenset({"ksampler", "custom-advanced", "detailer-stage"}),
"guiders": frozenset({"cfg", "basic"}),
"conditioning": frozenset({"passthrough", "zeroed"}),
"selectors": frozenset({"boolean-select"}),
"composites": frozenset({"combine"}),
"encoders": frozenset({"single", "parts-equal", "pm-compose", "flag-parts"}),
"strings": frozenset({"literal", "strip-newlines", "join-multi", "concat"}),
"savers": frozenset({"comfy-counter", "vhs-counter"}),
}
)
# Shapes of the facts each sampler class provides (consumed by comfy-metadata.js).
FACT_SHAPES = frozenset({"ksampler", "custom", "custom-advanced"})
SAVER_PATTERNS = MappingProxyType(
{
"comfy-counter": "{name}_{counter:05}_.{ext}",
"vhs-counter": "{name}_{counter:05}.{ext}",
}
)
_SAMPLER_OPTIONS = frozenset({"prompt_overrides", "fact_overrides"})
_CONFIGURATION_KINDS = frozenset({"noise", "sampler", "sigmas", "guider"})
_SCALAR_KINDS = frozenset({"number", "string"})
_TYPE_FIELDS = frozenset({"type_fields"})
_CLASS_CATEGORIES = ("samplers", "guiders", "selectors", "composites", "savers")
_SLOT_CATEGORIES = ("conditioning", "encoders", "strings")
_SET_CATEGORIES = ("runtime", "non_file_outputs")
_FACTS_TABLES = (
"configuration_outputs",
"model_outputs",
"model_passthroughs",
"scalar_outputs",
)
_CATEGORIES = frozenset(
("schema",)
+ _CLASS_CATEGORIES
+ _SLOT_CATEGORIES
+ _SET_CATEGORIES
+ ("stage_inputs", "facts")
)
# Input names that make an unlisted node a possible sampler stage in an ancestry
# walk (``shaped``: pipes, video embeds and custom-sampler parts carry a prompt
# without a ``positive`` link), and those that do so beside a linked ``model``
# (``with_model``: XlabsSampler's ``conditioning``, video samplers' ``samples``).
# ``model`` alone (ModelSampling*) or with ``clip`` (LoraLoader) is a model patch.
# The resolver and comfy-metadata.js both read these lists.
_STAGE_INPUT_LISTS = ("shaped", "with_model")
_STRING_PARAMS = MappingProxyType(
{
"literal": frozenset(),
"concat": frozenset(),
"strip-newlines": frozenset({"flag"}),
"join-multi": frozenset({"count", "delimiter", "list_flag", "input_prefix"}),
}
)
Frozen = Union[Mapping[str, Any], Tuple[Any, ...], str, int, None]
class SemanticsError(ValueError):
"""The semantics table is unreadable or violates its schema."""
@dataclass(frozen=True)
class Semantics:
"""Validated, deeply frozen semantics. Slot categories key by ``(class, slot)``."""
samplers: Mapping[str, Mapping[str, Any]]
guiders: Mapping[str, Mapping[str, Any]]
conditioning: Mapping[Tuple[str, int], Mapping[str, Any]]
selectors: Mapping[str, Mapping[str, Any]]
composites: Mapping[str, Mapping[str, Any]]
encoders: Mapping[Tuple[str, int], Mapping[str, Any]]
strings: Mapping[Tuple[str, int], Mapping[str, Any]]
runtime: FrozenSet[str]
savers: Mapping[str, Mapping[str, Any]]
non_file_outputs: FrozenSet[str]
stage_inputs: Mapping[str, Tuple[str, ...]]
facts: Mapping[str, Any]
all_classes: FrozenSet[str]
def _freeze(value: Any) -> Frozen:
if isinstance(value, dict):
return MappingProxyType({key: _freeze(item) for key, item in value.items()})
if isinstance(value, list):
return tuple(_freeze(item) for item in value)
return value
def _reject_duplicate_keys(pairs: list) -> dict:
result: Dict[str, Any] = {}
for key, value in pairs:
if key in result:
raise SemanticsError(f"duplicate key {key!r}")
result[key] = value
return result
def _reject_constant(name: str) -> None:
raise SemanticsError(f"non-finite number {name} is not allowed")
def _name(value: Any, where: str, what: str = "input") -> str:
if not isinstance(value, str) or not value or len(value) > _NAME_LENGTH:
raise SemanticsError(f"{where}: missing or invalid {what} name {value!r}")
return value
def _names(value: Any, where: str, minimum: int = 1) -> None:
if not isinstance(value, list) or len(value) < minimum:
raise SemanticsError(f"{where}: needs at least {minimum} input name(s)")
for item in value:
_name(item, where)
if len(set(value)) != len(value):
raise SemanticsError(f"{where}: repeated input name")
def _fields(
entry: Any, where: str, required: FrozenSet[str], optional: FrozenSet[str]
) -> None:
if not isinstance(entry, dict):
raise SemanticsError(f"{where}: entry must be an object")
missing = sorted(required - entry.keys())
if missing:
raise SemanticsError(f"{where}: missing input field(s) {missing}")
unknown = sorted(entry.keys() - required - optional)
if unknown:
raise SemanticsError(f"{where}: unknown field(s) {unknown}")
def _strategy(entry: Any, category: str, where: str) -> str:
strategy = entry.get("strategy") if isinstance(entry, dict) else None
if not isinstance(strategy, str) or strategy not in STRATEGIES[category]:
raise SemanticsError(f"{where}: unknown strategy {strategy!r}")
return strategy
def _check_sampler(entry: dict, strategy: str, where: str) -> None:
stage = "image_stage" if strategy == "detailer-stage" else "latent"
_fields(
entry,
where,
frozenset({"strategy", "conditioning", stage, "facts"}),
_SAMPLER_OPTIONS,
)
_name(entry[stage], where)
# Inputs that, when set, replace the prompt the sampler's conditioning
# inputs carry (a detailer's wildcard); the prompt is then not read.
# ``fact_overrides`` (read by comfy-metadata.js): inputs that, when set,
# replace the settings the facts would read (a script or an XY plot).
for option in sorted(_SAMPLER_OPTIONS & entry.keys()):
_names(entry[option], f"{where}.{option}")
keys = {"guider"} if strategy == "custom-advanced" else {"positive", "negative"}
conditioning = entry["conditioning"]
_fields(conditioning, where + ".conditioning", frozenset(keys), frozenset())
for key in keys:
_name(conditioning[key], where + ".conditioning")
if entry["facts"] not in FACT_SHAPES:
raise SemanticsError(f"{where}: unknown facts shape {entry['facts']!r}")
def _check_guider(entry: dict, strategy: str, where: str) -> None:
if strategy == "cfg":
_fields(
entry, where, frozenset({"strategy", "positive", "negative"}), frozenset()
)
_name(entry["negative"], where)
else:
# Negative absent by design; its reason is fixed text, not an input.
_fields(
entry,
where,
frozenset({"strategy", "positive"}),
frozenset({"absent_reason"}),
)
if "absent_reason" in entry:
_name(entry["absent_reason"], where, "absent reason")
_name(entry["positive"], where)
def _check_selector(entry: dict, strategy: str, where: str) -> None:
_fields(entry, where, frozenset({"strategy", "condition", "branches"}), frozenset())
_name(entry["condition"], where)
branches = entry["branches"]
_fields(branches, where + ".branches", frozenset({"true", "false"}), frozenset())
_name(branches["true"], where)
_name(branches["false"], where)
def _check_composite(entry: dict, strategy: str, where: str) -> None:
_fields(entry, where, frozenset({"strategy", "inputs"}), frozenset())
_names(entry["inputs"], where, minimum=2)
def _check_saver(entry: dict, strategy: str, where: str) -> None:
_fields(
entry,
where,
frozenset({"strategy", "prefix_input", "image_input", "pattern"}),
frozenset(),
)
_name(entry["prefix_input"], where)
_name(entry["image_input"], where)
if entry["pattern"] != SAVER_PATTERNS[strategy]:
raise SemanticsError(f"{where}: pattern does not match {strategy}")
def _check_conditioning(entry: dict, strategy: str, where: str) -> None:
_fields(entry, where, frozenset({"strategy", "input"}), frozenset())
_name(entry["input"], where)
def _check_encoder(entry: dict, strategy: str, where: str) -> None:
if strategy == "flag-parts":
# A literal boolean ``flag`` false reads the first input; true needs
# every input equal (smZ CLIPTextEncode's with_SDXL).
_fields(entry, where, frozenset({"strategy", "inputs", "params"}), frozenset())
params = entry["params"]
if not isinstance(params, dict) or set(params) != {"flag"}:
raise SemanticsError(f"{where}: params must be exactly ['flag']")
_name(params["flag"], where + ".params")
_names(entry["inputs"], where, minimum=2)
return
_fields(entry, where, frozenset({"strategy", "inputs"}), frozenset({"compose"}))
_names(entry["inputs"], where, minimum=2 if strategy == "parts-equal" else 1)
if "compose" in entry and not isinstance(entry["compose"], bool):
raise SemanticsError(f"{where}: compose must be a boolean")
def _check_string(entry: dict, strategy: str, where: str) -> None:
required = _STRING_PARAMS[strategy]
if required:
_fields(entry, where, frozenset({"strategy", "inputs", "params"}), frozenset())
params = entry["params"]
if not isinstance(params, dict) or set(params) != required:
raise SemanticsError(f"{where}: params must be exactly {sorted(required)}")
for value in params.values():
_name(value, where + ".params")
else:
_fields(entry, where, frozenset({"strategy", "inputs"}), frozenset())
_names(entry["inputs"], where)
_CHECKS: Mapping[str, Callable[[dict, str, str], None]] = MappingProxyType(
{
"samplers": _check_sampler,
"guiders": _check_guider,
"selectors": _check_selector,
"composites": _check_composite,
"savers": _check_saver,
"conditioning": _check_conditioning,
"encoders": _check_encoder,
"strings": _check_string,
}
)
class _Owners:
"""Each class belongs to one category; each (class, slot) is listed once."""
def __init__(self) -> None:
self._category: Dict[str, str] = {}
self._outputs: set = set()
def claim(self, name: str, category: str, slot: Optional[int] = None) -> None:
owner = self._category.setdefault(name, category)
if owner != category:
raise SemanticsError(f"duplicate class {name!r} in {owner} and {category}")
key = (name, slot)
if slot is not None and key in self._outputs:
raise SemanticsError(f"{category}: duplicate output key {key!r}")
self._outputs.add(key)
def classes(self) -> FrozenSet[str]:
return frozenset(self._category)
def _class_category(doc: dict, category: str, owners: _Owners) -> Mapping:
table = doc[category]
if not isinstance(table, dict):
raise SemanticsError(f"category {category} must be an object")
result = {}
for name, entry in table.items():
where = f"{category}[{name!r}]"
_name(name, where, "class")
strategy = _strategy(entry, category, where)
_CHECKS[category](entry, strategy, where)
owners.claim(name, category)
result[name] = _freeze(entry)
return MappingProxyType(result)
def _slot(entry: dict, where: str) -> int:
slot = entry.get("slot")
# bool is an int subclass; a JSON true is not an output slot.
if type(slot) is not int or not 0 <= slot <= _MAX_SLOT:
raise SemanticsError(f"{where}: slot must be a non-negative int, not {slot!r}")
return slot
def _slot_category(doc: dict, category: str, owners: _Owners) -> Mapping:
rows = doc[category]
if not isinstance(rows, list):
raise SemanticsError(f"category {category} must be a list")
result = {}
for index, row in enumerate(rows):
where = f"{category}[{index}]"
if not isinstance(row, dict):
raise SemanticsError(f"{where}: entry must be an object")
name = _name(row.get("class"), where, "class")
slot = _slot(row, where)
entry = {
key: value for key, value in row.items() if key not in ("class", "slot")
}
strategy = _strategy(entry, category, where)
_CHECKS[category](entry, strategy, where)
owners.claim(name, category, slot)
result[(name, slot)] = _freeze(entry)
return MappingProxyType(result)
def _set_category(doc: dict, category: str, owners: _Owners) -> FrozenSet[str]:
rows = doc[category]
if not isinstance(rows, list):
raise SemanticsError(f"category {category} must be a list")
seen: set = set()
for name in rows:
_name(name, category, "class")
if name in seen:
raise SemanticsError(f"{category}: duplicate class {name!r}")
seen.add(name)
owners.claim(name, category)
return frozenset(seen)
def _string_map(table: Any, where: str, allowed: Optional[FrozenSet[str]]) -> None:
if not isinstance(table, dict):
raise SemanticsError(f"{where} must be an object")
for name, value in table.items():
_name(name, where, "class")
_name(value, where)
if allowed is not None and value not in allowed:
raise SemanticsError(f"{where}[{name!r}]: unknown kind {value!r}")
def _stage_inputs(doc: dict) -> Mapping[str, Tuple[str, ...]]:
table = doc["stage_inputs"]
if not isinstance(table, dict) or set(table) != set(_STAGE_INPUT_LISTS):
raise SemanticsError(
f"stage_inputs must have exactly {list(_STAGE_INPUT_LISTS)}"
)
for key in _STAGE_INPUT_LISTS:
_names(table[key], f"stage_inputs.{key}")
if "model" in table[key]:
raise SemanticsError(f"stage_inputs.{key}: model is a patch input")
if set(table["shaped"]) & set(table["with_model"]):
raise SemanticsError("stage_inputs: an input is listed in both lists")
return _freeze(table)
def _facts(doc: dict) -> Mapping[str, Any]:
facts = doc["facts"]
if not isinstance(facts, dict) or set(facts) != set(_FACTS_TABLES):
raise SemanticsError(f"facts must have exactly {list(_FACTS_TABLES)}")
configuration = facts["configuration_outputs"]
if not isinstance(configuration, dict):
raise SemanticsError("facts.configuration_outputs must be an object")
for name, adapter in configuration.items():
where = f"facts.configuration_outputs[{name!r}]"
_name(name, where, "class")
_fields(adapter, where, frozenset({"kind", "fields"}), _TYPE_FIELDS)
if adapter["kind"] not in _CONFIGURATION_KINDS:
raise SemanticsError(f"{where}: unknown kind {adapter['kind']!r}")
_names(adapter["fields"], where)
if "type_fields" in adapter:
# A fact whose value is the provider's node type (D8: Flux2Scheduler).
_names(adapter["type_fields"], where)
if set(adapter["type_fields"]) & set(adapter["fields"]):
raise SemanticsError(f"{where}: type_fields repeat an input field")
_string_map(facts["model_outputs"], "facts.model_outputs", None)
_string_map(facts["scalar_outputs"], "facts.scalar_outputs", _SCALAR_KINDS)
passthroughs = facts["model_passthroughs"]
_names(passthroughs, "facts.model_passthroughs", minimum=0)
return _freeze(facts)
def _check_nesting(doc: Any) -> None:
"""Reject containers nested deeper than ``MAX_NESTING`` (iterative walk)."""
stack = [(doc, 1)]
while stack:
value, level = stack.pop()
if not isinstance(value, (dict, list)):
continue
if level > MAX_NESTING:
raise SemanticsError(f"semantics table nests deeper than {MAX_NESTING}")
items = value.values() if isinstance(value, dict) else value
stack.extend((item, level + 1) for item in items)
def _validate(doc: Any) -> Semantics:
if not isinstance(doc, dict):
raise SemanticsError("semantics table must be an object")
if set(doc) != _CATEGORIES:
missing = sorted(_CATEGORIES - doc.keys())
unknown = sorted(doc.keys() - _CATEGORIES)
raise SemanticsError(f"category mismatch: missing {missing}, unknown {unknown}")
if type(doc["schema"]) is not int or doc["schema"] != SCHEMA_VERSION:
raise SemanticsError(f"unsupported schema {doc['schema']!r}")
owners = _Owners()
tables: Dict[str, Any] = {}
for category in _CLASS_CATEGORIES:
tables[category] = _class_category(doc, category, owners)
for category in _SLOT_CATEGORIES:
tables[category] = _slot_category(doc, category, owners)
for category in _SET_CATEGORIES:
tables[category] = _set_category(doc, category, owners)
return Semantics(
stage_inputs=_stage_inputs(doc),
facts=_facts(doc),
all_classes=owners.classes(),
**tables,
)
def load_semantics(path: Optional[Union[str, Path]] = None) -> Semantics:
"""Read and validate a semantics table; ``SemanticsError`` on any problem."""
source = Path(path) if path is not None else SEMANTICS_PATH
try:
with source.open("rb") as stream:
data = stream.read(MAX_TABLE_BYTES + 1)
if len(data) > MAX_TABLE_BYTES:
raise SemanticsError(
f"semantics table {source.name} exceeds {MAX_TABLE_BYTES} bytes"
)
doc = json.loads(
data.decode("utf-8"),
object_pairs_hook=_reject_duplicate_keys,
parse_constant=_reject_constant,
)
except SemanticsError:
raise
except RecursionError:
raise SemanticsError(
f"semantics table {source.name} nests deeper than {MAX_NESTING}"
) from None
except (OSError, UnicodeDecodeError, ValueError) as error:
raise SemanticsError(f"cannot read semantics table {source.name}: {error}")
_check_nesting(doc)
return _validate(doc)
@lru_cache(maxsize=1)
def get_semantics() -> Semantics:
"""The repository table, loaded on first use; failures are not cached."""
return load_semantics()
+21 -139
View File
@@ -3,7 +3,6 @@
import hashlib
import io
import json
import re
import struct
import subprocess # nosec B404
import threading
@@ -14,11 +13,8 @@ from typing import Any
from PIL import Image
from .inspection_outcomes import UNREADABLE_METADATA_REASON, outcome_message
from .pair_execution import EXECUTION_KEY
from .prompt_pair_resolver import (
_FOOTER,
EMPTY_POSITIVE_REASON,
INSPECTION_PROFILE,
ResolverProfile,
resolve_prompt_pair,
@@ -43,23 +39,6 @@ MAX_PNG_CHUNKS = 10_000
_ROLE_METADATA_KEYS = frozenset({"prompt", "parameters", EXECUTION_KEY})
_METADATA_KEYS = _ROLE_METADATA_KEYS | {"workflow"}
_TEXT_CHUNKS = (b"tEXt", b"zTXt", b"iTXt")
# An accumulating batch saver writes one A1111 ``parameters`` record per batch
# item, identical except this field (R3); Pillow keeps the last one.
# Matched only inside the record's one ``Steps: N, ...`` settings footer line.
_BATCH_INDEX = re.compile(r"(?:^|(?<=, ))Batch index: \d+(?=, |$)")
def _batch_normalized(text: str) -> str | None:
"""``text`` with its footer's single batch index blanked, else ``None``."""
footers = list(_FOOTER.finditer(text))
if len(footers) != 1:
return None
line, count = _BATCH_INDEX.subn("Batch index: #", footers[0].group())
if count != 1:
return None
return text[: footers[0].start()] + line + text[footers[0].end() :]
_SIGNATURE = b"\x89PNG\r\n\x1a\n"
@@ -142,14 +121,13 @@ class _PngRecords:
self.digests: dict[str, tuple[int, bytes]] = {}
self.role_encoded = self.role_decoded = 0
self.duplicate_workflow = self.duplicate_role = 0
self.duplicate_decoded = 0
def skip(self, key: str, length: int) -> bool:
"""Decide from the keyword and length alone whether to seek past a chunk."""
if key not in _METADATA_KEYS or key in self.limited:
return True # never retained, or a duplicate of a limited workflow
if key in self.digests:
if self.digests[key][0] != length and key != "parameters":
if self.digests[key][0] != length:
raise MetadataReadError("Duplicate prompt metadata")
if key == "workflow":
if self.duplicate_workflow + length > DUPLICATE_WORKFLOW_BYTES:
@@ -177,11 +155,7 @@ class _PngRecords:
digest = hashlib.sha256(kind + data).digest()
if key in self.digests:
if self.digests[key][1] != digest:
if key != "parameters":
raise MetadataReadError("Duplicate prompt metadata")
self.batch_copy(kind, data)
# The kept copy is the last one; later repeats compare with it.
self.digests[key] = (len(data), digest)
raise MetadataReadError("Duplicate prompt metadata")
return
self.digests[key] = (len(data), digest)
workflow = key == "workflow"
@@ -202,27 +176,6 @@ class _PngRecords:
raise MetadataReadError("Metadata exceeds inspection limits")
self.found[key] = value
def batch_copy(self, kind: bytes, data: bytes) -> None:
"""Keep a later ``parameters`` copy that differs only in its batch index.
Each copy must carry exactly one ``Batch index: N`` settings field; any
other difference is still a conflict. Decoding every copy draws from the
duplicate role budget, so small compressed copies stay bounded.
"""
_, value = _png_text(kind, data, ROLE_METADATA_BYTES)
size = ROLE_METADATA_BYTES + 1 if value is None else len(value.encode())
self.duplicate_decoded += size
if value is None or self.duplicate_decoded > DUPLICATE_ROLE_BYTES:
raise MetadataReadError("Metadata exceeds inspection limits")
kept = self.found["parameters"]
normalized = _batch_normalized(kept)
if normalized is None or normalized != _batch_normalized(value):
raise MetadataReadError("Duplicate prompt metadata")
self.role_decoded += max(0, size - len(kept.encode()))
if self.role_decoded > ROLE_METADATA_BYTES:
raise MetadataReadError("Metadata exceeds inspection limits")
self.found["parameters"] = value
def _png_metadata(stream: Any, size: int) -> tuple[dict, frozenset, int, int]:
"""CRC-check retained text and structural chunks, seeking past everything else.
@@ -447,70 +400,6 @@ def _side(status: str, text: str | None = None, source: str | None = None) -> di
return {"status": status, "text": text, "source": source}
_EMPTY_POSITIVE = outcome_message(
{"state": "unresolved", "reason": EMPTY_POSITIVE_REASON}
)
def _explained(prompts: dict, resolution: dict) -> dict:
"""Each unresolved role carries the outcome's line; other roles are kept."""
message = outcome_message(resolution)
if message is None:
return prompts
return {
role: (
{**side, "message": message}
if side["status"] == "unresolved" and "message" not in side
else side
)
for role, side in prompts.items()
}
def unreadable_evidence() -> dict:
"""Both roles when the file's metadata could not be read at all."""
resolution = {
"state": "unresolved",
"sampler_id": None,
"reason": UNREADABLE_METADATA_REASON,
"reason_code": None,
"node": None,
}
prompts = {role: _side("unresolved") for role in ("positive", "negative")}
return {"prompts": _explained(prompts, resolution), "resolution": resolution}
def _may_hold_prompt_manager(metadata: dict) -> bool:
"""Whether the API graph can show the R1 signature, so the workflow is needed.
PromptManager classes, or a ``CLIPTextEncode`` carrying PromptManager's own
``search_text`` input (an older save rewrote the class).
"""
graph = metadata.get("prompt")
if not isinstance(graph, dict):
return False
for node in graph.values():
if not isinstance(node, dict):
continue
name = node.get("class_type")
inputs = node.get("inputs")
if isinstance(name, str) and "promptmanager" in name.lower():
return True
if isinstance(inputs, dict) and "search_text" in inputs:
return True
return False
def _parsed_workflow(value: Any) -> dict | None:
"""The UI workflow as an object, or ``None`` (absent, invalid or not JSON)."""
if isinstance(value, str):
try:
value = json.loads(value)
except (ValueError, RecursionError):
return None
return value if isinstance(value, dict) else None
def inspect_prompt_evidence(
metadata: dict,
saved: dict | None = None,
@@ -524,7 +413,6 @@ def inspect_prompt_evidence(
directly with its default profile. ``resolution`` carries the closed
``reason_code`` and, for a blocking node, ``node: {id, class_type}``.
"""
workflow = metadata.get("workflow")
metadata = {
key: value for key, value in metadata.items() if key in _ROLE_METADATA_KEYS
}
@@ -559,26 +447,21 @@ def inspect_prompt_evidence(
},
}
canonical = dict(metadata)
malformed = {
"prompts": _explained(
{role: _side("unresolved") for role in ("positive", "negative")},
resolution,
),
"resolution": resolution,
}
if "prompt" not in canonical and "parameters" not in canonical:
return malformed
return {
"prompts": {role: _side("unresolved") for role in ("positive", "negative")},
"resolution": resolution,
}
if isinstance(canonical.get("prompt"), str):
try:
canonical["prompt"] = json.loads(canonical["prompt"])
except (ValueError, RecursionError):
return malformed
workflow = (
_parsed_workflow(workflow) if _may_hold_prompt_manager(canonical) else None
)
if workflow is not None and "prompt" in canonical:
# Only to recognize overwritten PromptManager text (R1); never a role.
canonical["workflow"] = workflow
return {
"prompts": {
role: _side("unresolved") for role in ("positive", "negative")
},
"resolution": resolution,
}
resolved = resolve_prompt_pair(canonical, profile=profile)
state = resolved["state"]
result = {}
@@ -592,19 +475,18 @@ def inspect_prompt_evidence(
result[role] = _side(
"resolved", side["text"], side["provenance"]["text_source"]
)
elif state == "resolved" and isinstance(side, dict):
# A resolved pair withholds only an empty positive.
result[role] = {**_side("unresolved"), "message": _EMPTY_POSITIVE}
else:
result[role] = _side(state if state != "resolved" else "unresolved")
resolution = {
"state": state,
"sampler_id": resolved.get("sampler_id"),
"reason": resolved.get("reason", ""),
"reason_code": resolved.get("reason_code"),
"node": resolved.get("node"),
return {
"prompts": result,
"resolution": {
"state": state,
"sampler_id": resolved.get("sampler_id"),
"reason": resolved.get("reason", ""),
"reason_code": resolved.get("reason_code"),
"node": resolved.get("node"),
},
}
return {"prompts": _explained(result, resolution), "resolution": resolution}
def inspect_prompt_roles(metadata: dict, saved: dict | None = None) -> dict:
File diff suppressed because it is too large Load Diff
+20 -216
View File
@@ -403,53 +403,8 @@
}
const FACT_FIELDS = ["seed", "steps", "cfg", "sampler", "scheduler", "model"];
const GENERATION_SAMPLERS = new Set(["KSampler", "KSamplerAdvanced", "SamplerCustom", "SamplerCustomAdvanced"]);
const WORKFLOW_PASSTHROUGH = Symbol("inspection workflow passthrough");
// These tables must equal inspection-semantics.json: SAMPLERS its `samplers` section
// (facts shape, and a detailer stage's image input), the other four its `facts`
// section (tests/js/inspection-semantics-sync.test.js). They stay inline literals
// because the legacy pages load this file as a classic script.
const SAMPLERS = Object.freeze({
KSampler: { facts: "ksampler" },
KSamplerAdvanced: { facts: "ksampler" },
SamplerCustom: { facts: "custom" },
SamplerCustomAdvanced: { facts: "custom-advanced" },
FaceDetailer: { facts: "ksampler", image_stage: "image" },
DetailerForEach: { facts: "ksampler", image_stage: "image" },
UltimateSDUpscale: { facts: "ksampler", image_stage: "image" },
UltimateSDUpscaleNoUpscale: { facts: "ksampler", image_stage: "upscaled_image" },
// `fact_overrides`: inputs that, when set, replace the settings read here.
ClownsharKSampler_Beta: { facts: "ksampler", fact_overrides: ["options", "sigmas"] },
"KSampler (Efficient)": { facts: "ksampler", fact_overrides: ["script"] },
"ttN KSampler_v2": { facts: "ksampler", fact_overrides: ["adv_xyPlot"] },
});
// Stages merged for one result: the resolver's candidate cap. Together with
// MAX_VISITS per candidate walk these are deliberately tighter than the server's
// budgets (2,000 visits, depth 64), so the JS walk always fails closed first.
const MAX_STAGES = 32;
// Every class the semantics table lists (the resolver's `all_classes`): a stage walk
// passes these, and treats any other node shaped like a sampler as an unknown stage.
const TABLE_CLASSES = new Set([
"KSampler", "KSamplerAdvanced", "SamplerCustom", "SamplerCustomAdvanced", "FaceDetailer",
"CFGGuider", "BasicGuider", "easy ifElse", "ConditioningCombine", "ConditioningConcat",
"ConditioningAverage", "SaveImage", "SaveAnimatedWEBP", "SaveAnimatedPNG", "SaveVideo",
"SaveWEBM", "VHS_VideoCombine", "FluxGuidance", "ReferenceLatent", "ConditioningSetArea",
"ConditioningSetAreaPercentage", "ConditioningSetAreaStrength", "ConditioningSetMask", "ConditioningSetTimestepRange", "ControlNetApply",
"ControlNetApplyAdvanced", "ControlNetApplySD3", "InpaintModelConditioning", "WanImageToVideo", "ConditioningZeroOut",
"CLIPTextEncode", "CLIPTextEncodeSDXL", "CLIPTextEncodeFlux", "CLIPTextEncodeSD3", "TextEncodeQwenImageEdit",
"TextEncodeQwenImageEditPlus", "TextEncodeQwenImage21", "KikoTextEncode", "StringConstant", "StringConstantMultiline",
"JoinStringMulti", "PromptSearchList", "PreviewImage", "PreviewAny", "SaveImageWebsocket",
"Image Comparer (rgthree)", "DetailerForEach", "UltimateSDUpscale", "UltimateSDUpscaleNoUpscale",
"ControlNetInpaintingAliMamaApply", "LTXVConditioning", "FluxKontextMultiReferenceLatentMethod", "Text to Conditioning",
"TextInput", "DisplayText", "ClownsharKSampler_Beta", "KSampler (Efficient)", "ttN KSampler_v2",
"SeedVarianceEnhancer", "ApplyInstantIDAdvanced", "Switch conditioning [Crystools]", "smZ CLIPTextEncode", "ttN text",
"CR Text", "CR Prompt Text", "TextBoxMira",
]);
// The table's `stage_inputs` (the resolver reads the same lists): inputs that make an
// unlisted node a possible stage, and those that do so beside a linked `model`.
const STAGE_INPUTS = Object.freeze({
shaped: ["latent_image", "guider", "positive", "negative", "pipe", "basic_pipe", "detailer_pipe", "sampler", "sigmas", "noise", "text_embeds", "hyvid_embeds"],
with_model: ["conditioning", "neg_conditioning", "samples", "latent"],
});
// Fixed output0 contracts from the pinned core node schemas, not input-name inference.
const SCALAR_OUTPUTS = Object.freeze({
PrimitiveInt: "number", PrimitiveFloat: "number",
@@ -459,17 +414,11 @@
CheckpointLoader: "ckpt_name", CheckpointLoaderSimple: "ckpt_name",
unCLIPCheckpointLoader: "ckpt_name", UNETLoader: "unet_name",
});
const MODEL_PASSTHROUGHS = new Set([
"LoraLoader", "LoraLoaderModelOnly", "ModelSamplingFlux", "ModelSamplingAuraFlow", "ModelSamplingSD3",
"Lora Loader (LoraManager)", "Power Lora Loader (rgthree)", "ModelPatchTorchSettings", "CFGNorm", "PerturbedAttentionGuidance",
"CR Apply LoRA Stack",
]);
const MODEL_PASSTHROUGHS = new Set(["LoraLoader", "LoraLoaderModelOnly"]);
const CONFIGURATION_OUTPUTS = Object.freeze({
RandomNoise: { kind: "noise", fields: ["noise_seed"] },
KSamplerSelect: { kind: "sampler", fields: ["sampler_name"] },
BasicScheduler: { kind: "sigmas", fields: ["steps", "scheduler"] },
// D8: no scheduler input; the node type is the scheduler configuration.
Flux2Scheduler: { kind: "sigmas", fields: ["steps"], type_fields: ["scheduler"] },
CFGGuider: { kind: "guider", fields: ["cfg", "model"] },
BasicGuider: { kind: "guider", fields: ["model"] },
});
@@ -514,85 +463,24 @@
const seen = new Set();
const [, node, adapter] = configurationOutput(ctx, link, kind, seen);
if (!node) return fact("unresolved", source, samplerId);
if (keys.some(name => adapter.type_fields?.includes(name))) {
const value = exactFactValue(node.class_type, field);
return fact(value === null ? "unresolved" : "known", source, samplerId, value);
}
const key = keys.find(name => adapter.fields.includes(name) && Object.hasOwn(node.inputs, name));
return key === undefined ? fact("not-recorded", source, samplerId) : scalarFact(ctx, node.inputs, key, field, source, samplerId, seen);
}
const samplerEntry = (graph, id) => (Object.hasOwn(graph, id) && Object.hasOwn(SAMPLERS, graph[id].class_type) ? SAMPLERS[graph[id].class_type] : undefined);
// The resolver's `_link`: an integer or string id and a non-negative integer slot.
// Stage walks use it so they cross exactly the server's edges; the legacy `isLink` stays.
const stageLink = value => Array.isArray(value) && value.length === 2 &&
(typeof value[0] === "string" || Number.isInteger(value[0])) && Number.isInteger(value[1]) && value[1] >= 0;
const linked = (inputs, key) => Object.hasOwn(inputs, key) && stageLink(inputs[key]);
/** An unlisted node that may itself be a sampler stage: the walk cannot judge it. */
const stageShaped = ({ class_type, inputs }) => !TABLE_CLASSES.has(class_type) &&
(STAGE_INPUTS.shaped.some(key => linked(inputs, key)) || (linked(inputs, "model") && STAGE_INPUTS.with_model.some(key => linked(inputs, key))));
/** Samplers reached from a detailer's image input, breadth-first, stopping at listed samplers. */
function upstreamStages(graph, origin, value, budget) {
if (!stageLink(value)) return null;
const found = [], queue = [String(value[0])], queued = new Set(queue);
for (let head = 0; head < queue.length; head++) {
const id = queue[head];
if (id === origin || ++budget.visits > MAX_VISITS || !Object.hasOwn(graph, id)) return null;
if (samplerEntry(graph, id)) { found.push(id); continue; }
if (stageShaped(graph[id])) return null;
for (const input of Object.values(graph[id].inputs)) {
const next = stageLink(input) ? String(input[0]) : null;
if (next !== null && !queued.has(next)) { queued.add(next); queue.push(next); }
}
}
return found;
}
/**
* Collect a final stage and every stage in its image ancestry into `stages`, as the
* resolver judges a detailer chain. `later` holds the detailers already being judged
* below this one, so a cyclic chain fails instead of recursing. False when the chain
* cannot be walked within the bounds.
*/
// `stages` is shared on purpose across one result's candidates: re-adding a stage is
// harmless, and MAX_STAGES caps the union, not each candidate.
function collectStages(graph, id, later, stages, budget) {
stages.add(id);
if (stages.size > MAX_STAGES || later.size >= MAX_STAGES) return false;
const entry = samplerEntry(graph, id);
if (entry.image_stage === undefined) return true;
const inputs = graph[id].inputs;
const upstream = upstreamStages(graph, id, Object.hasOwn(inputs, entry.image_stage) ? inputs[entry.image_stage] : undefined, budget);
if (upstream === null || upstream.some(stage => later.has(stage))) return false;
const below = new Set(later).add(id);
return upstream.every(stage => collectStages(graph, stage, below, stages, budget));
}
/** Spec section 10 merge: equal known values stay known, differing ones are ambiguous. */
function mergeFacts(sets, source) {
if (sets.length === 1) return sets[0];
return Object.fromEntries(FACT_FIELDS.map(field => {
const facts = sets.map(set => set[field]);
if (new Set(facts.filter(item => item.status === "known").map(item => item.value)).size > 1) return [field, fact("ambiguous", source)];
const [first] = facts;
const agreed = facts.every(item => item.status === first.status && item.value === first.value);
// A merged fact names no single sampler: it is every stage's value.
return [field, agreed ? fact(first.status, source, null, first.value, first.reason) : fact("unresolved", source)];
}));
}
function samplerFacts(ctx, samplerId, source) {
const sampler = ctx.graph[samplerId];
function graphFacts(graph, source, resolution) {
if (!object(graph)) return factSet("unresolved", source);
// Graph size is not a bound: each lookup is bounded by its MAX_VISITS seen-set,
// and the server's node ceiling arrives as a `limited` projection.
const entries = Object.entries(graph);
if (entries.some(([, node]) => !object(node) || !object(node.inputs) || typeof node.class_type !== "string")) return factSet("unresolved", source);
const candidates = entries.filter(([, node]) => GENERATION_SAMPLERS.has(node.class_type));
const selected = candidates.find(([id]) => typeof resolution?.sampler_id === "string" && id === resolution.sampler_id);
if (!selected && candidates.length !== 1) return factSet(candidates.length > 1 ? "ambiguous" : "unresolved", source);
const [samplerId, sampler] = selected || candidates[0];
const ctx = createContext(graph, null, true);
const inputs = sampler.inputs;
const { facts: shape, fact_overrides: overrides = [] } = SAMPLERS[sampler.class_type];
// A set override (a script, an XY plot, custom sigmas) replaces the settings below.
if (overrides.some(name => Object.hasOwn(inputs, name) && inputs[name] !== null && inputs[name] !== "")) {
return Object.fromEntries(FACT_FIELDS.map(name => [name, fact("unresolved", source, samplerId)]));
}
const advanced = shape === "custom-advanced";
const custom = advanced || shape === "custom";
const advanced = sampler.class_type === "SamplerCustomAdvanced";
const custom = advanced || sampler.class_type === "SamplerCustom";
let settings = inputs;
let settingsAdapter;
const settingsPath = new Set();
@@ -622,45 +510,6 @@
return facts;
}
/**
* Facts follow the server's resolution. Its `sampler_id` drives them alone when
* resolved. Any other result naming a sampler (an ambiguous multistage chain, or an
* absent or unresolved prompt of the branch the server chose, e.g. after dropping a
* preview-only stage) merges per field across that sampler's stages, independent of
* any selection (D5). Ambiguous without a named sampler (no candidates on the wire
* until PR3), and unresolved or absent over several listed samplers, merge the same
* way across every listed sampler and its stages. Otherwise (no resolution, or one
* listed sampler) exactly one listed sampler supplies them; several leave every field ambiguous.
*/
function graphFacts(graph, source, resolution) {
if (!object(graph)) return factSet("unresolved", source);
// Graph size is not a bound: each lookup is bounded by its MAX_VISITS seen-set,
// and the server's node ceiling arrives as a `limited` projection.
const entries = Object.entries(graph);
if (entries.some(([, node]) => !object(node) || !object(node.inputs) || typeof node.class_type !== "string")) return factSet("unresolved", source);
const ctx = createContext(graph, null, true);
const state = object(resolution) ? resolution.state : undefined;
const named = typeof resolution?.sampler_id === "string" ? resolution.sampler_id : null;
if (["resolved", "ambiguous", "absent", "unresolved"].includes(state) && named !== null) {
if (!samplerEntry(graph, named)) return factSet("unresolved", source);
if (state === "resolved") return samplerFacts(ctx, named, source);
const stages = new Set();
if (!collectStages(graph, named, new Set(), stages, { visits: 0 })) return factSet("unresolved", source);
return mergeFacts([...stages].map(id => samplerFacts(ctx, id, source)), source);
}
const candidates = entries.filter(([id]) => samplerEntry(graph, id));
// Review I-1: an unresolved or absent prompt does not make the run's settings
// unknown, so several listed samplers merge the same way.
if (state === "ambiguous" || ((state === "unresolved" || state === "absent") && candidates.length > 1)) {
// The server's branches: every listed sampler, a detailer judged with its stages.
const stages = new Set();
if (!candidates.length || !candidates.every(([id]) => collectStages(graph, id, new Set(), stages, { visits: 0 }))) return factSet("unresolved", source);
return mergeFacts([...stages].map(id => samplerFacts(ctx, id, source)), source);
}
if (candidates.length !== 1) return factSet(candidates.length > 1 ? "ambiguous" : "unresolved", source);
return samplerFacts(ctx, candidates[0][0], source);
}
function validFactWorkflow(workflow) {
if (!object(workflow) || !Array.isArray(workflow.nodes) || (workflow.links !== undefined && !Array.isArray(workflow.links))) return false;
const ids = new Set(), links = new Set(), setters = new Set();
@@ -682,51 +531,18 @@
return true;
}
// The inspection resolver's `_footer` (Python): R5 also reads a bare `Steps: N`
// footer, and sd-dynamic-prompts' one-line template records after it.
const FOOTER_TRAILERS = ["Template:", "Negative Template:"];
function trailerLines(text) {
const lines = text.trim().split(/\r?\n|\r/).filter((line, index, all) => all.length > 1 || line);
const order = lines.map(line => FOOTER_TRAILERS.findIndex(name => line.startsWith(name)));
return !order.includes(-1) && order.every((value, index) => index === 0 || value > order[index - 1]);
}
function settingsFooter(parameters) {
let matches = [...parameters.matchAll(/^Steps:\s*\d+\s*,[^\n]*$/gm)];
if (!matches.length) matches = [...parameters.matchAll(/^Steps:\s*\d+\s*$/gm)];
if (matches.length !== 1) return null;
return trailerLines(parameters.slice(matches[0].index + matches[0][0].length)) ? matches[0] : null;
}
/** R10: split settings on commas outside A1111's quoted values; unbalanced quotes split as before. */
function settingsParts(line) {
const parts = [];
let start = 0, quoted = false;
for (let index = 0; index < line.length; index++) {
if (quoted && line[index] === "\\") index++;
else if (line[index] === '"') quoted = !quoted;
else if (line[index] === "," && !quoted) { parts.push(line.slice(start, index)); start = index + 1; }
}
if (quoted) return line.split(",");
parts.push(line.slice(start));
return parts;
}
function settingValue(value) {
if (!/^"(?:\\.|[^\\"])*"$/.test(value)) return value;
try { return JSON.parse(value); } catch { return value; }
}
function parameterFacts(parameters) {
const source = "embedded-parameters";
const footer = settingsFooter(parameters);
if (!footer) return factSet("unresolved", source);
// R4: like the resolver, text starting with the delimiter has an empty positive.
const body = "\n" + parameters.slice(0, footer.index).replace(/[\r\n]+$/, "");
const matches = [...parameters.matchAll(/^Steps:\s*\d+\s*,[^\n]*$/gm)];
if (matches.length !== 1 || parameters.slice(matches[0].index + matches[0][0].length).trim()) return factSet("unresolved", source);
const body = parameters.slice(0, matches[0].index).replace(/[\r\n]+$/, "");
if (!body.split("\nNegative prompt:")[0].trim() || body.split("\nNegative prompt:").length > 2) return factSet("unresolved", source);
const settings = new Map();
for (const part of settingsParts(footer[0])) {
for (const part of matches[0][0].split(",")) {
const match = part.match(/^\s*([^:]+):\s*(.*?)\s*$/);
if (!match) return factSet("unresolved", source);
const [, key, value] = match;
settings.set(key, settings.has(key) ? null : settingValue(value));
settings.set(key, settings.has(key) ? null : value);
}
const keys = { seed: "Seed", steps: "Steps", cfg: "CFG scale", sampler: "Sampler", scheduler: "Schedule type", model: "Model" };
return Object.fromEntries(FACT_FIELDS.map(field => {
@@ -759,18 +575,6 @@
}
const api = { extractGenerationParams, extractGenerationFacts, PLACEHOLDERS };
// For the semantics sync test only; not part of the legacy page contract.
Object.defineProperty(api, "_factsTables", {
value: Object.freeze({
configuration_outputs: CONFIGURATION_OUTPUTS,
model_outputs: MODEL_OUTPUTS,
model_passthroughs: MODEL_PASSTHROUGHS,
scalar_outputs: SCALAR_OUTPUTS,
}),
});
Object.defineProperty(api, "_samplerTable", { value: SAMPLERS });
Object.defineProperty(api, "_tableClasses", { value: TABLE_CLASSES });
Object.defineProperty(api, "_stageInputs", { value: STAGE_INPUTS });
if (typeof module !== "undefined" && module.exports) module.exports = api;
else root.ComfyMetadata = api;
})(typeof window !== "undefined" ? window : globalThis);
-270
View File
@@ -1,270 +0,0 @@
{
"schema": 1,
"samplers": {
"KSampler": {
"strategy": "ksampler",
"conditioning": { "positive": "positive", "negative": "negative" },
"latent": "latent_image",
"facts": "ksampler"
},
"KSamplerAdvanced": {
"strategy": "ksampler",
"conditioning": { "positive": "positive", "negative": "negative" },
"latent": "latent_image",
"facts": "ksampler"
},
"SamplerCustom": {
"strategy": "ksampler",
"conditioning": { "positive": "positive", "negative": "negative" },
"latent": "latent_image",
"facts": "custom"
},
"SamplerCustomAdvanced": {
"strategy": "custom-advanced",
"conditioning": { "guider": "guider" },
"latent": "latent_image",
"facts": "custom-advanced"
},
"FaceDetailer": {
"strategy": "detailer-stage",
"conditioning": { "positive": "positive", "negative": "negative" },
"image_stage": "image",
"facts": "ksampler",
"prompt_overrides": ["wildcard"]
},
"DetailerForEach": {
"strategy": "detailer-stage",
"conditioning": { "positive": "positive", "negative": "negative" },
"image_stage": "image",
"facts": "ksampler",
"prompt_overrides": ["wildcard"]
},
"UltimateSDUpscale": {
"strategy": "detailer-stage",
"conditioning": { "positive": "positive", "negative": "negative" },
"image_stage": "image",
"facts": "ksampler"
},
"UltimateSDUpscaleNoUpscale": {
"strategy": "detailer-stage",
"conditioning": { "positive": "positive", "negative": "negative" },
"image_stage": "upscaled_image",
"facts": "ksampler"
},
"ClownsharKSampler_Beta": {
"strategy": "ksampler",
"conditioning": { "positive": "positive", "negative": "negative" },
"latent": "latent_image",
"facts": "ksampler",
"prompt_overrides": ["options"],
"fact_overrides": ["options", "sigmas"]
},
"KSampler (Efficient)": {
"strategy": "ksampler",
"conditioning": { "positive": "positive", "negative": "negative" },
"latent": "latent_image",
"facts": "ksampler",
"prompt_overrides": ["script"],
"fact_overrides": ["script"]
},
"ttN KSampler_v2": {
"strategy": "ksampler",
"conditioning": { "positive": "positive", "negative": "negative" },
"latent": "latent",
"facts": "ksampler",
"prompt_overrides": ["adv_xyPlot"],
"fact_overrides": ["adv_xyPlot"]
}
},
"guiders": {
"CFGGuider": { "strategy": "cfg", "positive": "positive", "negative": "negative" },
"BasicGuider": {
"strategy": "basic",
"positive": "conditioning",
"absent_reason": "Guider has no negative input"
}
},
"conditioning": [
{ "class": "FluxGuidance", "slot": 0, "strategy": "passthrough", "input": "conditioning" },
{ "class": "ReferenceLatent", "slot": 0, "strategy": "passthrough", "input": "conditioning" },
{ "class": "ConditioningSetArea", "slot": 0, "strategy": "passthrough", "input": "conditioning" },
{ "class": "ConditioningSetAreaPercentage", "slot": 0, "strategy": "passthrough", "input": "conditioning" },
{ "class": "ConditioningSetAreaStrength", "slot": 0, "strategy": "passthrough", "input": "conditioning" },
{ "class": "ConditioningSetMask", "slot": 0, "strategy": "passthrough", "input": "conditioning" },
{ "class": "ConditioningSetTimestepRange", "slot": 0, "strategy": "passthrough", "input": "conditioning" },
{ "class": "ControlNetApply", "slot": 0, "strategy": "passthrough", "input": "conditioning" },
{ "class": "ControlNetApplyAdvanced", "slot": 0, "strategy": "passthrough", "input": "positive" },
{ "class": "ControlNetApplyAdvanced", "slot": 1, "strategy": "passthrough", "input": "negative" },
{ "class": "ControlNetApplySD3", "slot": 0, "strategy": "passthrough", "input": "positive" },
{ "class": "ControlNetApplySD3", "slot": 1, "strategy": "passthrough", "input": "negative" },
{ "class": "InpaintModelConditioning", "slot": 0, "strategy": "passthrough", "input": "positive" },
{ "class": "InpaintModelConditioning", "slot": 1, "strategy": "passthrough", "input": "negative" },
{ "class": "WanImageToVideo", "slot": 0, "strategy": "passthrough", "input": "positive" },
{ "class": "WanImageToVideo", "slot": 1, "strategy": "passthrough", "input": "negative" },
{ "class": "ControlNetInpaintingAliMamaApply", "slot": 0, "strategy": "passthrough", "input": "positive" },
{ "class": "ControlNetInpaintingAliMamaApply", "slot": 1, "strategy": "passthrough", "input": "negative" },
{ "class": "LTXVConditioning", "slot": 0, "strategy": "passthrough", "input": "positive" },
{ "class": "LTXVConditioning", "slot": 1, "strategy": "passthrough", "input": "negative" },
{ "class": "FluxKontextMultiReferenceLatentMethod", "slot": 0, "strategy": "passthrough", "input": "conditioning" },
{ "class": "SeedVarianceEnhancer", "slot": 0, "strategy": "passthrough", "input": "conditioning" },
{ "class": "ApplyInstantIDAdvanced", "slot": 1, "strategy": "passthrough", "input": "positive" },
{ "class": "ApplyInstantIDAdvanced", "slot": 2, "strategy": "passthrough", "input": "negative" },
{ "class": "ConditioningZeroOut", "slot": 0, "strategy": "zeroed", "input": "conditioning" }
],
"selectors": {
"easy ifElse": {
"strategy": "boolean-select",
"condition": "boolean",
"branches": { "true": "on_true", "false": "on_false" }
},
"Switch conditioning [Crystools]": {
"strategy": "boolean-select",
"condition": "boolean",
"branches": { "true": "on_true", "false": "on_false" }
}
},
"composites": {
"ConditioningCombine": { "strategy": "combine", "inputs": ["conditioning_1", "conditioning_2"] },
"ConditioningConcat": { "strategy": "combine", "inputs": ["conditioning_to", "conditioning_from"] },
"ConditioningAverage": { "strategy": "combine", "inputs": ["conditioning_to", "conditioning_from"] }
},
"encoders": [
{ "class": "CLIPTextEncode", "slot": 0, "strategy": "single", "inputs": ["text"] },
{ "class": "CLIPTextEncodeSDXL", "slot": 0, "strategy": "parts-equal", "inputs": ["text_g", "text_l"] },
{ "class": "CLIPTextEncodeFlux", "slot": 0, "strategy": "parts-equal", "inputs": ["clip_l", "t5xxl"] },
{ "class": "CLIPTextEncodeSD3", "slot": 0, "strategy": "parts-equal", "inputs": ["clip_l", "clip_g", "t5xxl"] },
{ "class": "TextEncodeQwenImageEdit", "slot": 0, "strategy": "single", "inputs": ["prompt"] },
{ "class": "TextEncodeQwenImageEditPlus", "slot": 0, "strategy": "single", "inputs": ["prompt"] },
{ "class": "TextEncodeQwenImage21", "slot": 0, "strategy": "single", "inputs": ["prompt"] },
{ "class": "TextEncodeQwenImage21", "slot": 1, "strategy": "single", "inputs": ["negative_prompt"] },
{ "class": "KikoTextEncode", "slot": 0, "strategy": "single", "inputs": ["text"] },
{ "class": "Text to Conditioning", "slot": 0, "strategy": "single", "inputs": ["text"] },
{
"class": "smZ CLIPTextEncode",
"slot": 0,
"strategy": "flag-parts",
"inputs": ["text", "text_g", "text_l"],
"params": { "flag": "with_SDXL" }
}
],
"strings": [
{ "class": "StringConstant", "slot": 0, "strategy": "literal", "inputs": ["string"] },
{ "class": "TextInput", "slot": 0, "strategy": "literal", "inputs": ["text"] },
{ "class": "DisplayText", "slot": 0, "strategy": "literal", "inputs": ["text"] },
{ "class": "ttN text", "slot": 0, "strategy": "literal", "inputs": ["text"] },
{ "class": "CR Text", "slot": 0, "strategy": "literal", "inputs": ["text"] },
{ "class": "CR Prompt Text", "slot": 0, "strategy": "literal", "inputs": ["prompt"] },
{ "class": "TextBoxMira", "slot": 0, "strategy": "literal", "inputs": ["text"] },
{
"class": "StringConstantMultiline",
"slot": 0,
"strategy": "strip-newlines",
"inputs": ["string"],
"params": { "flag": "strip_newlines" }
},
{
"class": "JoinStringMulti",
"slot": 0,
"strategy": "join-multi",
"inputs": ["string_1"],
"params": {
"count": "inputcount",
"delimiter": "delimiter",
"list_flag": "return_list",
"input_prefix": "string_"
}
}
],
"runtime": ["PromptSearchList"],
"savers": {
"SaveImage": {
"strategy": "comfy-counter",
"prefix_input": "filename_prefix",
"image_input": "images",
"pattern": "{name}_{counter:05}_.{ext}"
},
"SaveAnimatedWEBP": {
"strategy": "comfy-counter",
"prefix_input": "filename_prefix",
"image_input": "images",
"pattern": "{name}_{counter:05}_.{ext}"
},
"SaveAnimatedPNG": {
"strategy": "comfy-counter",
"prefix_input": "filename_prefix",
"image_input": "images",
"pattern": "{name}_{counter:05}_.{ext}"
},
"SaveVideo": {
"strategy": "comfy-counter",
"prefix_input": "filename_prefix",
"image_input": "video",
"pattern": "{name}_{counter:05}_.{ext}"
},
"SaveWEBM": {
"strategy": "comfy-counter",
"prefix_input": "filename_prefix",
"image_input": "images",
"pattern": "{name}_{counter:05}_.{ext}"
},
"VHS_VideoCombine": {
"strategy": "vhs-counter",
"prefix_input": "filename_prefix",
"image_input": "images",
"pattern": "{name}_{counter:05}.{ext}"
}
},
"non_file_outputs": ["PreviewImage", "PreviewAny", "SaveImageWebsocket", "Image Comparer (rgthree)"],
"stage_inputs": {
"shaped": [
"latent_image",
"guider",
"positive",
"negative",
"pipe",
"basic_pipe",
"detailer_pipe",
"sampler",
"sigmas",
"noise",
"text_embeds",
"hyvid_embeds"
],
"with_model": ["conditioning", "neg_conditioning", "samples", "latent"]
},
"facts": {
"configuration_outputs": {
"RandomNoise": { "kind": "noise", "fields": ["noise_seed"] },
"KSamplerSelect": { "kind": "sampler", "fields": ["sampler_name"] },
"BasicScheduler": { "kind": "sigmas", "fields": ["steps", "scheduler"] },
"Flux2Scheduler": { "kind": "sigmas", "fields": ["steps"], "type_fields": ["scheduler"] },
"CFGGuider": { "kind": "guider", "fields": ["cfg", "model"] },
"BasicGuider": { "kind": "guider", "fields": ["model"] }
},
"model_outputs": {
"CheckpointLoader": "ckpt_name",
"CheckpointLoaderSimple": "ckpt_name",
"unCLIPCheckpointLoader": "ckpt_name",
"UNETLoader": "unet_name"
},
"model_passthroughs": [
"LoraLoader",
"LoraLoaderModelOnly",
"ModelSamplingFlux",
"ModelSamplingAuraFlow",
"ModelSamplingSD3",
"Lora Loader (LoraManager)",
"Power Lora Loader (rgthree)",
"ModelPatchTorchSettings",
"CFGNorm",
"PerturbedAttentionGuidance",
"CR Apply LoRA Stack"
],
"scalar_outputs": {
"PrimitiveInt": "number",
"PrimitiveFloat": "number",
"PrimitiveString": "string",
"PrimitiveStringMultiline": "string"
}
}
}
+3 -3
View File
@@ -85,7 +85,7 @@ function formatBytes(value) {
return value === null || value === undefined ? null : value >= 1048576 ? `${(value / 1048576).toFixed(1)} MB` : `${Math.max(1, Math.round(value / 1024))} KB`;
}
export function mountFileInspector(container, {sender, parentScope, inline = false, promptContext = '', onPreview, onRetry, onRefresh, onInteraction = () => {}}) {
export function mountFileInspector(container, {sender, parentScope, inline = false, onPreview, onRetry, onRefresh, onInteraction = () => {}}) {
if (parentScope.signal.aborted) return {setState() {}, dispose() {}};
const scope = childScope(parentScope);
let contentScope;
@@ -145,7 +145,7 @@ export function mountFileInspector(container, {sender, parentScope, inline = fal
}
if (chips.childNodes.length) inner.append(chips);
const prompts = el('div'); inner.append(prompts);
const blocks = mountPromptBlocks(prompts, {sender, parentScope:contentScope, includePairAction:false, contextLabel:promptContext});
const blocks = mountPromptBlocks(prompts, {sender, parentScope:contentScope, includePairAction:false});
blocks.setState({prompts:record.prompts}); contentScope.cleanup(blocks.dispose);
if (library?.status === 'linked') {
const vocabulary = el('div', undefined, 'file-vocabulary');
@@ -226,7 +226,7 @@ export function mountFileInspector(container, {sender, parentScope, inline = fal
inner.append(raw);
const footer = el('footer', undefined, 'file-inspector-footer'), roles = promptRoles(record.prompts);
if (Object.hasOwn(roles, 'positive') && Object.hasOwn(roles, 'negative')) {
const pair = createSendButton(promptContext ? `Send ${promptContext.toLowerCase()} prompt pair` : 'Send prompt pair'); pair.dataset.sendRole = 'pair';
const pair = createSendButton('Send prompt pair'); pair.dataset.sendRole = 'pair';
contentScope.listen(pair, 'click', () => sender.open(roles, pair)); footer.append(pair);
}
if (record.historyTarget) {
+4 -9
View File
@@ -1,10 +1,9 @@
import '../api-client.js';
import { createButton, createField, createScope, createSelect } from '../shared/toolkit/core.js';
import { routes, mountRouter, summarizeStats, routeTitle, isHandoff } from './state.mjs';
import { routes, mountRouter, summarizeStats } from './state.mjs';
import { mountWorkspaceTheme } from './theme.mjs';
import { mountImages } from './images.mjs';
import { mountLibrary, mountNewPromptAction } from './library.mjs';
import { mountVocabulary } from './vocabulary.mjs';
import { createWorkspaceIcon } from './icons.mjs';
const { createApiClient, resolveUrl } = window.ApiClient;
@@ -66,7 +65,7 @@ function mountWorkspace() {
if (link.dataset.route === route.id) link.setAttribute('aria-current', 'page');
else link.removeAttribute('aria-current');
}
document.title = `${routeTitle(route)} · Prompt Manager`;
document.title = `${route.label} · Prompt Manager`;
const heading = element('h1', route.label);
// Focus target after a save, as the concept's page title.
heading.tabIndex = -1;
@@ -77,7 +76,7 @@ function mountWorkspace() {
main.replaceChildren(headingRegion);
if (!firstRender) main.focus();
firstRender = false;
document.getElementById('workspace-breadcrumb').textContent = routeTitle(route);
document.getElementById('workspace-breadcrumb').textContent = route.label;
if (route.id === 'images') {
mountNewPromptAction(main, api, view);
mountImages(main, api, view);
@@ -87,11 +86,7 @@ function mountWorkspace() {
mountLibrary(main, api, view, {positiveId:route.positiveId});
return;
}
if (route.id === 'tags') {
mountVocabulary(main, api, view);
return;
}
if (isHandoff(route)) {
if (route.id !== 'overview') {
headingCopy.append(element('p', route.description, 'intro'));
const panel = element('section', undefined, 'panel handoff');
panel.append(element('h2', 'Your current tools are ready'),
+5 -42
View File
@@ -9,10 +9,6 @@ const FACT_FIELDS = ['seed', 'steps', 'cfg', 'sampler', 'scheduler', 'model'];
const RECORD_FORMATS = ['original-text', 'reserialized-json', 'invalid', 'limited'];
const TEXTLESS_FORMATS = ['invalid', 'limited'];
const REASON_CODES = ['work-limit', 'depth-limit', 'text-limit', 'graph-ceiling', 'unsupported-node', 'runtime-value', 'composite', 'no-sampler', 'malformed', 'multistage-differs'];
// Only these codes name the blocking node; node IDs and class names are bounded.
const NAMED_CODES = ['unsupported-node', 'runtime-value', 'composite'];
const NAME_LENGTH = 200, MESSAGE_LENGTH = 512;
const boundedText = (value, limit) => typeof value === 'string' && value.length > 0 && value.length <= limit;
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
const validScope = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value) && value.length === 64;
@@ -81,8 +77,7 @@ function promptsFor(value) {
if (record === undefined) continue;
if (!object(record) || typeof record.status !== 'string' ||
(record.text !== undefined && record.text !== null && typeof record.text !== 'string') ||
(record.status === 'resolved' && !Object.hasOwn(confirmed, role)) ||
(record.message !== undefined && (record.status === 'resolved' || !boundedText(record.message, MESSAGE_LENGTH)))) throw new TypeError('Invalid inspection role');
(record.status === 'resolved' && !Object.hasOwn(confirmed, role))) throw new TypeError('Invalid inspection role');
}
return value;
}
@@ -146,8 +141,7 @@ function resolutionFor(value) {
const code = value.reason_code ?? null, node = value.node ?? null;
// Codes explain a non-resolved outcome; a blocking node is named only with its code.
if ((code !== null && (!REASON_CODES.includes(code) || ['resolved', 'absent'].includes(value.state))) ||
(node !== null && (!NAMED_CODES.includes(code) || !object(node) || Object.keys(node).sort().join() !== 'class_type,id' ||
!boundedText(node.id, NAME_LENGTH) || (node.class_type !== null && !boundedText(node.class_type, NAME_LENGTH))))) throw new TypeError('Invalid inspection resolution reason');
(node !== null && (code === null || !object(node) || typeof node.id !== 'string' || (node.class_type !== null && typeof node.class_type !== 'string')))) throw new TypeError('Invalid inspection resolution reason');
return value;
}
@@ -163,41 +157,11 @@ function libraryFor(value) {
return library;
}
// The legacy workflow parse (comfy-metadata.js, as of #170) also feeds the gallery and
// admin pages; it fills any prompt or setting the server could not decide, so this page
// never shows less than they do. A definite server answer is never replaced.
const LEGACY_FACTS = [['seed', 'seed'], ['steps', 'steps'], ['cfg', 'cfgScale'], ['sampler', 'sampler'], ['model', 'checkpoint']];
function comfyMetadata() {
return globalThis.ComfyMetadata || globalThis.window?.ComfyMetadata;
}
function withWorkflowParams(record, parser = comfyMetadata()) {
const graph = record.metadata.prompt_graph.status === 'parsed' ? record.metadata.prompt_graph.value : null;
const workflow = record.metadata.workflow_graph.status === 'parsed' ? record.metadata.workflow_graph.value : null;
if (typeof parser?.extractGenerationParams !== 'function' || (!graph && !workflow)) return;
const params = parser.extractGenerationParams({ prompt: graph, workflow });
const found = key => typeof params[key] === 'string' || typeof params[key] === 'number' ?
String(params[key]) !== '' && params[key] !== parser.PLACEHOLDERS?.[key] : false;
const prompts = { ...record.prompts };
for (const [role, key] of [['positive', 'positivePrompt'], ['negative', 'negativePrompt']]) {
const current = prompts[role];
if (!found(key) || current?.status === 'resolved') continue;
prompts[role] = { status: 'resolved', text: params[key], source: 'graph-inferred' };
}
record.prompts = prompts;
for (const [field, key] of LEGACY_FACTS) {
// Only open answers are filled: a known value or a definite "not recorded" stays.
if (!found(key) || !['unresolved', 'ambiguous'].includes(record.generation[field]?.status)) continue;
record.generation[field] = { status: 'known', value: String(params[key]), source: graph ? 'api-graph' : 'workflow', evidence: 'configuration', reason: null, sampler_id: null };
}
}
function unavailableFacts() {
return Object.fromEntries(FACT_FIELDS.map(key => [key, { status: 'unresolved', value: null, source: null, evidence: null, reason: null, sampler_id: null }]));
}
export function toInspectionRecord(result, image, extractFacts = globalThis.ComfyMetadata?.extractGenerationFacts || globalThis.window?.ComfyMetadata?.extractGenerationFacts, parser = comfyMetadata()) {
export function toInspectionRecord(result, image, extractFacts = globalThis.ComfyMetadata?.extractGenerationFacts || globalThis.window?.ComfyMetadata?.extractGenerationFacts) {
const scope = validateIdentity(image);
if (scope !== null && (!object(result) || result.root_scope !== scope)) throw new InspectionSourceChangedError();
if (!object(result) || result.success === false) throw new TypeError('Invalid inspection response');
@@ -207,11 +171,11 @@ export function toInspectionRecord(result, image, extractFacts = globalThis.Comf
if ((!legacy || result.image !== undefined) && (!object(result.image) || result.image.root_index !== image.root_index || result.image.path !== image.path || result.image.filename !== image.path.split('/').at(-1))) throw new TypeError('Mismatched inspection identity');
return projectInspectionPayload(result,
{ kind: 'rooted-file', key: imageKey(image), root_index: image.root_index, path: image.path, root_scope: scope },
downloadFor(image, !legacy || scope !== null), extractFacts, legacy, parser);
downloadFor(image, !legacy || scope !== null), extractFacts, legacy);
}
/** Shared bounded payload projection; adapters validate identity before entering here. */
export function projectInspectionPayload(result, identity, download, extractFacts = globalThis.ComfyMetadata?.extractGenerationFacts || globalThis.window?.ComfyMetadata?.extractGenerationFacts, legacy = false, parser = comfyMetadata()) {
export function projectInspectionPayload(result, identity, download, extractFacts = globalThis.ComfyMetadata?.extractGenerationFacts || globalThis.window?.ComfyMetadata?.extractGenerationFacts, legacy = false) {
const record = {
identity, file: null, prompts: promptsFor(result.prompts), library: null, metadata: null,
generation: unavailableFacts(), historyTarget: null, download,
@@ -224,7 +188,6 @@ export function projectInspectionPayload(result, identity, download, extractFact
if (typeof extractFacts !== 'function') throw new TypeError('Inspection facts parser unavailable');
record.generation = extractFacts({ prompt_graph: record.metadata.prompt_graph, workflow_graph: record.metadata.workflow_graph,
parameters: record.metadata.parameters, recordFormats: record.metadata.record_formats, metadataStatus: record.metadata.status, resolution });
withWorkflowParams(record, parser);
if (record.library.status === 'linked' && record.library.positive_id !== null) record.historyTarget = `#/library/${record.library.positive_id}`;
return record;
}
+22 -22
View File
@@ -1,27 +1,27 @@
/* 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; }
/* Native recipe editor and shared toast: geometry and tokens follow the approved concept (style.css 859-1007, 1424-1531). */
.library-editor, .toast { --editor-red: #ff8c98; --editor-warning: #e6bd78; }
[data-ca-theme="light"] :where(.library-editor, .toast) { --editor-red: #b71931; --editor-warning: #815514; }
.ca-tools .library-editor { 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 { background: #080b12b8; }
.library-editor .dialog-header { display: flex; justify-content: space-between; align-items: center; gap: 18px; margin-bottom: 20px; }
.library-editor .dialog-header h2 { margin: 0; font-size: 18px; font-weight: 600; line-height: inherit; }
.library-editor :where(form, p) { margin: 0; line-height: inherit; }
.library-editor p + p { margin-top: 10px; }
.ca-tools .library-editor button { min-height: 32px; font-size: 12px; }
.ca-tools .library-editor .dialog-header > button { font-size: 20px; min-width: 32px; }
.library-editor .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; flex-wrap: wrap; margin-top: 23px; }
.library-editor .field { display: grid; gap: 7px; font-size: 12px; margin: 15px 0; }
.library-editor .field :where(input, textarea, select) { width: 100%; font-size: 12px; }
.library-editor .field textarea { min-height: 100px; resize: vertical; }
.library-editor .two-col { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
.library-editor .small { font-size: 12px; color: var(--ca-muted); }
.library-editor .warning-note { font-size: 11px; color: var(--editor-warning); margin: 13px 0; }
.ca-tools .library-editor .danger { color: var(--editor-red); border-color: var(--editor-red); }
.library-editor :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; }
.library-editor .two-col { grid-template-columns: 1fr; }
.toast { bottom: 12px; }
}
@media (pointer: coarse) { .ca-tools :is(.library-editor, .vocabulary-dialog) button { min-height: 40px; } }
@media (pointer: coarse) { .ca-tools .library-editor button { min-height: 40px; } }
-11
View File
@@ -41,17 +41,6 @@ export function invalidateLibraryAfterWrite(commit) {
writeGeneration++;
if(shelfVisit) shelfVisit={filters:shelfVisit.filters,anchor:shelfVisit.layout?.anchor ?? shelfVisit.anchor ?? null};
}
/**
* Open the Library shelf with exactly these filters (Tags & categories Browse): the
* visit is seeded like a returning shelf without a cached stream, so it reads page one
* with the existing Library filters, and the heading takes focus as after a save.
*/
export function browseLibrary(filters) {
const {text='',category='',tags=[],sort='newest'}=filters || {};
if(typeof text!=='string' || typeof category!=='string' || !Array.isArray(tags) || tags.some(tag=>typeof tag!=='string') || typeof sort!=='string') throw new TypeError('Invalid Library filters');
shelfVisit={filters:{text,category,tags:[...tags],sort},anchor:null};
focusHeadingOnMount=true;window.location.hash='#/library';
}
/** Remember the selection a reconciled save names, so the destination history opens on it once. */
export function rememberLibraryResult(result) {
const positive=result?.detail?.positive, kind=result?.commit?.kind;
+4 -23
View File
@@ -4,8 +4,6 @@ import {rootedFileAdapter} from './inspection-state.mjs';
import {mountFileInspector} from './file-inspector.mjs';
let viewerId = 0;
// Upper bound on waiting for a closed Viewer's in-flight image view before destroying it.
const PENDING_VIEW_DESTROY_MS = 10000;
export function readViewerPreferences(getStorage = () => window.localStorage, normalize = globalThis.GallerySettings.normalizeGallerySettings) {
let stored;
try { stored = JSON.parse(getStorage().getItem('gallerySettings')); } catch { stored = null; }
@@ -129,19 +127,12 @@ export function createMediaViewer({container,parentScope,inspectionController,se
} else {
node.alt = selected.filename;node.decoding = 'async';
node.hidden = !!window.Viewer;
// With Viewer.js the src is released after the viewer (see release below).
if (!window.Viewer) {
mediaLocal.listen(node,'load',() => {if (current()) session.ready(key);});
mediaLocal.cleanup(() => node.removeAttribute('src'));
}
if (!window.Viewer) mediaLocal.listen(node,'load',() => {if (current()) session.ready(key);});
mediaLocal.cleanup(() => node.removeAttribute('src'));
}
node.src = url.href;media.append(node);
if (!selected.is_video && window.Viewer) {
let fitFrame = 0, fitWidth, fitHeight, viewSettled = false, released = false, fallback = 0;
const release = () => {
if (released) return;
released = true;clearTimeout(fallback);instance.destroy();node.removeAttribute('src');
};
let fitFrame = 0, fitWidth, fitHeight;
const scheduleFit = () => {
if (!current() || fitFrame) return;
fitFrame = requestAnimationFrame(() => {
@@ -154,21 +145,11 @@ export function createMediaViewer({container,parentScope,inspectionController,se
};
const instance = new window.Viewer(node,{inline:true,container:media,toolbar:false,navbar:false,title:false,button:false,
keyboard:false,fullscreen:false,transition:false,viewed() {
viewSettled = true;
if (mediaLocal.signal.aborted) { release(); return; }
if (!current()) return;
session.ready(key);scheduleFit();
}});
const observer = new ResizeObserver(scheduleFit);observer.observe(stage);
viewer = instance;mediaLocal.cleanup(() => {
observer.disconnect();cancelAnimationFrame(fitFrame);
// Inline Viewer.js keeps viewing a still-loading image after destroy(), and that view
// rebuilds its list from images that still have a src; keep both until the view
// settles, fails or times out.
if (viewSettled) { release(); return; }
node.addEventListener('error',release,{once:true});
fallback = setTimeout(release,PENDING_VIEW_DESTROY_MS);
});
viewer = instance;mediaLocal.cleanup(() => {observer.disconnect();cancelAnimationFrame(fitFrame);instance.destroy();});
}
updateControls(session.state);
}
+6 -11
View File
@@ -11,7 +11,6 @@ const sources = {
execution: 'Recorded at execution',
'graph-inferred': 'Inferred from the saved workflow',
'embedded-parameters': 'Embedded generation parameters',
'zeroed-conditioning': 'Zeroed conditioning (no negative text)',
'saved-association': 'Saved library association',
};
const statuses = {
@@ -41,11 +40,7 @@ function childScope(parentScope) {
return scope;
}
export function mountPromptBlocks(container, {prompts, sender, parentScope, roles:visibleRoles = ['positive', 'negative'], onRetry, includePairAction = true, contextLabel = ''}) {
// A nested set of blocks (the current file inside a recorded run) names its regions and actions with a
// context so assistive technology never meets two identically named prompt regions in one dialog.
const named = role => contextLabel ? `${contextLabel} ${role} prompt` : `${role} prompt`;
const action = role => contextLabel ? `${contextLabel[0].toLowerCase()}${contextLabel.slice(1)} ${role} prompt` : `${role} prompt`;
export function mountPromptBlocks(container, {prompts, sender, parentScope, roles:visibleRoles = ['positive', 'negative'], onRetry, includePairAction = true}) {
if (parentScope.signal.aborted) return {update() {}, setState() {}, dispose() {}};
const scope = childScope(parentScope);
container.classList.add('prompt-inspector');
@@ -91,7 +86,7 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role
const title = `${role[0].toUpperCase() + role.slice(1)} prompt`;
const record = state.prompts?.[role];
const block = el('section', undefined, `prompt-block prompt-${role}`);
block.setAttribute('aria-label', contextLabel ? named(role) : title);
block.setAttribute('aria-label', title);
const heading = el('div', undefined, 'prompt-block-heading');
heading.append(el('h3', title));
block.append(heading);
@@ -105,12 +100,12 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role
block.append(text);
}
const actions = el('div', undefined, 'prompt-actions');
const send = createSendButton(`Send ${action(role)}`);
const send = createSendButton(`Send ${role} prompt`);
send.dataset.sendRole = role;
send.disabled = !actionsEnabled;
const copy = createButton('', 'ca-icon-button');
copy.setAttribute('aria-label', `Copy ${action(role)}`);
copy.title = `Copy ${action(role)}`;
copy.setAttribute('aria-label', `Copy ${role} prompt`);
copy.title = `Copy ${role} prompt`;
copy.append(createWorkspaceIcon('copy'));
copy.dataset.copyRole = role;
copy.disabled = !actionsEnabled;
@@ -118,7 +113,7 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role
heading.append(actions);
const status = el('p', '', 'prompt-copy-status muted'); status.setAttribute('role', 'status');
block.append(status);
} else block.append(el('p', typeof record?.message === 'string' && record.message ? record.message : statuses[record?.status] || statuses.unresolved, 'muted'));
} else block.append(el('p', typeof record?.message === 'string' ? record.message : statuses[record?.status] || statuses.unresolved, 'muted'));
container.append(block);
}
if (includePairAction && Object.hasOwn(roles, 'positive') && Object.hasOwn(roles, 'negative')) {
+1 -1
View File
@@ -68,7 +68,7 @@ export function mountRecordedRun(container, {api, controller, sender, parentScop
const receiptNodes = new Map();
const evidenceValues = new Map();
const blocks = mountPromptBlocks(prompts, {sender, parentScope:local});
const inspector = mountFileInspector(inspectorContainer, {sender, parentScope:local, inline:true, promptContext:'Current file', onRetry:() => inspection.retry(), onPreview:() => showViewer(), onRefresh:refreshCurrent});
const inspector = mountFileInspector(inspectorContainer, {sender, parentScope:local, inline:true, onRetry:() => inspection.retry(), onPreview:() => showViewer(), onRefresh:refreshCurrent});
function selectedReceipt() {
const selected = latest.selection.receipt;
return selected && latest.outputs.items.find(item => occurrenceKey(item.occurrence) === occurrenceKey(selected));
+1 -14
View File
@@ -3,8 +3,7 @@ export const routes = [
description: 'Browse your images, inspect their metadata and review generated tags.', action: 'Open existing image gallery' },
{ id: 'library', label: 'Prompt library', href: '/prompt_manager/admin',
description: 'Find, edit and organize the prompts already in your library.', action: 'Open existing prompt library' },
// Native screen; `title` is the concept's breadcrumb/title, the href stays for Overview's card.
{ id: 'tags', label: 'Tags & categories', title: 'Tags', href: '/prompt_manager/admin#/tags',
{ id: 'tags', label: 'Tags & categories', href: '/prompt_manager/admin#/tags',
description: 'Explore your vocabulary and find the prompts associated with each tag.', action: 'Open existing tag manager' },
{ id: 'inspect', label: 'Inspect a file', href: '/prompt_manager/gallery.html',
description: 'Inspect generation metadata from an image or video file.', action: 'Open existing metadata inspector' },
@@ -13,18 +12,6 @@ export const routes = [
description: 'Open the current dashboard, then choose Settings or Maintenance for configuration, scans, backups and diagnostics.', action: 'Open existing administration' },
];
const NATIVE_ROUTES = new Set(['images', 'library', 'tags', 'overview']);
/** Breadcrumb and document title: the concept names a few pages differently from their nav label. */
export function routeTitle(route) {
return route.title || route.label;
}
/** Routes the workspace does not render yet show the generic legacy handoff panel. */
export function isHandoff(route) {
return !NATIVE_ROUTES.has(route.id);
}
export function resolveRoute(hash) {
const match = /^#\/library\/([1-9]\d*)$/.exec(hash);
if (match && Number.isSafeInteger(Number(match[1]))) {
+1 -2
View File
@@ -9,8 +9,7 @@ body[data-ca-theme="light"] { --workspace-page: #f3f4f6; --workspace-sidebar: #f
[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; }
a:focus-visible, 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; }
-388
View File
@@ -1,388 +0,0 @@
/**
* Tags & categories dialogs: Create tag, Rename or merge tag (with Delete tag…)
* and Manage categories.
*
* One `dialog.vocabulary-dialog` in the recipe editor's modal shell (concept
* simpleInput, app.js:1138-1143): header h2 + ×, `.field`, `.dialog-actions`
* with Cancel then the primary action. Recorded deviations: "Apply in prototype"
* is Apply, Manage categories adds a Category select and a Preview step (D9),
* and Rename or merge carries Delete tag… (D14). Every write goes through
* vocabulary-state.mjs; this module renders its phases as the editor does, with
* inline `.warning-note` states, and never resends a write on its own.
*/
import { createButton } from '../shared/toolkit/core.js';
import { showWorkspaceToast } from './library-editor.mjs';
import { categoryPreviewText, deleteTagText, readTagImpact } from './vocabulary-state.mjs';
const RESTORED = 'Prompt storage was restored since this dialog opened. Nothing was changed.';
const UNKNOWN = 'The result could not be confirmed. The lists were re-read; check them before applying again.';
const TAG_CONFLICT = 'This tag changed since this dialog opened. Nothing was changed. The list was re-read; review it before trying again.';
const CATEGORY_CONFLICT = 'This category changed since this dialog opened. Nothing was changed. Preview again before applying.';
const NOT_LOADED = 'The vocabulary has not loaded yet. Nothing was changed.';
const CHECKING = 'Checking linked prompts…';
const IMPACT_FAILED = 'Linked prompts could not be read. Nothing was deleted.';
const SAVING = 'Applying… The dialog stays open until the server answers.';
const DELETING = 'Deleting… The dialog stays open until the server answers.';
const PREVIEWING = 'Previewing… Nothing is changed by a preview.';
const CATEGORIES_LOADING = 'Loading categories…';
const CATEGORIES_FAILED = 'Categories could not be loaded. Nothing was changed.';
// Manage categories reads at most this many categories (10 pages) into its select.
const CATEGORY_CAP = 1000;
const CATEGORIES_CAPPED = 'Only the first 1,000 categories are listed.';
// Command phases after which the delete confirmation's impact no longer describes storage.
const STALE_IMPACT = new Set(['conflict', 'storage-changed', 'unknown-outcome']);
// Phases that send nothing until the user acts: the primary stays disabled.
const BLOCKED = new Set(['saving', 'storage-changed']);
function el(tag, text, className) {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
if (className) node.className = className;
return node;
}
function field(label, control, id) {
const wrapper = el('div', undefined, 'field');
const caption = el('label', label);
caption.htmlFor = id;
control.id = id;
wrapper.append(caption, control);
return wrapper;
}
/** Re-parent only on a real change: moving a focused button would drop its focus. */
function setChildren(parent, nodes) {
const current = [...parent.children];
if (current.length !== nodes.length || current.some((node, index) => node !== nodes[index])) parent.replaceChildren(...nodes);
}
/** Toast copy for a confirmed receipt (decision D11). */
function toastOf(kind, receipt) {
switch (kind) {
case 'create-tag': return receipt.created ? 'Tag created.' : 'Tag already exists.';
case 'rename-tag': return receipt.merged ? 'Tag merged without duplicate links.' : 'Tag renamed.';
case 'delete-tag': return 'Tag deleted.';
default: return receipt.consolidated ? 'Categories consolidated.' : 'Category renamed.';
}
}
/** Inline state for a command phase; `conflict` is the kind-specific conflict copy, `failed` the refusal prefix. */
function statusOf(state, {conflict, saving, failed, kept = ' Your entries are kept.'}) {
switch (state.phase) {
case 'saving': return {text: state.kind === 'preview-category' ? PREVIEWING : saving};
case 'write-error': return {text: `${state.kind === 'preview-category' ? 'Not previewed' : failed}: ${state.error?.message}${kept}`};
case 'conflict': return {text: conflict};
case 'storage-changed': return {text: RESTORED, reload: true};
case 'unknown-outcome': return {text: UNKNOWN};
default: return null;
}
}
/**
* `epoch()` reads the loaded lists' epoch and `categories` is the page's category
* list (`{stream, subscribe}`), which Manage categories reads to the end (capped);
* `reload()` re-reads both lists and
* `settled()` resolves once the latest re-read landed; `tagAction(id)` and
* `categoryAction(value)` find a loaded row's action for focus; `heading()` is the
* page h1, the focus fallback.
*/
export function createVocabularyDialog({api, scope, createCommand, epoch, categories, reload, settled, heading, tagAction, categoryAction}) {
const dialog = el('dialog', undefined, 'vocabulary-dialog');
dialog.setAttribute('aria-labelledby', 'vocabulary-dialog-title');
const header = el('div', undefined, 'dialog-header');
const title = el('h2');
title.id = 'vocabulary-dialog-title';
const close = createButton('×');
close.setAttribute('aria-label', 'Close dialog');
const body = el('div');
header.append(title, close);
dialog.append(header, body);
let session = null, disposed = false;
const busy = () => session?.command.state.phase === 'saving';
close.addEventListener('click', () => finish());
dialog.addEventListener('cancel', event => {
event.preventDefault();
if (!busy()) finish();
});
// A browser may still force-close a modal; treat that as a dismissal.
dialog.addEventListener('close', () => {if (!dialog.open) finish();});
/** Close without writing. Focus goes to `target`, else the trigger, else the page heading. */
function finish(target = null) {
if (!session) return;
const {command, trigger, views} = session;
session = null;
views.abort();
command.dispose();
if (dialog.open) dialog.close();
dialog.remove();
(target ?? (trigger?.isConnected ? trigger : heading()))?.focus();
}
/** A confirmed write: toast, close, then focus the resulting row once the re-read has landed. */
async function complete(kind, receipt, focusTarget) {
showWorkspaceToast(toastOf(kind, receipt));
finish(heading());
await settled();
if (disposed || session) return;
focusTarget()?.focus();
}
function show(text, view) {
title.textContent = text;
body.replaceChildren(...view);
if (!dialog.isConnected) document.body.append(dialog);
if (!dialog.open) dialog.showModal();
close.focus();
}
/** Open a new view; an open dialog swaps its content, as the concept does. */
function start(trigger, build) {
if (disposed) return;
const previous = session;
if (previous) {
previous.views.abort();
previous.command.dispose();
}
const views = new AbortController();
const next = {trigger: trigger ?? previous?.trigger ?? null, views, epoch: previous?.epoch ?? epoch(), render: () => {}};
next.command = createCommand(state => {if (session === next) next.render(state);});
session = next;
build(next);
}
/** One status line, an optional Reload, and focus kept inside the dialog when a disabled control drops it. */
function chrome(views) {
const note = el('p', '', 'warning-note');
note.id = 'vocabulary-dialog-status';
note.setAttribute('role', 'status');
note.hidden = true;
const actions = el('div', undefined, 'dialog-actions');
const reloadButton = createButton('Reload');
reloadButton.addEventListener('click', () => {finish(heading()); reload();}, {signal: views.signal});
return {note, actions, reloadButton, set(text) {
if (note.textContent !== (text || '')) note.textContent = text || '';
note.hidden = !text;
}, refocus(...candidates) {
if (!dialog.open || (document.activeElement && document.activeElement !== document.body)) return;
(candidates.find(node => node?.isConnected && !node.disabled && !node.hidden) ?? close).focus();
}};
}
/** The epoch this dialog opened with; null until the lists have loaded once. */
function openedEpoch(current) {
current.epoch ??= epoch();
return current.epoch;
}
/** Create tag and Rename or merge tag: one required name field. */
function nameView(current, {row}) {
const {command, views} = current;
const form = el('form');
const input = el('input');
input.required = true;
if (row) input.value = row.name;
const ui = chrome(views);
const remove = row ? createButton('Delete tag…', 'danger') : null;
const cancel = createButton('Cancel'), apply = createButton('Apply', 'ca-primary');
apply.type = 'submit';
form.append(field(row ? 'New name; existing names merge' : 'Tag name', input, row ? 'vocabulary-new-name' : 'vocabulary-tag-name'),
ui.note, ui.actions);
cancel.addEventListener('click', () => finish(), {signal: views.signal});
remove?.addEventListener('click', () => start(null, next => deleteView(next, row)), {signal: views.signal});
let message = null;
form.addEventListener('submit', async event => {
event.preventDefault();
const expected = openedEpoch(current);
if (!expected) {message = NOT_LOADED; render(command.state); return;}
message = null;
// After an unconfirmed result the user checked the lists; applying again is their explicit choice.
if (command.state.phase === 'unknown-outcome') command.reset();
await (row ? command.renameTag(row, input.value, expected) : command.createTag(input.value, expected));
const {phase, receipt, kind} = command.state;
if (phase === 'done' && receipt) complete(kind, receipt, () => tagAction(receipt.tag.id));
}, {signal: views.signal});
function render(state) {
const status = statusOf(state, {conflict: TAG_CONFLICT, saving: SAVING, failed: 'Not changed'});
ui.set(status?.text ?? message);
const saving = state.phase === 'saving';
apply.disabled = BLOCKED.has(state.phase) || state.phase === 'conflict';
cancel.disabled = close.disabled = saving;
if (remove) remove.disabled = saving;
setChildren(ui.actions, [...(status?.reload ? [ui.reloadButton] : []), ...(remove ? [remove] : []), cancel, apply]);
if (!saving && state.phase !== 'done') ui.refocus(status?.reload ? ui.reloadButton : null, apply, cancel);
}
current.render = render;
show(row ? 'Rename or merge tag' : 'Create tag', [form]);
render(command.state);
}
/** The Delete tag confirmation: the exact name and its real links, then Cancel / Delete. */
function deleteView(current, row) {
const {command, views} = current;
const impactText = el('p', '', 'small');
impactText.hidden = true;
const ui = chrome(views);
const retry = createButton('Retry');
const cancel = createButton('Cancel'), confirm = createButton('Delete', 'danger');
let read = 'loading';
cancel.addEventListener('click', () => finish(), {signal: views.signal});
retry.addEventListener('click', () => {
// After an unconfirmed delete, Retry only re-reads the impact; it never resends the delete.
if (command.state.phase === 'unknown-outcome') command.reset();
readImpact();
}, {signal: views.signal});
confirm.addEventListener('click', async () => {
await command.deleteTag(row, current.epoch);
const {phase, receipt, kind} = command.state;
if (phase === 'done' && receipt) complete(kind, receipt, () => null);
}, {signal: views.signal});
async function readImpact() {
read = 'loading';
render(command.state);
try {
const value = await readTagImpact(api, row, {signal: views.signal});
if (views.signal.aborted) return;
const expected = openedEpoch(current);
if (expected && value.library_epoch !== expected) read = 'restored';
else {
read = 'ready';
impactText.textContent = deleteTagText(row.name, value.impact);
}
} catch (error) {
if (views.signal.aborted) return;
// A missing tag is a changed tag: the lists are re-read for review.
read = error?.status === 404 ? 'conflict' : 'failed';
if (read === 'conflict') reload();
}
render(command.state);
}
function render(state) {
const status = statusOf(state, {conflict: TAG_CONFLICT, saving: DELETING, failed: 'Not deleted', kept: ''})
?? {loading: {text: CHECKING}, failed: {text: IMPACT_FAILED, retry: true}, restored: {text: RESTORED, reload: true},
conflict: {text: TAG_CONFLICT}}[read] ?? null;
ui.set(status?.text);
const stale = STALE_IMPACT.has(state.phase);
impactText.hidden = read !== 'ready' || stale;
const saving = state.phase === 'saving';
confirm.disabled = read !== 'ready' || saving || stale;
cancel.disabled = close.disabled = saving;
const recovery = status?.reload ? ui.reloadButton : status?.retry || state.phase === 'unknown-outcome' ? retry : null;
setChildren(ui.actions, [...(recovery ? [recovery] : []), cancel, confirm]);
if (!saving && state.phase !== 'done') ui.refocus(recovery, confirm, cancel);
}
current.render = render;
show('Delete tag', [impactText, ui.note, ui.actions]);
readImpact();
}
/** Manage categories: Category select, proposed name, Preview then Apply. */
function manageView(current) {
const {command, views} = current;
const form = el('form');
const {stream} = categories;
// A category command carries the category list's own epoch.
current.epoch = stream.state.epoch;
const source = el('select');
source.required = true;
const target = el('input');
target.required = true;
// Its own id: the recipe editor already owns `datalist#categories`.
target.setAttribute('list', 'vocabulary-category-options');
const list = el('datalist');
list.id = 'vocabulary-category-options';
const retry = createButton('Retry');
retry.addEventListener('click', () => stream.loadMore(), {signal: views.signal});
let drain = 'loading', shown = null;
function fill(values) {
const key = values.join('\n');
if (key === shown) return;
shown = key;
const selected = source.value;
source.replaceChildren(...values.map(value => new Option(value, value)));
if (values.includes(selected)) source.value = selected;
list.replaceChildren(...values.map(value => Object.assign(document.createElement('option'), {value})));
}
/** Read the shared category list to its end (at most CATEGORY_CAP) before the select is offered. */
function sync() {
const state = stream.state;
if (state.resetRequired) drain = 'restored';
else if (state.error) drain = 'failed';
// A reset emits its empty first state before it starts reading; the stream itself reads page one.
else if (state.loading || state.epoch === null) drain = 'loading';
else if (state.hasMore && state.items.length < CATEGORY_CAP) {
drain = 'loading';
stream.loadMore();
return;
} else {
fill(state.items.slice(0, CATEGORY_CAP).map(item => item.value));
drain = state.hasMore || state.items.length > CATEGORY_CAP ? 'capped' : 'ready';
}
render(command.state);
}
views.signal.addEventListener('abort', categories.subscribe(() => sync()), {once: true});
const impactText = el('p', '', 'small');
impactText.hidden = true;
const ui = chrome(views);
const cancel = createButton('Cancel'), primary = createButton('Preview', 'ca-primary');
primary.type = 'submit';
form.append(field('Category', source, 'vocabulary-category'), field('Proposed new category name', target, 'vocabulary-category-name'),
list, impactText, ui.note, ui.actions);
cancel.addEventListener('click', () => finish(), {signal: views.signal});
for (const [control, type] of [[source, 'change'], [target, 'input']]) {
control.addEventListener(type, () => command.edit(), {signal: views.signal});
}
let message = null;
form.addEventListener('submit', async event => {
event.preventDefault();
const {phase, preview} = command.state;
if (phase === 'previewed') {
await command.applyCategory(preview);
const {receipt, kind} = command.state;
if (command.state.phase === 'done' && receipt) complete(kind, receipt, () => categoryAction(receipt.target));
return;
}
const expected = current.epoch ??= stream.state.epoch;
if (!expected) {message = NOT_LOADED; render(command.state); return;}
message = null;
if (phase === 'unknown-outcome') command.reset();
await command.previewCategory(source.value, target.value, expected);
}, {signal: views.signal});
function render(state) {
const previewed = state.phase === 'previewed';
const status = statusOf(state, {conflict: CATEGORY_CONFLICT, saving: SAVING, failed: 'Not changed'})
?? (message ? {text: message} : null)
?? {loading: {text: CATEGORIES_LOADING}, failed: {text: CATEGORIES_FAILED, retry: true},
restored: {text: RESTORED, reload: true}, capped: {text: CATEGORIES_CAPPED}}[drain] ?? null;
ui.set(status?.text);
if (previewed) impactText.textContent = categoryPreviewText(state.preview);
impactText.hidden = !previewed;
primary.textContent = previewed ? 'Apply' : 'Preview';
const saving = state.phase === 'saving';
primary.disabled = BLOCKED.has(state.phase) || !['ready', 'capped'].includes(drain);
cancel.disabled = close.disabled = saving;
const recovery = status?.reload ? ui.reloadButton : status?.retry ? retry : null;
setChildren(ui.actions, [...(recovery ? [recovery] : []), cancel, primary]);
if (!saving && state.phase !== 'done') ui.refocus(recovery, primary, cancel);
}
current.render = render;
show('Manage categories', [form]);
sync();
}
function dispose() {
if (disposed) return;
finish();
disposed = true;
}
scope.cleanup(dispose);
return {
createTag: trigger => start(trigger, current => nameView(current, {row: null})),
renameTag: (row, trigger) => start(trigger, current => nameView(current, {row})),
manageCategories: trigger => start(trigger, current => manageView(current)),
dispose,
};
}
-287
View File
@@ -1,287 +0,0 @@
/**
* Native Tags & categories state: bounded vocabulary pages and guarded commands.
*
* UI-agnostic. The page renders `stream.state` / `command.state` and calls their
* methods. Reads are owned by their stream: reset() or dispose() aborts the read
* in flight and a superseded response never lands. Writes are sent once, without
* an abort signal (aborting cannot undo a write), and are never replayed.
*
* Category pages are keyed by each value's lowest prompt id, which can move
* between pages after an edit, so appended rows are deduplicated (first wins)
* and every command that may have changed storage calls `onInvalidate` so the
* page resets its streams to the first page.
*/
import { countOf } from './plural.mjs';
const LIMIT = 100;
const BASE = '/prompt_manager/vocabulary';
const KINDS = {
tags: {key: row => row.id, valid: row => id(row.id) && typeof row.name === 'string' && row.name.length > 0},
categories: {key: row => row.value, valid: row => typeof row.value === 'string' && row.value.length > 0},
};
const RECOVERY = {'storage-changed': 'storage-changed', 'vocabulary-conflict': 'conflict'};
// A proxy timeout or bad gateway cannot tell whether the server committed the write.
const UNCONFIRMED_STATUS = new Set([502, 503, 504]);
// Phases that may send a command; done, storage-changed and unknown-outcome need reset().
const READY = new Set(['idle', 'previewed', 'write-error', 'conflict']);
const IMPACT_FIELDS = ['positive_prompts', 'other_prompts', 'negative_variants', 'archived_negative_variants'];
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
const id = value => Number.isSafeInteger(value) && value > 0;
const count = value => Number.isSafeInteger(value) && value >= 0;
const epochOf = value => typeof value === 'string' && /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(value);
const hexToken = value => typeof value === 'string' && /^[0-9a-f]{64}$/.test(value);
function requireValue(valid, message) {if (!valid) throw new TypeError(message);}
function freeze(value) {
if (value && typeof value === 'object' && !Object.isFrozen(value)) {Object.values(value).forEach(freeze); Object.freeze(value);}
return value;
}
function storageChanged() {
return Object.assign(new Error('Prompt storage was restored. Reload the lists before continuing.'), {code: 'storage-changed'});
}
// Duck-typed by name: this ES module cannot import the UMD api-client.js.
function isApiError(error) {return error instanceof Error && error.name === 'ApiError' && Number.isInteger(error.status);}
function validatePage(page, kind, cursor) {
const rows = page?.[kind];
const {key, valid} = KINDS[kind];
requireValue(object(page) && page.success === true && epochOf(page.library_epoch) && Array.isArray(rows) &&
rows.every(row => object(row) && valid(row) && count(row.prompt_count)) &&
new Set(rows.map(key)).size === rows.length && count(page.total) && typeof page.has_more === 'boolean' &&
(page.next_cursor === null || (typeof page.next_cursor === 'string' && page.next_cursor.length > 0)) &&
page.has_more === (page.next_cursor !== null) && !(page.has_more && page.next_cursor === cursor),
`Invalid ${kind} page`);
}
/** One bounded, append-only list of tags or categories. */
export function createVocabularyStream({api, kind, onChange = () => {}}) {
requireValue(Object.hasOwn(KINDS, kind), 'Unknown vocabulary kind');
const {key} = KINDS[kind];
const initial = () => ({items: Object.freeze([]), total: 0, cursor: null, hasMore: true, loading: false, error: null,
epoch: null, resetRequired: false});
let state = Object.freeze(initial());
let request = null, disposed = false, seen = new Set();
const emit = patch => {state = Object.freeze({...state, ...patch}); onChange(state);};
async function loadMore() {
if (disposed || state.loading || !state.hasMore || state.resetRequired) return 0;
const current = request = new AbortController();
const cursor = state.cursor;
const query = new URLSearchParams({limit: String(LIMIT)});
if (cursor !== null) query.set('cursor', cursor);
emit({loading: true, error: null});
try {
const page = await api.get(`${BASE}/${kind}?${query}`, {signal: current.signal, cache: 'no-store'});
if (disposed || current !== request) return 0;
validatePage(page, kind, cursor);
request = null;
if (state.epoch !== null && page.library_epoch !== state.epoch) {
emit({loading: false, error: storageChanged(), resetRequired: true});
return 0;
}
const incoming = page[kind].filter(row => !seen.has(key(row))).map(row => Object.freeze({...row}));
incoming.forEach(row => seen.add(key(row)));
emit({items: incoming.length ? Object.freeze([...state.items, ...incoming]) : state.items, total: page.total,
cursor: page.next_cursor, hasMore: page.has_more, loading: false, error: null, epoch: page.library_epoch});
return incoming.length;
} catch (error) {
if (!disposed && current === request) {request = null; emit({loading: false, error});}
return 0;
}
}
return {
get state() {return state;},
loadMore,
/** Drop every loaded row and read again from the first page. */
reset() {
if (disposed) return Promise.resolve(0);
request?.abort();
request = null;
seen = new Set();
emit(initial());
return loadMore();
},
dispose() {disposed = true; request?.abort(); request = null;},
};
}
function validateTag(value) {return object(value) && KINDS.tags.valid(value) && count(value.prompt_count);}
function validateImpact(value) {return object(value) && IMPACT_FIELDS.every(name => count(value[name]));}
function requireRow(row) {requireValue(object(row) && id(row.id) && typeof row.name === 'string', 'Invalid tag row');}
function requireEpoch(epoch) {requireValue(epochOf(epoch), 'Invalid library epoch');}
function requireText(value, name) {requireValue(typeof value === 'string', `${name} must be a string`);}
const RECEIPTS = {
'create-tag': value => typeof value.created === 'boolean' && validateTag(value.tag),
'rename-tag': value => typeof value.merged === 'boolean' && validateTag(value.tag) &&
count(value.components_changed) && count(value.pairs_changed),
'delete-tag': value => value.deleted === true && count(value.components_changed) && count(value.pairs_changed),
'apply-category': (value, body) => value.source === body.source && value.target === body.target &&
typeof value.consolidated === 'boolean' && validateImpact(value.impact),
};
function writePhase(error) {
if (!isApiError(error)) return 'unknown-outcome';
if (error.status === 409 && Object.hasOwn(RECOVERY, error.code)) return RECOVERY[error.code];
if ((error.status >= 200 && error.status < 300) || UNCONFIRMED_STATUS.has(error.status)) return 'unknown-outcome';
return 'write-error';
}
/**
* One dialog's command. Phases: idle, saving, previewed, write-error, conflict,
* storage-changed, unknown-outcome, done (and disposed). `onInvalidate({phase,
* kind, receipt})` fires whenever stored vocabulary may differ from the loaded
* lists: after a receipt, a conflict, a restore or an unconfirmed write, including
* one that lands after `dispose()`.
*/
export function createVocabularyCommand({api, onChange = () => {}, onInvalidate = () => {}}) {
let state = freeze({phase: 'idle', kind: null, error: null, preview: null, receipt: null});
let disposed = false;
// Set when the dialog's inputs change while a category preview is in flight.
let stalePreview = false;
const emit = patch => {state = freeze({...state, ...patch}); onChange(state);};
const invalidate = () => onInvalidate(freeze({phase: state.phase, kind: state.kind, receipt: state.receipt}));
async function write(kind, method, url, body) {
emit({phase: 'saving', kind, error: null, preview: null, receipt: null});
let phase = 'done', error = null, receipt = null;
try {
const value = await api[method](url, body);
if (object(value) && value.success === true && value.library_epoch === body.expected_epoch && RECEIPTS[kind](value, body)) {
receipt = freeze({...value});
} else {
// The request reached the server, so the write may have committed.
phase = 'unknown-outcome';
error = new TypeError('The server response could not be confirmed.');
}
} catch (failure) {
phase = writePhase(failure);
error = failure;
}
if (disposed) {
// A write that may have committed still invalidates cached Library views after the dialog went away
// (forced close, Back); nothing is rendered then.
if (phase !== 'write-error') onInvalidate(freeze({phase, kind, receipt}));
return;
}
emit({phase, error, receipt});
if (phase !== 'write-error') invalidate();
}
async function preview(body) {
emit({phase: 'saving', kind: 'preview-category', error: null, preview: null, receipt: null});
stalePreview = false;
try {
const value = await api.post(`${BASE}/categories/preview`, body);
requireValue(object(value) && value.success === true && value.library_epoch === body.expected_epoch &&
value.source === body.source && value.target === body.target.trim() && typeof value.target_exists === 'boolean' &&
validateImpact(value.impact) && hexToken(value.impact_token), 'Invalid category preview');
// Inputs edited while it was pending: it describes values no longer on screen, so Preview is needed again.
if (!disposed) emit(stalePreview ? {phase: 'idle'} : {phase: 'previewed', preview: freeze({...value})});
} catch (error) {
if (disposed) return;
// A preview writes nothing: only a restore or a changed category needs recovery.
const phase = isApiError(error) && error.status === 409 && Object.hasOwn(RECOVERY, error.code) ? RECOVERY[error.code] : 'write-error';
if (phase === 'write-error' && stalePreview) {emit({phase: 'idle'}); return;}
emit({phase, error});
if (phase !== 'write-error') invalidate();
}
}
const ready = () => !disposed && READY.has(state.phase);
return {
get state() {return state;},
createTag(name, epoch) {
requireText(name, 'Tag name');
requireEpoch(epoch);
if (!ready()) return Promise.resolve();
return write('create-tag', 'post', `${BASE}/tags`, {expected_epoch: epoch, name});
},
renameTag(row, newName, epoch) {
requireRow(row);
requireText(newName, 'New name');
requireEpoch(epoch);
if (!ready()) return Promise.resolve();
return write('rename-tag', 'patch', `${BASE}/tags/${row.id}`, {expected_epoch: epoch, name: row.name, new_name: newName});
},
deleteTag(row, epoch) {
requireRow(row);
requireEpoch(epoch);
if (!ready()) return Promise.resolve();
return write('delete-tag', 'post', `${BASE}/tags/${row.id}/delete`, {expected_epoch: epoch, name: row.name});
},
previewCategory(source, target, epoch) {
requireText(source, 'Category');
requireText(target, 'New category name');
requireEpoch(epoch);
if (!ready()) return Promise.resolve();
return preview({expected_epoch: epoch, source, target});
},
/** Apply only the preview currently shown; anything else is ignored. */
applyCategory(shown) {
if (!ready() || state.phase !== 'previewed' || shown !== state.preview) return Promise.resolve();
return write('apply-category', 'post', `${BASE}/categories/apply`, {expected_epoch: shown.library_epoch,
source: shown.source, target: shown.target, impact_token: shown.impact_token});
},
/** The user changed a dialog input: a shown or pending preview no longer describes it. */
edit() {
if (disposed) return;
if (state.phase === 'previewed') emit({phase: 'idle', preview: null});
else if (state.phase === 'saving' && state.kind === 'preview-category') stalePreview = true;
},
/** Explicit user recovery (after re-reading the lists); never resends anything. */
reset() {
if (!disposed && state.phase !== 'saving') emit({phase: 'idle', kind: null, error: null, preview: null, receipt: null});
},
dispose() {
disposed = true;
state = freeze({...state, phase: 'disposed'});
},
};
}
/** Library filters for a row's Browse: exactly one constraint, the stored value. */
export function browseFilters(kind, value) {
requireValue(Object.hasOwn(KINDS, kind), 'Unknown vocabulary kind');
requireValue(typeof value === 'string' && value.length > 0, 'Invalid vocabulary value');
return kind === 'tags'
? {text: '', category: '', tags: [value], sort: 'newest'}
: {text: '', category: value, tags: [], sort: 'newest'};
}
/** The exact tag's current owner counts, for the delete confirmation. Throws on any unconfirmed shape. */
export function readTagImpact(api, row, {signal} = {}) {
requireRow(row);
return api.get(`${BASE}/tags/${row.id}/impact`, {signal, cache: 'no-store'}).then(value => {
requireValue(object(value) && value.success === true && epochOf(value.library_epoch) && validateImpact(value.impact), 'Invalid tag impact');
return freeze({library_epoch: value.library_epoch, impact: {...value.impact}});
});
}
/** Owner kinds with a nonzero count, in sentence form ("a, b and c"); empty when nothing is linked. */
function impactList(impact) {
const negatives = impact.negative_variants + impact.archived_negative_variants;
const parts = [];
if (impact.positive_prompts) parts.push(countOf(impact.positive_prompts, 'positive prompt'));
if (negatives) {
const archived = impact.archived_negative_variants ? ` (${impact.archived_negative_variants} archived)` : '';
parts.push(`${countOf(negatives, 'negative variant')}${archived}`);
}
if (impact.other_prompts) parts.push(countOf(impact.other_prompts, 'other saved prompt'));
return parts.length > 1 ? `${parts.slice(0, -1).join(', ')} and ${parts.at(-1)}` : parts.join('');
}
/** The Manage categories preview sentence (decision D9); zero counts are left out. */
export function categoryPreviewText(shown) {
const outcome = shown.target_exists ? `consolidates it into the existing “${shown.target}”` : `renames it to “${shown.target}”`;
return `“${shown.source}” is used by ${impactList(shown.impact)}. Apply ${outcome}.`;
}
/** The Delete tag confirmation sentence (decision D14). */
export function deleteTagText(name, impact) {
const list = impactList(impact);
if (!list) return `“${name}” is not linked to any saved prompt. Deleting it removes only the name.`;
return `Deleting “${name}” removes the name and its links from ${list}. The prompts, negative variants, images and history themselves are kept.`;
}
-47
View File
@@ -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; }
}
-238
View File
@@ -1,238 +0,0 @@
/**
* Native Tags & categories screen (concept renderTags, app.js:719-729).
*
* Composition, labels and order follow the approved concept; the recorded
* deviations are Auto-tag images omitted (no image-tag owner yet), Images cells
* "—" / "Not available yet", and bounded lists with Load more. Rows are real
* vocabulary pages from vocabulary-state.mjs; Browse hands the exact constraint
* to the existing Library filters.
*/
import { createButton } from '../shared/toolkit/core.js';
import { browseFilters, createVocabularyCommand, createVocabularyStream } from './vocabulary-state.mjs';
import { countOf } from './plural.mjs';
import { browseLibrary, invalidateLibraryAfterWrite } from './library.mjs';
import { createVocabularyDialog } from './vocabulary-dialog.mjs';
const COPY = {
tags: {empty: 'No tags yet.', error: 'Tags could not be loaded. Nothing was changed.', more: 'Load more tags'},
categories: {empty: 'No categories yet.', error: 'Categories could not be loaded. Nothing was changed.', more: 'Load more categories'},
};
// Append the next page this far before the end of a list comes into view.
const LOAD_MARGIN = 250;
function el(tag, text, className) {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
if (className) node.className = className;
return node;
}
/** The concept's `.operation` row: h3 title, p description, one row action. */
function operation(title, description, label) {
const row = el('div', undefined, 'operation');
const copy = el('div');
copy.append(el('h3', title), el('p', description));
const actions = el('div', undefined, 'row-actions');
const button = createButton(label);
actions.append(button);
row.append(copy, actions);
return {row, button};
}
function tagRow(tag) {
const row = el('tr');
const images = el('td');
const dash = el('span', '—');
dash.setAttribute('aria-hidden', 'true');
images.append(dash, el('span', 'Not available yet', 'ca-visually-hidden'));
const cell = el('td');
const actions = el('div', undefined, 'row-actions');
const browse = createButton('Browse');
browse.dataset.action = 'browse';
const rename = createButton('Rename / merge');
rename.dataset.action = 'rename';
actions.append(browse, rename);
cell.append(actions);
row.append(el('td', tag.name), el('td', String(tag.prompt_count)), images, cell);
return row;
}
function categoryRow(category) {
const {row, button} = operation(category.value, countOf(category.prompt_count, 'positive prompt'), 'Browse');
button.dataset.action = 'browse';
return row;
}
/**
* One bounded list: rows append as pages arrive, the end of the list loads the
* next page each time it scrolls into view, and `Load more` stays available while
* more remain. Only a new intersection appends: when scroll anchoring keeps the end
* in view (stacked layout below 700px), pages do not chain on their own. Before any row arrives (loading, failed) the list shows no rows, so a
* failed read never looks like zero counts.
*/
function mountList(scope, api, kind, {content, rowsHost, renderRow, after}) {
const copy = COPY[kind];
const empty = el('p', copy.empty, 'empty');
const continuation = el('div', undefined, 'vocabulary-continuation');
const status = el('p', '', 'vocabulary-status');
status.setAttribute('role', 'status');
const alert = el('p', copy.error, 'vocabulary-error');
alert.setAttribute('role', 'alert');
const retry = createButton('Retry');
const more = createButton(copy.more);
continuation.append(status, alert, retry, more);
after.after(empty, continuation);
const items = new WeakMap();
const listeners = new Set();
let rendered = 0, first = null;
function maybeLoad() {
const {state} = stream;
if (scope.signal.aborted || !state.hasMore || state.loading || state.error || state.resetRequired) return;
const bounds = continuation.getBoundingClientRect();
if (bounds.top <= window.innerHeight + LOAD_MARGIN && bounds.bottom >= -LOAD_MARGIN) stream.loadMore();
}
function render(state) {
// A reset starts again from page one; otherwise pages only append.
if (state.items.length < rendered || (rendered && state.items[0] !== first)) {
rowsHost.replaceChildren();
rendered = 0;
}
const appended = [];
for (const item of state.items.slice(rendered)) {
const row = renderRow(item);
items.set(row, item);
appended.push(row);
}
rowsHost.append(...appended);
rendered = state.items.length;
first = state.items[0] ?? null;
const failed = Boolean(state.error);
content.hidden = !rendered;
empty.hidden = Boolean(rendered || state.loading || failed || state.hasMore);
status.textContent = state.loading ? 'Loading…' : '';
status.hidden = !state.loading;
alert.hidden = !failed;
retry.hidden = !failed;
const hideMore = !state.hasMore || failed || !rendered;
// Keyboard users keep a focus target: Retry after a failed read, else the first new (or first) row.
if (hideMore && document.activeElement === more) {
(failed ? retry : appended[0]?.querySelector('button') ?? rowsHost.querySelector('button'))?.focus();
}
more.hidden = hideMore;
more.setAttribute('aria-disabled', String(state.loading));
continuation.hidden = !(state.loading || failed || state.hasMore);
for (const listener of listeners) listener(state);
}
const stream = createVocabularyStream({api, kind, onChange: render});
scope.listen(more, 'click', () => stream.loadMore());
scope.listen(retry, 'click', () => (stream.state.resetRequired ? stream.reset() : stream.loadMore()));
let observer = null;
if (typeof IntersectionObserver === 'function') {
observer = new IntersectionObserver(entries => {
if (entries.some(entry => entry.isIntersecting)) maybeLoad();
}, {rootMargin: `${LOAD_MARGIN}px`});
observer.observe(continuation);
}
scope.cleanup(() => {
stream.dispose();
observer?.disconnect();
});
render(stream.state);
/** The rendered row whose item matches, if it is loaded. */
function rowFor(match) {
return [...rowsHost.children].find(row => {const item = items.get(row); return item !== undefined && match(item);}) ?? null;
}
/** Follow this list's state (a dialog reading further pages); returns the unsubscribe. */
function subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
}
return {stream, itemOf: row => items.get(row), rowFor, subscribe};
}
/** Mount the screen into `main` below the shell's heading; `scope` owns every request, listener and observer. */
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 create = createButton('+ Create tag');
actions.append(create);
heading.append(actions);
const grid = el('div', undefined, 'two-col');
const tagSurface = el('section', undefined, 'surface');
const wrap = el('div', undefined, 'table-wrap');
const table = el('table');
const head = el('thead');
const headRow = el('tr');
for (const label of ['Tag', 'Prompts', 'Images', 'Actions']) {
const cell = el('th', label);
cell.scope = 'col';
headRow.append(cell);
}
head.append(headRow);
const body = el('tbody');
table.append(el('caption', 'Reusable tags'), head, body);
wrap.append(table);
tagSurface.append(el('h2', 'Tag vocabulary'),
el('p', 'Category/tag autocomplete uses this same vocabulary across nodes and dialogs.'), wrap);
const categorySurface = el('section', undefined, 'surface');
const categoryRows = el('div', undefined, 'vocabulary-categories');
const manage = operation('Manage category', 'Rename or consolidate a category with a preview of affected recipes.', 'Review');
categorySurface.append(el('h2', 'Categories'),
el('p', 'Categories provide a broad home. Tags describe content; proposed collections group projects without changing either.'),
categoryRows, manage.row);
grid.append(tagSurface, categorySurface);
main.append(grid);
const tags = mountList(scope, api, 'tags', {content: wrap, rowsHost: body, renderRow: tagRow, after: wrap});
const categories = mountList(scope, api, 'categories', {content: categoryRows, rowsHost: categoryRows, renderRow: categoryRow, after: categoryRows});
scope.listen(body, 'click', event => {
const button = event.target.closest('button[data-action="browse"]');
const tag = button && tags.itemOf(button.closest('tr'));
if (tag) browseLibrary(browseFilters('tags', tag.name));
});
scope.listen(categoryRows, 'click', event => {
const button = event.target.closest('button[data-action="browse"]');
const category = button && categories.itemOf(button.closest('.operation'));
if (category) browseLibrary(browseFilters('categories', category.value));
});
let settled = Promise.resolve();
/** Re-read both lists from page one: after any command, rows can move between pages. */
function reload() {
settled = Promise.all([tags.stream.reset(), categories.stream.reset()]).then(() => undefined);
return settled;
}
/** A guarded command for one dialog; any outcome that may have changed storage re-reads the lists and the Library shelf. */
function createCommand(onChange) {
return createVocabularyCommand({api, onChange, onInvalidate: event => {
invalidateLibraryAfterWrite({kind: 'vocabulary', receipt: event.receipt || {phase: event.phase}});
reload();
}});
}
const h1 = main.querySelector('h1');
const actionOf = (row, action) => row?.querySelector(`button[data-action="${action}"]`) ?? null;
const dialog = createVocabularyDialog({api, scope, createCommand,
epoch: () => tags.stream.state.epoch ?? categories.stream.state.epoch,
categories: {stream: categories.stream, subscribe: categories.subscribe},
reload,
// Rows are replaced on every reset, so a result's row exists only once the re-read has landed.
settled: () => settled,
heading: () => h1,
tagAction: id => actionOf(tags.rowFor(item => item.id === id), 'rename'),
categoryAction: value => actionOf(categories.rowFor(item => item.value === value), 'browse')});
scope.listen(create, 'click', () => dialog.createTag(create));
scope.listen(manage.button, 'click', () => dialog.manageCategories(manage.button));
scope.listen(body, 'click', event => {
const button = event.target.closest('button[data-action="rename"]');
const tag = button && tags.itemOf(button.closest('tr'));
if (tag) dialog.renameTag(tag, button);
});
reload();
return {create, manage: manage.button, tags: tags.stream, categories: categories.stream, reload, createCommand, dialog};
}
-1
View File
@@ -12,7 +12,6 @@
<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">
<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>