Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f53d02e3d1 | ||
|
|
5b2c21bdaf | ||
|
|
25e7ae158b | ||
|
|
08151ca93d | ||
|
|
b2cc489fd4 | ||
|
|
954369add9 | ||
|
|
0e3cdeeea8 | ||
|
|
347de6a023 | ||
|
|
8604c5fbac | ||
|
|
fd747a0ffc | ||
|
|
7a4b6eebb2 |
+131
-21
@@ -50,6 +50,13 @@ stdin open and the server (`--exit-on-stdin-eof`) shuts down cleanly, removing i
|
|||||||
temp root, when that pipe closes, so a worker killed without teardown leaves no
|
temp root, when that pipe closes, so a worker killed without teardown leaves no
|
||||||
server behind. A test that fails gets the stderr its server wrote during the test
|
server behind. A test that fails gets the stderr its server wrote during the test
|
||||||
attached to the report as `server-stderr` (an aiohttp traceback behind a 500).
|
attached to the report as `server-stderr` (an aiohttp traceback behind a 500).
|
||||||
|
A `/__test__/.../reset` still running after 2 s writes a report to the job log,
|
||||||
|
passing test or not: every thread's stack taken while it waits, the default
|
||||||
|
executor's threads and queue, the open connections and WAL size, then the total
|
||||||
|
and how long each executor call queued and ran. Its lines start with
|
||||||
|
`pm-slow-reset| ` (`tests/browser/slow_reset_watch.py`). The report never reads a
|
||||||
|
connection another thread is using: one that is busy inside SQLite would block
|
||||||
|
that read with the GIL held and freeze the whole server.
|
||||||
|
|
||||||
The coverage run can be split the way CI splits it, and each shard keeps the raw
|
The coverage run can be split the way CI splits it, and each shard keeps the raw
|
||||||
V8 files it writes under `coverage/js/raw`:
|
V8 files it writes under `coverage/js/raw`:
|
||||||
@@ -398,6 +405,13 @@ naming it, here and in a detailer's image ancestry; a latent that is not a link
|
|||||||
reaches its own sampler is `malformed`; running out of budget is `work-limit` 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;
|
`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.
|
selector branch runs share it and other candidates are charged its visit cost.
|
||||||
|
A UI workflow node whose `type` is a list or an object (read only for the overwritten
|
||||||
|
PromptManager check) is skipped like a non-object node, since it can be neither a
|
||||||
|
subgraph instance nor a PromptManager node, so a prompt that resolves still resolves
|
||||||
|
and the other nodes still feed the check; a list or object `class_type` is never a
|
||||||
|
preview output. Before #286 both raised and the inspect route answered 500
|
||||||
|
(`test_unhashable_*` in `tests/test_api_prompt_inspector.py`, the `workflow-type-*`
|
||||||
|
golden cases on both sides).
|
||||||
Text encoders: `single` encoders (the Qwen image encoders, `TextEncodeQwenImage21`
|
Text encoders: `single` encoders (the Qwen image encoders, `TextEncodeQwenImage21`
|
||||||
slot 0 `prompt` and slot 1 `negative_prompt`, `KikoTextEncode`) read one string
|
slot 0 `prompt` and slot 1 `negative_prompt`, `KikoTextEncode`) read one string
|
||||||
input unchanged. `parts-equal` encoders (CLIPTextEncodeSDXL, Flux, SD3) resolve when
|
input unchanged. `parts-equal` encoders (CLIPTextEncodeSDXL, Flux, SD3) resolve when
|
||||||
@@ -1581,7 +1595,43 @@ AVI/WMV preview only), chosen or dropped, previews inline. The heading's one act
|
|||||||
the shared viewer with the full inspector on a record read entirely in this browser
|
the shared viewer with the full inspector on a record read entirely in this browser
|
||||||
(`web/js/workspace/inspect.mjs`, `local-session.mjs`, `local-inspection.mjs` and the L1
|
(`web/js/workspace/inspect.mjs`, `local-session.mjs`, `local-inspection.mjs` and the L1
|
||||||
readers `png-metadata.js`, `local-video.mjs`, `local-projection.mjs`). Nothing is
|
readers `png-metadata.js`, `local-video.mjs`, `local-projection.mjs`). Nothing is
|
||||||
uploaded, imported or stored. Only HTML and JS change, so no ComfyUI restart is needed.
|
uploaded, imported or stored. PRs L1 and L2 change only HTML and JS, so they need no
|
||||||
|
ComfyUI restart; PR L3 does (below).
|
||||||
|
|
||||||
|
Local roles (#249 PR L3, decision LI-1): the prompts and resolution of a local record are
|
||||||
|
the server inspector's for the same records. `web/js/workspace/local-resolver.mjs` (with
|
||||||
|
`local-resolver-support.mjs`, `local-resolver-stages.mjs`, `local-parameters.mjs` and
|
||||||
|
`py-json.mjs`) ports `inspect_prompt_evidence` with `INSPECTION_PROFILE`: execution
|
||||||
|
evidence, graph tracing, every bound, several sampler candidates with the
|
||||||
|
identical-branch rule (#270), preview-only stages, detailer stages (multistage) and A1111
|
||||||
|
`parameters`. It reads the server's own node table, `web/js/inspection-semantics.json`,
|
||||||
|
which the page fetches once through the API client as it mounts, before any selection
|
||||||
|
(`local-semantics.mjs`; `/prompt_manager/js/inspection-semantics.json` is the one JSON
|
||||||
|
file the static route serves, by exact path). A table that cannot be fetched answers
|
||||||
|
"The inspector's node table is unavailable." as the server does. The legacy fill in
|
||||||
|
`projectInspectionPayload` then applies to a local record exactly where it applies to the
|
||||||
|
Images answer for the same bytes. Python is the oracle:
|
||||||
|
`tests/test_local_inspect_parity.py` `LocalResolverParityTests` compares the local
|
||||||
|
evidence byte for byte with `inspect_prompt_evidence` for every parity PNG and inspection
|
||||||
|
vector (prompts, resolution and facts), the golden and selection record sets
|
||||||
|
(`tests/local_resolver_cases.py`, `tests/local_resolver_selection_cases.py`), every work
|
||||||
|
bound and 1,200 seeded fuzz cases; `tests/fixtures/local-inspect/resolver-vectors.json`
|
||||||
|
holds the committed answers the Node unit test reads. The browser spec `chooser shows the
|
||||||
|
Images roles and resolution for every parity PNG` compares the rail with Images for all
|
||||||
|
25 committed parity PNGs. The static route change and the resolver fixes (Python) need a
|
||||||
|
ComfyUI restart.
|
||||||
|
|
||||||
|
Main thread (M4): the resolver runs on the page's thread, not a worker. On a desktop
|
||||||
|
(Node 26) `resolveLocalEvidence` on maximal 2 MiB graphs (a 19,990-sampler chain, 6,600
|
||||||
|
independent samplers, a consumer-dense scan, a 100,000-node PromptManager workflow) took
|
||||||
|
130–455 ms; the server took 80–905 ms on the same inputs. `localFileAdapter.read` runs in
|
||||||
|
bounded steps (read, table, parse and resolve, project, fit) with a turn of the event
|
||||||
|
loop between them, every wait ends with the selection's signal (a stalled table request
|
||||||
|
included), and the inspector carries `aria-busy="true"` with "Loading file details…"
|
||||||
|
until the answer lands. `tests/js/workspace-local-inspection.test.js` pins a 2,500 ms
|
||||||
|
budget per step on maximal independent-sampler and stage-chain graphs, checks that the
|
||||||
|
stepped read answers byte for byte as one synchronous build, and that an abort at any
|
||||||
|
step ends the read.
|
||||||
|
|
||||||
Bounds (the server's own budgets, ported by PR L1; see "Follow-up: local Inspect parity"
|
Bounds (the server's own budgets, ported by PR L1; see "Follow-up: local Inspect parity"
|
||||||
under "Chronological Library history" for the known divergences):
|
under "Chronological Library history" for the known divergences):
|
||||||
@@ -1600,7 +1650,8 @@ under "Chronological Library history" for the known divergences):
|
|||||||
|
|
||||||
Privacy, asserted by `local inspection makes no content request or storage write` and
|
Privacy, asserted by `local inspection makes no content request or storage write` and
|
||||||
`local roles send exactly` in `tests/browser/workspace-inspect.spec.mjs`, and by the
|
`local roles send exactly` in `tests/browser/workspace-inspect.spec.mjs`, and by the
|
||||||
managed-host spec below: no request carries the file's bytes, name or text (fetch, XHR,
|
managed-host spec below: the only static file Inspect reads is its node table, once per
|
||||||
|
page and before any selection; no request carries the file's bytes, name or text (fetch, XHR,
|
||||||
beacon, WebSocket, EventSource); Inspect writes nothing to `localStorage`,
|
beacon, WebSocket, EventSource); Inspect writes nothing to `localStorage`,
|
||||||
`sessionStorage` or IndexedDB; only an explicit Send crosses the host bridge, and its
|
`sessionStorage` or IndexedDB; only an explicit Send crosses the host bridge, and its
|
||||||
payload carries neither the `blob:` URL nor the filename; no console message names the
|
payload carries neither the `blob:` URL nor the filename; no console message names the
|
||||||
@@ -1627,19 +1678,27 @@ mapped differences"):
|
|||||||
`image/png,image/jpeg,image/webp` plus `video/*,.mp4,.mov,.m4v,.webm,.mkv,.avi,.wmv`. A
|
`image/png,image/jpeg,image/webp` plus `video/*,.mp4,.mov,.m4v,.webm,.mkv,.avi,.wmv`. A
|
||||||
video previews in the same `#local-preview` slot as `video.local-file-preview` with
|
video previews in the same `#local-preview` slot as `video.local-file-preview` with
|
||||||
native `controls`, `playsInline`, `preload="metadata"` and `muted`, never autoplays
|
native `controls`, `playsInline`, `preload="metadata"` and `muted`, never autoplays
|
||||||
inline, and is paused, has its `src` removed and is removed from the page on replacement
|
inline. On replacement or dispose it is paused, has its `src` removed, is reset with
|
||||||
or dispose, before its URL is revoked. Each preview owns its listeners, so the page never
|
`load()` (which frees its decoder) and is removed from the page, before its URL is
|
||||||
pins a released node. A settled video (metadata read, or failed) is then reset with
|
revoked. Each preview owns its listeners, so the page never pins a released node.
|
||||||
`load()` at once; one still starting is reset only once it reports metadata or an error,
|
A video replaced while still starting (a source, but neither metadata nor an error yet)
|
||||||
because WebKit (GStreamer) blocked the page when such a pipeline was reset (`replacing a
|
stays where it is, inert and `aria-hidden`, with the new preview inserted after it, and
|
||||||
video that just started loading never blocks the page`, `a video replaced while still
|
the session's revoke of its URL waits; once it reports metadata or an error (or after
|
||||||
starting is reset once it settles`). A WebKit load cut off by the revoked URL usually
|
15 s) it is released as above and only then is its URL revoked. Page leave and dispose
|
||||||
stalls and never settles; a fallback then only drops the waiting listeners. The viewer's
|
release it at once. Cause, found with native stacks of the hung WPE web process in the
|
||||||
own video teardown was checked the same way (`closing the viewer on a video that just
|
pinned image: WebKit (GStreamer) deadlocks when a video leaves the page or the viewport
|
||||||
started never blocks the page`, 15 fresh pages on WebKit) and needed no change.
|
before its pipeline has started. The main thread changes the pipeline state and waits
|
||||||
Follow-up: on WebKit, a video released exactly at its `loadstart` sometimes blocks the
|
for the source's streaming thread, which is itself waiting on the main thread in a
|
||||||
page, with or without any reset and also before the deferred reset (2 of 24 runs in the
|
synchronous bus handler. Removing the video, hiding it, or only pushing it below the
|
||||||
pinned image), so the `loadstart` spec is skipped on WebKit until that is investigated.
|
fold with a taller preview inserted before it all hung (7 to 21 of 120 fresh pages each,
|
||||||
|
replaced at `loadstart` + 0–7 ms), and none of the earlier steps (`pause()`, removing
|
||||||
|
`src`, `load()`, revoking the URL) was the cause on its own; pushing it out after
|
||||||
|
`loadedmetadata` hung 0 of 180. Specs: `replacing a video that just started loading
|
||||||
|
never blocks the page`, and `a video replaced while still starting is reset once it
|
||||||
|
settles`, which now runs on WebKit too (15 fresh pages, replaced at `loadstart` + 0–4
|
||||||
|
ms). The viewer's own video teardown was checked the same way (`closing the viewer on a
|
||||||
|
video that just started never blocks the page`, 15 fresh pages on WebKit) and needed no
|
||||||
|
change.
|
||||||
- **D63** (status line): one `p.small.spaced-16` in `#local-preview`, for states the
|
- **D63** (status line): one `p.small.spaced-16` in `#local-preview`, for states the
|
||||||
concept never shows: "Drop one file at a time.", "Folders cannot be inspected. Choose
|
concept never shows: "Drop one file at a time.", "Folders cannot be inspected. Choose
|
||||||
one image or video file.", "This file type cannot be previewed or inspected here.
|
one image or video file.", "This file type cannot be previewed or inspected here.
|
||||||
@@ -1664,6 +1723,29 @@ mapped differences"):
|
|||||||
Modified with no Path row and no Show in folder, and ends with "Local file; no saved
|
Modified with no Path row and no Show in folder, and ends with "Local file; no saved
|
||||||
library association was checked." and Download original. There is no Prompt history
|
library association was checked." and Download original. There is no Prompt history
|
||||||
action and no category or tag chips.
|
action and no category or tag chips.
|
||||||
|
- **D117** (Copy settings summary, LI-4): the legacy metadata
|
||||||
|
page's COPY ALL, consolidated into the shared full inspector (the viewer's
|
||||||
|
`#viewer-details`, so Images, the recorded history's current file and Inspect all have
|
||||||
|
it). The concept has no such action. One plain button "Copy settings summary" follows
|
||||||
|
the six facts (before "File & provenance"); the Images side panel keeps the concept's
|
||||||
|
footer and has none. Its text is the legacy `copyAllData` template, line for line:
|
||||||
|
`Checkpoint`, `Positive Prompt`, `Negative Prompt`, `Steps`, `CFG Scale`, `Sampler`,
|
||||||
|
`Seed`, with the legacy placeholders ("Unknown", "No prompt found", "No negative prompt
|
||||||
|
found"). The values are the ones the inspector shows: a confirmed prompt and a known
|
||||||
|
fact verbatim, anything else (an explicitly empty prompt included, as the legacy parse
|
||||||
|
treats '') the placeholder. Against the legacy page, for the same file, only these
|
||||||
|
differ: numbers keep the server's spelling ("8.0" where the legacy page wrote 8); a fact
|
||||||
|
the legacy parse could not read can be known; and a negative the #249 ZeroOut guard
|
||||||
|
zeroed reads "No negative prompt found" where the legacy parse read the positive's text
|
||||||
|
through the zero-out. `workspace-file-inspector.test.js` runs the template taken from
|
||||||
|
`web/metadata.html` itself on the legacy parse of every parity PNG: 22 of 25 are
|
||||||
|
byte-equal, f01b and f23 differ by the ZeroOut guard and f17 by facts the legacy parse
|
||||||
|
missed. Copying works as the concept's prompt copy: the toast "Settings summary
|
||||||
|
copied.", or, without a clipboard or when it is refused, the select-to-copy dialog
|
||||||
|
titled "Copy settings summary". `.ca-tools #viewer-details > div > button` wraps
|
||||||
|
(`white-space: normal`) in the narrow rail at 200% zoom, as D20's buttons do. The concept
|
||||||
|
comparisons add the button to the concept's viewer at runtime (a copy of its own plain
|
||||||
|
button) and to the Inspect rail's section order.
|
||||||
|
|
||||||
The chooser keeps naming the chosen file, as the concept's does: it is cleared as the
|
The chooser keeps naming the chosen file, as the concept's does: it is cleared as the
|
||||||
picker opens (so choosing the same file again is a new selection), not after `change`,
|
picker opens (so choosing the same file again is a new selection), not after `change`,
|
||||||
@@ -1677,9 +1759,8 @@ Legacy floor (`web/metadata.html`), dispositions:
|
|||||||
- Full text (the old "Show more"): the prompt blocks show the whole text.
|
- Full text (the old "Show more"): the prompt blocks show the whole text.
|
||||||
- Raw workflow view and Download workflow JSON: kept, in "Workflow & raw metadata".
|
- Raw workflow view and Download workflow JSON: kept, in "Workflow & raw metadata".
|
||||||
- Copy all summary and filename copy (LI-4): the filename is selectable text in the rail
|
- Copy all summary and filename copy (LI-4): the filename is selectable text in the rail
|
||||||
and Download original saves the file under its name. The ruled "Copy settings summary"
|
and Download original saves the file under its name. COPY ALL is the shared full
|
||||||
button after the six facts of the shared full inspector is not part of PR L2; until it
|
inspector's "Copy settings summary" (D117), with the legacy text.
|
||||||
lands, the legacy page keeps COPY ALL and stays reachable from Overview.
|
|
||||||
- Fully browser-local reading: kept and asserted (privacy above).
|
- Fully browser-local reading: kept and asserted (privacy above).
|
||||||
- Case-variant keys `Workflow|comfy|ComfyUI|Prompt|Parameters` (LI-5): a separate
|
- Case-variant keys `Workflow|comfy|ComfyUI|Prompt|Parameters` (LI-5): a separate
|
||||||
backend issue; Inspect follows the server's readers, so the two never disagree.
|
backend issue; Inspect follows the server's readers, so the two never disagree.
|
||||||
@@ -2272,12 +2353,41 @@ skip without ffmpeg). The remaining known divergences, checked against ffprobe 9
|
|||||||
`unreadable`); ffprobe reads such files.
|
`unreadable`); ffprobe reads such files.
|
||||||
- An EBML DocType other than `matroska`/`webm` is an invalid container locally; ffmpeg
|
- An EBML DocType other than `matroska`/`webm` is an invalid container locally; ffmpeg
|
||||||
only warns and reads it.
|
only warns and reads it.
|
||||||
- Envelope JSON deeper than 1,000 levels is refused locally as Python's default
|
- Deep JSON (local roles, PR L3, and video envelopes): both browser CPython-JSON
|
||||||
recursion limit refuses it; the server's real limit depends on the interpreter and
|
parsers (`py-json.mjs`, `local-video.mjs`) share one cap, `JSON_MAX_DEPTH` = 1,000
|
||||||
call stack. A duplicate key in an object enclosing a nest deeper than the running
|
nested arrays or objects (each counts, an empty one too); past it a record is invalid
|
||||||
|
JSON locally. The server's `json.loads` raises RecursionError at a limit that depends
|
||||||
|
on the Python, measured on Linux x86-64: 993 levels from a bare script on 3.10 and
|
||||||
|
3.11 (the 1,000 recursion limit minus the caller's frames, so about 990 inside the
|
||||||
|
server), 9,997 on 3.12 and 9,998 on 3.13 (the C recursion counter; lower on Windows
|
||||||
|
builds), and about 52,150 on 3.14 (stack-based, varies with the thread's stack).
|
||||||
|
The record kinds answer: a deep `prompt` graph (or `comfyassets_prompt_pairs`
|
||||||
|
envelope) is malformed; a deep `workflow` is ignored, so the overwritten PromptManager
|
||||||
|
check (R1) does not run and a prompt can resolve; a deep video `comment` or
|
||||||
|
`description` envelope makes the file unreadable on both sides (the server's
|
||||||
|
RecursionError reaches `read_inspection_file`). That gives two directions:
|
||||||
|
- 3.10/3.11, between the server's limit and 1,000: the server refuses and the local
|
||||||
|
page reads, so a deep prompt resolves locally while the server says malformed, and
|
||||||
|
a deep workflow can make R1 refuse locally where the server resolves.
|
||||||
|
- 3.12 and newer, from 1,001 up to the server's limit: the local page refuses and
|
||||||
|
the server reads, so a deep prompt is malformed locally while the server resolves,
|
||||||
|
and a deep workflow is ignored locally while the server can refuse it under R1.
|
||||||
|
Past both limits they agree. `test_local_json_depth_cap_at_its_boundary` checks the
|
||||||
|
local answer at 999, 1,000 and 1,001 for both kinds and that any server difference
|
||||||
|
is only one of the two directions above.
|
||||||
|
- Local roles (PR L3): `\d` in the A1111 settings footer is any Unicode decimal
|
||||||
|
digit on both sides, but the browser's Unicode version can be newer than the server
|
||||||
|
Python's, so a footer whose step count uses a digit added after Unicode 15 can differ.
|
||||||
|
`str.lower()` and `toLowerCase()` differ only for characters no node class name uses.
|
||||||
|
- A duplicate key in an object enclosing a nest deeper than the running
|
||||||
Python's json limit is `malformed` locally and `limited` on that Python.
|
Python's json limit is `malformed` locally and `limited` on that Python.
|
||||||
- Decimal exponent limits assume CPython's C `_decimal`; on a `_pydecimal` interpreter
|
- Decimal exponent limits assume CPython's C `_decimal`; on a `_pydecimal` interpreter
|
||||||
the server would read the over-limit exponents the local projection refuses.
|
the server would read the over-limit exponents the local projection refuses.
|
||||||
|
- Follow-up (two CPython-JSON emulations, review M6): `local-video.mjs`
|
||||||
|
(`pythonJsonLoads`/`pythonFloatRepr`, numbers kept as lexemes) and `py-json.mjs`
|
||||||
|
(`pyJsonLoads`/`pyFloatRepr`, Python value kinds) both emulate `json.loads`; only the
|
||||||
|
depth cap is shared so far. A fix to one parser can miss the other, so the video
|
||||||
|
reader should move onto `py-json.mjs` in a later PR.
|
||||||
|
|
||||||
Preserve raw logs, failed traces, paired PNGs, geometry, runtime versions and
|
Preserve raw logs, failed traces, paired PNGs, geometry, runtime versions and
|
||||||
source hashes before later managed commands clear their output directories.
|
source hashes before later managed commands clear their output directories.
|
||||||
|
|||||||
+15
-6
@@ -108,16 +108,20 @@ STATIC_MIME_TYPES = {
|
|||||||
".html": "text/html",
|
".html": "text/html",
|
||||||
}
|
}
|
||||||
_STATIC_SEPARATORS = re.compile(r"[\\/]+")
|
_STATIC_SEPARATORS = re.compile(r"[\\/]+")
|
||||||
|
# Served by exact request path beside the extension allow-list: the browser's
|
||||||
|
# copy of the inspection node table (local Inspect resolves with it, #249).
|
||||||
|
STATIC_EXACT_FILES = {("js", "inspection-semantics.json"): "application/json"}
|
||||||
|
|
||||||
|
|
||||||
def _resolve_static_file(root, filepath):
|
def _resolve_static_file(root, filepath, allowed=STATIC_MIME_TYPES):
|
||||||
"""Real path of ``filepath`` under ``root``, or None when it must not be served.
|
"""Real path of ``filepath`` under ``root``, or None when it must not be served.
|
||||||
|
|
||||||
The request path is untrusted. It is rejected when it is empty, carries a
|
The request path is untrusted. It is rejected when it is empty, carries a
|
||||||
``..`` segment, is absolute on any platform (a leading slash or backslash,
|
``..`` segment, is absolute on any platform (a leading slash or backslash,
|
||||||
a drive letter such as ``C:`` or a UNC ``\\\\server\\share`` prefix all
|
a drive letter such as ``C:`` or a UNC ``\\\\server\\share`` prefix all
|
||||||
re-anchor ``os.path.join`` on Windows), has an extension outside
|
re-anchor ``os.path.join`` on Windows), has an extension outside
|
||||||
STATIC_MIME_TYPES, or resolves (symlinks included) outside ``root``.
|
``allowed`` (STATIC_MIME_TYPES), or resolves (symlinks included) outside
|
||||||
|
``root``.
|
||||||
"""
|
"""
|
||||||
if not isinstance(filepath, str) or not filepath:
|
if not isinstance(filepath, str) or not filepath:
|
||||||
return None
|
return None
|
||||||
@@ -130,7 +134,7 @@ def _resolve_static_file(root, filepath):
|
|||||||
segments = _STATIC_SEPARATORS.split(filepath)
|
segments = _STATIC_SEPARATORS.split(filepath)
|
||||||
if any(segment in ("", ".", "..") for segment in segments):
|
if any(segment in ("", ".", "..") for segment in segments):
|
||||||
return None
|
return None
|
||||||
if os.path.splitext(filepath)[1].lower() not in STATIC_MIME_TYPES:
|
if os.path.splitext(filepath)[1].lower() not in allowed:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
root_real = Path(os.path.normcase(os.path.realpath(root)))
|
root_real = Path(os.path.normcase(os.path.realpath(root)))
|
||||||
@@ -499,15 +503,20 @@ class PromptManagerAPI(
|
|||||||
def _serve_static_file(self, subdir, filepath):
|
def _serve_static_file(self, subdir, filepath):
|
||||||
"""Serve ``web/<subdir>/<filepath>``; 403 outside it, 404 when missing."""
|
"""Serve ``web/<subdir>/<filepath>``; 403 outside it, 404 when missing."""
|
||||||
root = os.path.join(_get_project_root(), "web", subdir)
|
root = os.path.join(_get_project_root(), "web", subdir)
|
||||||
file_path = _resolve_static_file(root, filepath)
|
exact = STATIC_EXACT_FILES.get((subdir, filepath))
|
||||||
|
# An exact file passes the same containment check, its extension only.
|
||||||
|
allowed = STATIC_MIME_TYPES if exact is None else {".json"}
|
||||||
|
file_path = _resolve_static_file(root, filepath, allowed)
|
||||||
if file_path is None:
|
if file_path is None:
|
||||||
return web.Response(text="Forbidden", status=403)
|
return web.Response(text="Forbidden", status=403)
|
||||||
|
content_type = (
|
||||||
|
exact or STATIC_MIME_TYPES[os.path.splitext(file_path)[1].lower()]
|
||||||
|
)
|
||||||
if not os.path.isfile(file_path):
|
if not os.path.isfile(file_path):
|
||||||
return web.Response(text="Not Found", status=404)
|
return web.Response(text="Not Found", status=404)
|
||||||
ext = os.path.splitext(file_path)[1].lower()
|
|
||||||
with open(file_path, "rb") as f:
|
with open(file_path, "rb") as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
return web.Response(body=content, content_type=STATIC_MIME_TYPES[ext])
|
return web.Response(body=content, content_type=content_type)
|
||||||
|
|
||||||
def _serve_html_page(self, filename, title, cache=True):
|
def _serve_html_page(self, filename, title, cache=True):
|
||||||
"""Serve ``web/<filename>``; 404 when missing, 500 when unreadable."""
|
"""Serve ``web/<filename>``; 404 when missing, 500 when unreadable."""
|
||||||
|
|||||||
@@ -78,6 +78,17 @@ export async function seedConcept(request,data={}) {
|
|||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed the concept fixture and fix the browser's clock at the fixture's own instant (`manifest.now`), so the page and
|
||||||
|
* the fixture read one "now": a prompt used now reads "Used today" and the caption names the fixture's day even when
|
||||||
|
* the run crosses midnight between seeding and rendering. Timers keep running; only `Date` stands still.
|
||||||
|
*/
|
||||||
|
export async function seedConceptAt(page,request,data={}) {
|
||||||
|
const manifest=await seedConcept(request,data);
|
||||||
|
await page.clock.setFixedTime(new Date(manifest.now*1000));
|
||||||
|
return manifest;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save `tags` as the current image tag set of the listed file at `path` (root 0) through the real observe and tag
|
* Save `tags` as the current image tag set of the listed file at `path` (root 0) through the real observe and tag
|
||||||
* routes (#249 PR C), releasing the capture afterwards; returns the receipt's saved names.
|
* routes (#249 PR C), releasing the capture afterwards; returns the receipt's saved names.
|
||||||
|
|||||||
@@ -237,9 +237,12 @@ const viewerStructure=details=>details.evaluate(node=>{
|
|||||||
test('viewer details hold provenance, raw records and downloads',async({page,request})=>{
|
test('viewer details hold provenance, raw records and downloads',async({page,request})=>{
|
||||||
await page.setViewportSize({width:1600,height:1100});const reads=await patchInspection(page);
|
await page.setViewportSize({width:1600,height:1100});const reads=await patchInspection(page);
|
||||||
await fullFixture(page,request);let details=await viewerDetails(page,'complete.png');
|
await fullFixture(page,request);let details=await viewerDetails(page,'complete.png');
|
||||||
// The panel content without the cover, then the two disclosures, then the pair send and history buttons (D20).
|
// The panel content without the cover, D117's Copy settings summary after the facts, then the two disclosures,
|
||||||
|
// then the pair send and history buttons (D20).
|
||||||
expect(await viewerStructure(details)).toEqual({outer:['div','button.send','button'],
|
expect(await viewerStructure(details)).toEqual({outer:['div','button.send','button'],
|
||||||
inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','div.tags','dl.meta-grid','details','details']});
|
inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','div.tags','dl.meta-grid','button.settings-summary','details','details']});
|
||||||
|
await expect(details.locator(':scope > div > button')).toHaveText('Copy settings summary');
|
||||||
|
await expect(details.locator(':scope > div > button.settings-summary')).toHaveCSS('white-space','normal');
|
||||||
await expect(details.locator('.detail-cover,.detail-inner,.detail-footer')).toHaveCount(0);
|
await expect(details.locator('.detail-cover,.detail-inner,.detail-footer')).toHaveCount(0);
|
||||||
await expect(details.locator('.panel-heading h2')).toHaveText(POSITIVE_TITLE);await expect(details.locator('.panel-heading .chip')).toHaveText('PNG');
|
await expect(details.locator('.panel-heading h2')).toHaveText(POSITIVE_TITLE);await expect(details.locator('.panel-heading .chip')).toHaveText('PNG');
|
||||||
await expect(details.locator(':scope > div > p.small')).toHaveText('complete.png');
|
await expect(details.locator(':scope > div > p.small')).toHaveText('complete.png');
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {writeFile} from 'node:fs/promises';
|
import {writeFile} from 'node:fs/promises';
|
||||||
import {test,expect} from './fixtures.mjs';
|
import {test,expect} from './fixtures.mjs';
|
||||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||||
import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact,countCopy,reflow,withoutItems} from './concept-inventory.mjs';
|
import {SIZES,THEMES,MOTIONS,seedConceptAt,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact,countCopy,reflow,withoutItems} from './concept-inventory.mjs';
|
||||||
|
|
||||||
// Task C7 of #249: the production Images page against the immutable concept, on the concept-shaped real fixture (24
|
// Task C7 of #249: the production Images page against the immutable concept, on the concept-shaped real fixture (24
|
||||||
// images of the concept's six prompts, its seeds, sizes and favorites). Heading, toolbar, filter row, caption, grid,
|
// images of the concept's six prompts, its seeds, sizes and favorites). Heading, toolbar, filter row, caption, grid,
|
||||||
@@ -85,7 +85,7 @@ function listInfo(card,actionsWidth) {
|
|||||||
/** Recorded Images deviations only (spec §8); everything else in the concept inventory must equal production. */
|
/** Recorded Images deviations only (spec §8); everything else in the concept inventory must equal production. */
|
||||||
const nocase=(a,b)=>{const x=a.toLowerCase(),y=b.toLowerCase();return x<y?-1:x>y?1:a<b?-1:a>b?1:0;};
|
const nocase=(a,b)=>{const x=a.toLowerCase(),y=b.toLowerCase();return x<y?-1:x>y?1:a<b?-1:a>b?1:0;};
|
||||||
|
|
||||||
export function mapImagesConcept(reference,{manifest,now=new Date()}) {
|
export function mapImagesConcept(reference,{manifest,now=new Date(manifest.now*1000)}) {
|
||||||
const paths=new Map(manifest.images.map(image=>[image.filename,image.path]));
|
const paths=new Map(manifest.images.map(image=>[image.filename,image.path]));
|
||||||
// D14: the sort appends production's two sorts. Save view is the concept's own (saved views, #249 D16 retired).
|
// D14: the sort appends production's two sorts. Save view is the concept's own (saved views, #249 D16 retired).
|
||||||
const toolbar={...reference.toolbar};
|
const toolbar={...reference.toolbar};
|
||||||
@@ -318,7 +318,7 @@ async function behaviorsOf(page,scope) {
|
|||||||
return {hover,opened,viewerTitle,focusAfterViewer,selected,layouts,search,single};
|
return {hover,opened,viewerTitle,focusAfterViewer,selected,layouts,search,single};
|
||||||
}
|
}
|
||||||
|
|
||||||
test.beforeEach(async({request},testInfo)=>{testInfo.manifest=await seedConcept(request);});
|
test.beforeEach(async({page,request},testInfo)=>{testInfo.manifest=await seedConceptAt(page,request);});
|
||||||
|
|
||||||
for(const [width,height] of SIZES)for(const theme of THEMES)test(`images page geometry, tokens, controls and behaviors match the concept ${width}x${height} ${theme}`,async({page,request},testInfo)=>{
|
for(const [width,height] of SIZES)for(const theme of THEMES)test(`images page geometry, tokens, controls and behaviors match the concept ${width}x${height} ${theme}`,async({page,request},testInfo)=>{
|
||||||
test.setTimeout(240_000);
|
test.setTimeout(240_000);
|
||||||
@@ -348,7 +348,7 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`images page ge
|
|||||||
importDiff:diffInventory(mapImportConcept(referenceImport),actualImport),referenceBehaviors,actualBehaviors};
|
importDiff:diffInventory(mapImportConcept(referenceImport),actualImport),referenceBehaviors,actualBehaviors};
|
||||||
}
|
}
|
||||||
// Load more in production: a fixture past one listing page appends one batch and shows the concept's toast.
|
// Load more in production: a fixture past one listing page appends one batch and shows the concept's toast.
|
||||||
testInfo.manifest=await seedConcept(request,{extra_images:LOAD_MORE_EXTRA});
|
testInfo.manifest=await seedConceptAt(page,request,{extra_images:LOAD_MORE_EXTRA});
|
||||||
for(const motion of MOTIONS) {
|
for(const motion of MOTIONS) {
|
||||||
const total=FIXTURE_IMAGES+LOAD_MORE_EXTRA;
|
const total=FIXTURE_IMAGES+LOAD_MORE_EXTRA;
|
||||||
await openProductionLoaded(page,theme,motion,total);
|
await openProductionLoaded(page,theme,motion,total);
|
||||||
@@ -514,7 +514,7 @@ test('one appended image reads in the singular',async({page,request})=>{
|
|||||||
test.setTimeout(120_000);
|
test.setTimeout(120_000);
|
||||||
await page.setViewportSize({width:1600,height:1100});
|
await page.setViewportSize({width:1600,height:1100});
|
||||||
const total=PRODUCTION_BATCH+1;
|
const total=PRODUCTION_BATCH+1;
|
||||||
await seedConcept(request,{extra_images:total-FIXTURE_IMAGES});
|
await seedConceptAt(page,request,{extra_images:total-FIXTURE_IMAGES});
|
||||||
await openProductionLoaded(page,'dark','no-preference',total);
|
await openProductionLoaded(page,'dark','no-preference',total);
|
||||||
await expect(page.locator('.infinite-continuation > p').first()).toHaveText(countCopy(`${PRODUCTION_BATCH} of ${total} images · more load as you scroll`));
|
await expect(page.locator('.infinite-continuation > p').first()).toHaveText(countCopy(`${PRODUCTION_BATCH} of ${total} images · more load as you scroll`));
|
||||||
// A dispatched press: scrolling the button into view lets the page's own observer load the one image first.
|
// A dispatched press: scrolling the button into view lets the page's own observer load the one image first.
|
||||||
@@ -530,7 +530,7 @@ test('one image appended by scrolling reads in the singular',async({page,request
|
|||||||
test.setTimeout(120_000);
|
test.setTimeout(120_000);
|
||||||
await page.setViewportSize({width:1600,height:1100});
|
await page.setViewportSize({width:1600,height:1100});
|
||||||
const total=PRODUCTION_BATCH+1;
|
const total=PRODUCTION_BATCH+1;
|
||||||
await seedConcept(request,{extra_images:total-FIXTURE_IMAGES});
|
await seedConceptAt(page,request,{extra_images:total-FIXTURE_IMAGES});
|
||||||
await openProductionLoaded(page,'dark','no-preference',total);
|
await openProductionLoaded(page,'dark','no-preference',total);
|
||||||
await expect(page.locator('#result-count')).toHaveText(new RegExp(`^${PRODUCTION_BATCH} of ${total} images`));
|
await expect(page.locator('#result-count')).toHaveText(new RegExp(`^${PRODUCTION_BATCH} of ${total} images`));
|
||||||
await page.locator('.infinite-continuation').scrollIntoViewIfNeeded();
|
await page.locator('.infinite-continuation').scrollIntoViewIfNeeded();
|
||||||
@@ -551,7 +551,7 @@ test('one footer height for the collection (D3)',async({page,request})=>{
|
|||||||
const reference=await inventoryOf(page,'#main');
|
const reference=await inventoryOf(page,'#main');
|
||||||
expect(reference.grid.columns).toBe(2);
|
expect(reference.grid.columns).toBe(2);
|
||||||
expect(reference.cards.every(card=>card.info.p.height>parseFloat(card.info.p.lineHeight))).toBe(true);
|
expect(reference.cards.every(card=>card.info.p.height>parseFloat(card.info.p.lineHeight))).toBe(true);
|
||||||
const manifest=await seedConcept(request,{seedless_images:2,short_seedless:true});
|
const manifest=await seedConceptAt(page,request,{seedless_images:2,short_seedless:true});
|
||||||
await openProductionLoaded(page,'dark','no-preference',FIXTURE_IMAGES+2);
|
await openProductionLoaded(page,'dark','no-preference',FIXTURE_IMAGES+2);
|
||||||
const actual=await inventoryOf(page,'#workspace-main');
|
const actual=await inventoryOf(page,'#workspace-main');
|
||||||
const box=card=>({left:card.left,top:card.top,width:card.width,height:card.height});
|
const box=card=>({left:card.left,top:card.top,width:card.width,height:card.height});
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ function measureViewer() {
|
|||||||
['dl.meta-grid', node => node.closest('details') ? null : 'facts'],
|
['dl.meta-grid', node => node.closest('details') ? null : 'facts'],
|
||||||
['details > summary', node => text(node)],
|
['details > summary', node => text(node)],
|
||||||
['button.send', node => node.closest('.prompt-section') ? `send ${node.closest('.prompt-section').matches('.positive') ? 'positive' : 'negative'}` : 'send pair'],
|
['button.send', node => node.closest('.prompt-section') ? `send ${node.closest('.prompt-section').matches('.positive') ? 'positive' : 'negative'}` : 'send pair'],
|
||||||
['button', node => /Prompt history/.test(node.textContent) ? 'Prompt history' : null],
|
['button', node => /Prompt history/.test(node.textContent) ? 'Prompt history' : text(node) === 'Copy settings summary' ? 'settings summary' : null],
|
||||||
];
|
];
|
||||||
const sections = [...rail.querySelectorAll('*')].map(node => {
|
const sections = [...rail.querySelectorAll('*')].map(node => {
|
||||||
for (const [selector, label] of SECTIONS) if (node.matches(selector)) return label(node);
|
for (const [selector, label] of SECTIONS) if (node.matches(selector)) return label(node);
|
||||||
@@ -158,13 +158,18 @@ async function withProductionCopy(page) {
|
|||||||
}, { COPY, ACCEPT });
|
}, { COPY, ACCEPT });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** D66: the viewer of a local file is titled by its filename and has no tag chips and no Prompt history. */
|
/**
|
||||||
|
* D66: the viewer of a local file is titled by its filename and has no tag chips and no Prompt history. D117 (LI-4):
|
||||||
|
* "Copy settings summary" follows the six facts.
|
||||||
|
*/
|
||||||
function mapViewerConcept(reference) {
|
function mapViewerConcept(reference) {
|
||||||
|
const sections = reference.sections.filter(section => section !== 'tag chips' && section !== 'Prompt history');
|
||||||
|
const facts = sections.indexOf('facts');
|
||||||
return {
|
return {
|
||||||
...reference,
|
...reference,
|
||||||
title: FILE,
|
title: FILE,
|
||||||
railTitle: FILE,
|
railTitle: FILE,
|
||||||
sections: reference.sections.filter(section => section !== 'tag chips' && section !== 'Prompt history'),
|
sections: [...sections.slice(0, facts + 1), 'settings summary', ...sections.slice(facts + 1)],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,9 +274,9 @@ for (const [width, height] of SIZES) for (const theme of THEMES) {
|
|||||||
// The chooser names the chosen file after a real setInputFiles, as the concept's does.
|
// The chooser names the chosen file after a real setInputFiles, as the concept's does.
|
||||||
expect(production.initial.chosen).toEqual({ name: '', files: [] });
|
expect(production.initial.chosen).toEqual({ name: '', files: [] });
|
||||||
expect(production.selected.chosen).toEqual({ name: FILE, files: [FILE] });
|
expect(production.selected.chosen).toEqual({ name: FILE, files: [FILE] });
|
||||||
// D66 in the rail: no tag chips and no Prompt history; the title is the filename.
|
// D66 in the rail: no tag chips and no Prompt history; the title is the filename. D117 follows the facts.
|
||||||
expect(production.viewer.sections).toEqual(['heading', 'filename', 'badges', 'positive', 'send positive', 'negative',
|
expect(production.viewer.sections).toEqual(['heading', 'filename', 'badges', 'positive', 'send positive', 'negative',
|
||||||
'send negative', 'facts', 'File & provenance', 'Workflow & raw metadata', 'send pair']);
|
'send negative', 'facts', 'settings summary', 'File & provenance', 'Workflow & raw metadata', 'send pair']);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,10 +45,10 @@ function withCategoryScope(inventory) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34 and D38); everything else must equal
|
* Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34, D38 and D117); everything else must
|
||||||
* production. `reference.viewerPlain` is the concept's own full details
|
* equal production. `reference.viewerPlain` is the concept's own full details
|
||||||
* with its tag chips made plain (D20) and, under a coarse pointer, D34's targets, both at runtime: the browser lays out
|
* with its tag chips made plain (D20), D117's summary button added and, under a coarse pointer, D34's targets, all at
|
||||||
* what follows them, as the Images spec's removed toolbar items do.
|
* runtime: the browser lays out what follows them, as the Images spec's removed toolbar items do.
|
||||||
*/
|
*/
|
||||||
export function mapInspectorConcept(reference,{viewport}) {
|
export function mapInspectorConcept(reference,{viewport}) {
|
||||||
const {panel,viewerPlain,fileControls}=reference;
|
const {panel,viewerPlain,fileControls}=reference;
|
||||||
@@ -105,7 +105,11 @@ async function openProductionPanel(page,theme) {
|
|||||||
await settle(page);
|
await settle(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The concept's tag chips in the open viewer as plain `span.chip`s (D20) and D34's coarse targets; returns an undo. */
|
/**
|
||||||
|
* The concept's tag chips in the open viewer as plain `span.chip`s (D20), D34's coarse targets and D117's "Copy
|
||||||
|
* settings summary" after the six facts (LI-4), drawn as the concept's own plain button (its "Download sample JSON",
|
||||||
|
* without its action); returns an undo.
|
||||||
|
*/
|
||||||
async function plainViewerChips(page) {
|
async function plainViewerChips(page) {
|
||||||
const restoreTargets=await mutate(page,{css:D34_COARSE_TARGETS});
|
const restoreTargets=await mutate(page,{css:D34_COARSE_TARGETS});
|
||||||
await page.evaluate(()=>{
|
await page.evaluate(()=>{
|
||||||
@@ -113,10 +117,15 @@ async function plainViewerChips(page) {
|
|||||||
const span=document.createElement('span');span.className='chip';span.textContent=button.textContent;
|
const span=document.createElement('span');span.className='chip';span.textContent=button.textContent;
|
||||||
button.replaceWith(span);return [span,button];
|
button.replaceWith(span);return [span,button];
|
||||||
});
|
});
|
||||||
|
const summary=document.querySelector('#viewer-details [data-action="download-workflow"]').cloneNode(false);
|
||||||
|
for(const key of Object.keys(summary.dataset))delete summary.dataset[key];
|
||||||
|
summary.textContent='Copy settings summary';summary.classList.add('settings-summary');
|
||||||
|
document.querySelector('#viewer-details > div > dl.meta-grid').after(summary);window.__summaryButton=summary;
|
||||||
});
|
});
|
||||||
await settle(page);
|
await settle(page);
|
||||||
return async()=>{
|
return async()=>{
|
||||||
await page.evaluate(()=>{for(const [span,button] of window.__plainChips)span.replaceWith(button);delete window.__plainChips;});
|
await page.evaluate(()=>{for(const [span,button] of window.__plainChips)span.replaceWith(button);delete window.__plainChips;
|
||||||
|
window.__summaryButton.remove();delete window.__summaryButton;});
|
||||||
await restoreTargets();
|
await restoreTargets();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -342,7 +351,7 @@ for(const [width,height] of SIZES)test(`image tags saved on the concept image ke
|
|||||||
|
|
||||||
// The comparison is only worth something if it notices a change (spec §7 mutation check): on the panel one CSS
|
// The comparison is only worth something if it notices a change (spec §7 mutation check): on the panel one CSS
|
||||||
// mutation (the role label's letter spacing) and one DOM mutation (the footer removed); in the viewer (final review m5)
|
// mutation (the role label's letter spacing) and one DOM mutation (the footer removed); in the viewer (final review m5)
|
||||||
// its pair actions removed and D38's Show in folder removed; each applied after an exact match.
|
// its pair actions removed, D38's Show in folder removed and D117's summary removed; each applied after an exact match.
|
||||||
test('inspector comparison detects mutations',async({page})=>{
|
test('inspector comparison detects mutations',async({page})=>{
|
||||||
test.setTimeout(120_000);
|
test.setTimeout(120_000);
|
||||||
const viewport={width:1600,height:1100};
|
const viewport={width:1600,height:1100};
|
||||||
@@ -366,6 +375,9 @@ test('inspector comparison detects mutations',async({page})=>{
|
|||||||
expect(()=>assertInspectorMatch(reference,withoutActions,viewport)).toThrow(/viewerDetails\.actions/);
|
expect(()=>assertInspectorMatch(reference,withoutActions,viewport)).toThrow(/viewerDetails\.actions/);
|
||||||
const withoutReveal=await viewerMutated({remove:['#viewer-details button[aria-label$=" in folder"]']});
|
const withoutReveal=await viewerMutated({remove:['#viewer-details button[aria-label$=" in folder"]']});
|
||||||
expect(()=>assertInspectorMatch(reference,withoutReveal,viewport)).toThrow(/fileControls/);
|
expect(()=>assertInspectorMatch(reference,withoutReveal,viewport)).toThrow(/fileControls/);
|
||||||
|
// D117: the summary action after the facts is part of the mapped concept, so losing it is a difference.
|
||||||
|
const withoutSummary=await viewerMutated({remove:['#viewer-details > div > button']});
|
||||||
|
expect(()=>assertInspectorMatch(reference,withoutSummary,viewport)).toThrow(/viewerDetails\.structure\.inner/);
|
||||||
assertInspectorMatch(reference,await viewerMutated(),viewport);
|
assertInspectorMatch(reference,await viewerMutated(),viewport);
|
||||||
|
|
||||||
await mutate(page,{remove:['#image-inspector > .detail-footer']});
|
await mutate(page,{remove:['#image-inspector > .detail-footer']});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {writeFile} from 'node:fs/promises';
|
import {writeFile} from 'node:fs/promises';
|
||||||
import {test,expect} from './fixtures.mjs';
|
import {test,expect} from './fixtures.mjs';
|
||||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||||
import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact,countCopy,reflow} from './concept-inventory.mjs';
|
import {SIZES,THEMES,MOTIONS,seedConceptAt,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact,countCopy,reflow} from './concept-inventory.mjs';
|
||||||
|
|
||||||
// Task E3 of #249: the production Prompt library shelf against the immutable concept (app.js renderLibrary a572–586,
|
// Task E3 of #249: the production Prompt library shelf against the immutable concept (app.js renderLibrary a572–586,
|
||||||
// style.css s648–708), on the concept-shaped real fixture (the concept's six prompts with their negatives, categories,
|
// style.css s648–708), on the concept-shaped real fixture (the concept's six prompts with their negatives, categories,
|
||||||
@@ -37,7 +37,8 @@ export function mapLibraryConcept(reference) {
|
|||||||
// D43: tag chips come in the store's alphabetical order; the row flows again from the concept's own chips.
|
// D43: tag chips come in the store's alphabetical order; the row flows again from the concept's own chips.
|
||||||
tags:sortedTags(card.tags),
|
tags:sortedTags(card.tags),
|
||||||
// D23: the stars are aria-hidden, with a visually hidden "Rated N out of 5" spoken in their place. The fixture's
|
// D23: the stars are aria-hidden, with a visually hidden "Rated N out of 5" spoken in their place. The fixture's
|
||||||
// run_count (4) and last_used_at (now) give the concept's own "4 runs" and "Used today". The two-line clamp is
|
// run_count (4) and last_used_at (its own "now", where the page's clock stands) give the concept's own "4 runs"
|
||||||
|
// and "Used today". The two-line clamp is
|
||||||
// applied below through `clampedCards`, measured on the concept with `CLAMP_CSS` (its samples wrap at 390px).
|
// applied below through `clampedCards`, measured on the concept with `CLAMP_CSS` (its samples wrap at 390px).
|
||||||
footer:{...card.footer,facts:{...card.footer.facts,spoken:card.footer.facts.spoken.replace(/^(★+)/,stars=>`Rated ${stars.length} out of 5`)}}}));
|
footer:{...card.footer,facts:{...card.footer.facts,spoken:card.footer.facts.spoken.replace(/^(★+)/,stars=>`Rated ${stars.length} out of 5`)}}}));
|
||||||
return {...reference,library:{...library,toolbar,
|
return {...reference,library:{...library,toolbar,
|
||||||
@@ -238,7 +239,8 @@ async function openHost(context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test.beforeEach(async({page,request},testInfo)=>{
|
test.beforeEach(async({page,request},testInfo)=>{
|
||||||
testInfo.manifest=await seedConcept(request);
|
// The page's clock stands at the fixture's own "now", so its last use reads "Used today" on either side of midnight.
|
||||||
|
testInfo.manifest=await seedConceptAt(page,request);
|
||||||
// Both documents copy through the clipboard; the stub records what they wrote.
|
// Both documents copy through the clipboard; the stub records what they wrote.
|
||||||
await page.addInitScript(()=>{window.copied=[];Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>{window.copied.push(text);}}});});
|
await page.addInitScript(()=>{window.copied=[];Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>{window.copied.push(text);}}});});
|
||||||
});
|
});
|
||||||
@@ -322,6 +324,27 @@ for(const theme of THEMES)test(`library grid and cards match the concept 1800x11
|
|||||||
expect(compare(reference,actual,{sections:['library'],library:['grid','cards','trailing']})).toEqual([]);
|
expect(compare(reference,actual,{sections:['library'],library:['grid','cards','trailing']})).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// PR #284's CI run: the fixture recorded the last use at 23:59:5x UTC and the shelf rendered at 00:00:05, so production
|
||||||
|
// read "Used yesterday" against the concept's "Used today". Here the browser's wall clock starts at 23:59:58 UTC, the
|
||||||
|
// fixture records its use at that instant, and midnight passes before either document opens.
|
||||||
|
const BEFORE_MIDNIGHT=Date.UTC(2026,9,22,23,59,58);
|
||||||
|
const PAST_MIDNIGHT_MS=5_000;
|
||||||
|
test('a run that crosses midnight still reads the concept fixture\'s day',async({page,request})=>{
|
||||||
|
await page.setViewportSize({width:1800,height:1100});
|
||||||
|
await page.clock.install({time:BEFORE_MIDNIGHT});
|
||||||
|
const manifest=await seedConceptAt(page,request,{now:BEFORE_MIDNIGHT/1000});
|
||||||
|
expect(manifest.now*1000,'the fixture\'s now').toBe(BEFORE_MIDNIGHT);
|
||||||
|
const used=(await (await request.get('/prompt_manager/library')).json()).items.map(item=>Date.parse(item.last_used_at));
|
||||||
|
expect(used,'every last use at the fixture\'s now').toEqual(Array(CONCEPT_PROMPTS).fill(BEFORE_MIDNIGHT));
|
||||||
|
await page.clock.fastForward(PAST_MIDNIGHT_MS);
|
||||||
|
await openConceptLibrary(page,'dark','no-preference');
|
||||||
|
const reference=await conceptInventory(page);
|
||||||
|
await openProductionLibrary(page,'dark','no-preference');
|
||||||
|
const actual=await inventoryOf(page,'#workspace-main');
|
||||||
|
expect(actual.library.cards.map(card=>card.footer.facts.text.replace(/^★+ · /,''))).toEqual(Array(CONCEPT_PROMPTS).fill('Used today'));
|
||||||
|
expect(compare(reference,actual,{sections:['library'],library:['grid','cards','trailing']})).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
// The comparison is only worth something if it notices a change (spec §7 mutation check): one CSS mutation (the footer
|
// The comparison is only worth something if it notices a change (spec §7 mutation check): one CSS mutation (the footer
|
||||||
// padding) and one DOM mutation (the negative-variants chip removed), each applied after an exact match.
|
// padding) and one DOM mutation (the negative-variants chip removed), each applied after an exact match.
|
||||||
test('library comparison detects mutations',async({page})=>{
|
test('library comparison detects mutations',async({page})=>{
|
||||||
|
|||||||
+14
-1
@@ -19,10 +19,12 @@ from PIL import Image
|
|||||||
from PIL.PngImagePlugin import PngInfo
|
from PIL.PngImagePlugin import PngInfo
|
||||||
|
|
||||||
from tests.browser.request_hold import register_request_holds
|
from tests.browser.request_hold import register_request_holds
|
||||||
|
from tests.browser import slow_reset_watch
|
||||||
from tests.browser.workspace_activity_fixture import register_activity_commands
|
from tests.browser.workspace_activity_fixture import register_activity_commands
|
||||||
from tests.browser.workspace_concept_fixture import (
|
from tests.browser.workspace_concept_fixture import (
|
||||||
MAX_EXTRA_IMAGES as CONCEPT_MAX_EXTRA_IMAGES,
|
MAX_EXTRA_IMAGES as CONCEPT_MAX_EXTRA_IMAGES,
|
||||||
MAX_SEEDLESS_IMAGES as CONCEPT_MAX_SEEDLESS_IMAGES,
|
MAX_SEEDLESS_IMAGES as CONCEPT_MAX_SEEDLESS_IMAGES,
|
||||||
|
MAX_EPOCH_SECONDS as CONCEPT_MAX_EPOCH_SECONDS,
|
||||||
seed_workspace_concept,
|
seed_workspace_concept,
|
||||||
)
|
)
|
||||||
from tests.browser.workspace_visual_fixture import seed_workspace_visual
|
from tests.browser.workspace_visual_fixture import seed_workspace_visual
|
||||||
@@ -197,6 +199,7 @@ def create_app(root: Path) -> web.Application:
|
|||||||
reveal_stub = register_reveal_stub(routes, api, root)
|
reveal_stub = register_reveal_stub(routes, api, root)
|
||||||
register_activity_commands(app, routes, api)
|
register_activity_commands(app, routes, api)
|
||||||
request_holds = register_request_holds(app, routes)
|
request_holds = register_request_holds(app, routes)
|
||||||
|
slow_reset_watch.install(app, api)
|
||||||
|
|
||||||
async def canonical_asset(request: web.Request) -> web.Response:
|
async def canonical_asset(request: web.Request) -> web.Response:
|
||||||
filename, content_type = CANONICAL_ASSETS[request.path]
|
filename, content_type = CANONICAL_ASSETS[request.path]
|
||||||
@@ -489,8 +492,17 @@ def create_app(root: Path) -> web.Application:
|
|||||||
if (
|
if (
|
||||||
request.query
|
request.query
|
||||||
or not isinstance(data, dict)
|
or not isinstance(data, dict)
|
||||||
or set(data) - set(limits) - {"short_seedless"}
|
or set(data) - set(limits) - {"short_seedless", "now"}
|
||||||
or not isinstance(data.get("short_seedless", False), bool)
|
or not isinstance(data.get("short_seedless", False), bool)
|
||||||
|
# A chosen "now" (epoch seconds) pins every time the fixture writes.
|
||||||
|
or (
|
||||||
|
"now" in data
|
||||||
|
and (
|
||||||
|
isinstance(data["now"], bool)
|
||||||
|
or not isinstance(data["now"], (int, float))
|
||||||
|
or not 0 < data["now"] < CONCEPT_MAX_EPOCH_SECONDS
|
||||||
|
)
|
||||||
|
)
|
||||||
or any(
|
or any(
|
||||||
isinstance(data.get(key, 0), bool)
|
isinstance(data.get(key, 0), bool)
|
||||||
or not isinstance(data.get(key, 0), int)
|
or not isinstance(data.get(key, 0), int)
|
||||||
@@ -510,6 +522,7 @@ def create_app(root: Path) -> web.Application:
|
|||||||
extra_images=data.get("extra_images", 0),
|
extra_images=data.get("extra_images", 0),
|
||||||
seedless_images=data.get("seedless_images", 0),
|
seedless_images=data.get("seedless_images", 0),
|
||||||
short_seedless=data.get("short_seedless", False),
|
short_seedless=data.get("short_seedless", False),
|
||||||
|
now=data.get("now"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return web.json_response({"success": True, **manifest})
|
return web.json_response({"success": True, **manifest})
|
||||||
|
|||||||
@@ -178,27 +178,28 @@ async function captureFooter(page,testInfo,name) {
|
|||||||
|
|
||||||
test.beforeEach(async({request})=>{await seedConcept(request);});
|
test.beforeEach(async({request})=>{await seedConcept(request);});
|
||||||
|
|
||||||
for(const [width,height] of SIZES)for(const theme of THEMES)test(`shell geometry, tokens and controls match the concept ${width}x${height} ${theme}`,async({page},testInfo)=>{
|
// One test per route, size, theme and motion. All of them in one test per size and theme (12 page loads, 12 captures,
|
||||||
|
// 36 hovers) steadily took 22 s median and up to 33 s on CI WebKit, past the 30 s budget with no step stalled; one
|
||||||
|
// route in one motion is a sixth of that work under the same budget.
|
||||||
|
for(const [route,breadcrumb] of ROUTES)for(const [width,height] of SIZES)for(const theme of THEMES)for(const motion of MOTIONS)test(`shell geometry, tokens and controls match the concept on ${route} ${width}x${height} ${theme} ${motion}`,async({page},testInfo)=>{
|
||||||
await page.setViewportSize({width,height});const result={};
|
await page.setViewportSize({width,height});const result={};
|
||||||
for(const motion of MOTIONS)for(const [route,breadcrumb] of ROUTES) {
|
await openConcept(page,route,theme,motion);await page.mouse.move(0,0);
|
||||||
await openConcept(page,route,theme,motion);await page.mouse.move(0,0);
|
const reference=await inventoryOf(page,'#main');
|
||||||
const reference=await inventoryOf(page,'#main');
|
if(motion==='no-preference')await captureCanonicalState(page,testInfo,`concept-shell-${route}-${width}-${theme}`);
|
||||||
if(motion==='no-preference')await captureCanonicalState(page,testInfo,`concept-shell-${route}-${width}-${theme}`);
|
const referenceRings=FULL_HEADING.has(route)?await ringsOf(page,'#main'):null;
|
||||||
const referenceRings=FULL_HEADING.has(route)?await ringsOf(page,'#main'):null;
|
const referenceHovers=await hoversOf(page);
|
||||||
const referenceHovers=await hoversOf(page);
|
if(motion==='no-preference')await captureFooter(page,testInfo,`concept-footer-${route}-${width}-${theme}`);
|
||||||
if(motion==='no-preference')await captureFooter(page,testInfo,`concept-footer-${route}-${width}-${theme}`);
|
await openProduction(page,route,theme,motion);await page.mouse.move(0,0);
|
||||||
await openProduction(page,route,theme,motion);await page.mouse.move(0,0);
|
const actual=await inventoryOf(page,'#workspace-main');
|
||||||
const actual=await inventoryOf(page,'#workspace-main');
|
if(motion==='no-preference')await captureCanonicalState(page,testInfo,`production-shell-${route}-${width}-${theme}`);
|
||||||
if(motion==='no-preference')await captureCanonicalState(page,testInfo,`production-shell-${route}-${width}-${theme}`);
|
const actualRings=FULL_HEADING.has(route)?await ringsOf(page,'#workspace-main'):null;
|
||||||
const actualRings=FULL_HEADING.has(route)?await ringsOf(page,'#workspace-main'):null;
|
const actualHovers=await hoversOf(page);
|
||||||
const actualHovers=await hoversOf(page);
|
if(motion==='no-preference')await captureFooter(page,testInfo,`production-footer-${route}-${width}-${theme}`);
|
||||||
if(motion==='no-preference')await captureFooter(page,testInfo,`production-footer-${route}-${width}-${theme}`);
|
result[`${motion} ${route}`]={reference,actual,diff:compare(route,reference,actual),referenceRings,actualRings,
|
||||||
result[`${motion} ${route}`]={route,breadcrumb,reference,actual,diff:compare(route,reference,actual),referenceRings,actualRings,
|
referenceHovers,actualHovers};
|
||||||
referenceHovers,actualHovers};
|
const path=testInfo.outputPath(`shell-geometry-${route}-${motion}.json`);await writeFile(path,JSON.stringify(result,null,2));
|
||||||
}
|
await testInfo.attach(`shell-geometry-${route}-${motion}`,{path,contentType:'application/json'});
|
||||||
const path=testInfo.outputPath('shell-geometry.json');await writeFile(path,JSON.stringify(result,null,2));
|
for(const [key,{reference,actual,diff,referenceRings,actualRings,referenceHovers,actualHovers}] of Object.entries(result)) {
|
||||||
await testInfo.attach('shell-geometry',{path,contentType:'application/json'});
|
|
||||||
for(const [key,{route,breadcrumb,reference,actual,diff,referenceRings,actualRings,referenceHovers,actualHovers}] of Object.entries(result)) {
|
|
||||||
// The concept itself is what this comparison assumes: its sample counts and its shell controls.
|
// The concept itself is what this comparison assumes: its sample counts and its shell controls.
|
||||||
expect.soft(reference.chrome.navs[0].rows.map(row=>[row.text,row.count?.text??null]),`${key} concept WORKSPACE rows`)
|
expect.soft(reference.chrome.navs[0].rows.map(row=>[row.text,row.count?.text??null]),`${key} concept WORKSPACE rows`)
|
||||||
.toEqual([['Images','24'],['Prompt library','6'],['Tags & categories','15'],['Inspect a file',null]]);
|
.toEqual([['Images','24'],['Prompt library','6'],['Tags & categories','15'],['Inspect a file',null]]);
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
"""Report a slow test reset with what the server was doing while it waited.
|
||||||
|
|
||||||
|
A reset normally answers within about a second. Once one runs past
|
||||||
|
SLOW_RESET_SECONDS, this writes to stderr, while the reset is still waiting:
|
||||||
|
|
||||||
|
- every thread's stack, so a reset waiting on a lock, a full executor or a
|
||||||
|
slow disk shows where it waits and who holds it;
|
||||||
|
- the default executor's threads and queued work;
|
||||||
|
- how many database connections are open, and the WAL size;
|
||||||
|
- on Linux, each thread's scheduler state and the kernel function it sleeps
|
||||||
|
in (``D`` with a journal or writeback wait is a slow disk flush, a nanosleep
|
||||||
|
is SQLite's busy handler, a futex is a Python lock or an idle worker).
|
||||||
|
|
||||||
|
When the reset ends it writes how long it took and, for each executor call it
|
||||||
|
made, how long the call queued and how long it ran.
|
||||||
|
|
||||||
|
Every report line starts with REPORT_PREFIX. The worker fixture
|
||||||
|
(tools/vendor/start-browser-server.mjs) copies those lines to the job log,
|
||||||
|
so a slow reset is visible even in a test that still passes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextvars
|
||||||
|
import faulthandler
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Callable, TextIO
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
SLOW_RESET_SECONDS = 2.0
|
||||||
|
REPORT_PREFIX = "pm-slow-reset| "
|
||||||
|
|
||||||
|
_current: contextvars.ContextVar["ResetWatch | None"] = contextvars.ContextVar(
|
||||||
|
"pm_reset_watch", default=None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_reset_path(path: str) -> bool:
|
||||||
|
return path.startswith("/__test__/") and path.endswith("/reset")
|
||||||
|
|
||||||
|
|
||||||
|
class ResetWatch:
|
||||||
|
"""Time one reset; dump the server's state if it passes the threshold."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
label: str,
|
||||||
|
describe: Callable[[], list[str]],
|
||||||
|
*,
|
||||||
|
threshold: float = SLOW_RESET_SECONDS,
|
||||||
|
stream: TextIO | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.label = label
|
||||||
|
self.threshold = threshold
|
||||||
|
self._describe = describe
|
||||||
|
self._stream = stream
|
||||||
|
self._start = time.monotonic()
|
||||||
|
self._calls: list[str] = []
|
||||||
|
self._calls_lock = threading.Lock()
|
||||||
|
self._stalled = threading.Event()
|
||||||
|
self._timer = threading.Timer(threshold, self._report_stall)
|
||||||
|
self._timer.daemon = True
|
||||||
|
self._timer.name = "pm-slow-reset-watch"
|
||||||
|
|
||||||
|
def start(self) -> "ResetWatch":
|
||||||
|
self._timer.start()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def elapsed(self) -> float:
|
||||||
|
return time.monotonic() - self._start
|
||||||
|
|
||||||
|
def record_call(self, name: str, queued: float, ran: float) -> None:
|
||||||
|
with self._calls_lock:
|
||||||
|
self._calls.append(
|
||||||
|
f"executor call {name}: queued {queued:.3f}s, ran {ran:.3f}s"
|
||||||
|
)
|
||||||
|
|
||||||
|
def finish(self, outcome: str) -> None:
|
||||||
|
self._timer.cancel()
|
||||||
|
elapsed = self.elapsed()
|
||||||
|
# A coarse clock (Windows) can fire the timer just short of the
|
||||||
|
# threshold; a reset that was reported always gets its summary.
|
||||||
|
if elapsed < self.threshold and not self._stalled.is_set():
|
||||||
|
return
|
||||||
|
with self._calls_lock:
|
||||||
|
calls = list(self._calls)
|
||||||
|
self._write([f"{self.label} {outcome} after {elapsed:.3f}s", *calls])
|
||||||
|
|
||||||
|
def _report_stall(self) -> None:
|
||||||
|
self._stalled.set()
|
||||||
|
lines = [f"{self.label} still running after {self.elapsed():.3f}s"]
|
||||||
|
try:
|
||||||
|
lines.extend(self._describe())
|
||||||
|
except Exception as error: # a diagnostic never breaks the reset
|
||||||
|
lines.append(f"state unavailable: {error!r}")
|
||||||
|
lines.extend(thread_stacks())
|
||||||
|
self._write(lines)
|
||||||
|
|
||||||
|
def _write(self, lines: list[str]) -> None:
|
||||||
|
stream = self._stream or sys.stderr
|
||||||
|
text = "".join(
|
||||||
|
f"{REPORT_PREFIX}{part}\n" for line in lines for part in line.splitlines()
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
stream.write(text)
|
||||||
|
stream.flush()
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def thread_stacks() -> list[str]:
|
||||||
|
"""Every thread's stack, from faulthandler.
|
||||||
|
|
||||||
|
Formatting sys._current_frames() showed a thread blocked in SQLite as idle
|
||||||
|
(Python 3.14); faulthandler reports where each thread really is.
|
||||||
|
"""
|
||||||
|
# Before Python 3.14 faulthandler prints ids only; name each id here.
|
||||||
|
names = [
|
||||||
|
f"thread 0x{thread.ident:016x} is {thread.name}"
|
||||||
|
for thread in threading.enumerate()
|
||||||
|
if thread.ident is not None
|
||||||
|
]
|
||||||
|
with tempfile.TemporaryFile() as dump:
|
||||||
|
faulthandler.dump_traceback(file=dump, all_threads=True)
|
||||||
|
dump.seek(0)
|
||||||
|
return [*names, *dump.read().decode("utf-8", "replace").splitlines()]
|
||||||
|
|
||||||
|
|
||||||
|
def executor_state(loop: asyncio.AbstractEventLoop) -> list[str]:
|
||||||
|
executor = getattr(loop, "_default_executor", None)
|
||||||
|
if executor is None:
|
||||||
|
return ["default executor: not started"]
|
||||||
|
threads = getattr(executor, "_threads", ())
|
||||||
|
queue = getattr(executor, "_work_queue", None)
|
||||||
|
queued = queue.qsize() if queue is not None else "?"
|
||||||
|
return [
|
||||||
|
f"default executor: {len(threads)} of "
|
||||||
|
f"{getattr(executor, '_max_workers', '?')} threads, {queued} queued"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def database_state(model) -> list[str]:
|
||||||
|
"""How many connections are open, and the WAL size.
|
||||||
|
|
||||||
|
Never touches another thread's connection: a connection busy in SQLite
|
||||||
|
holds its mutex, and reading even ``in_transaction`` from here would block
|
||||||
|
on it while holding the GIL, freezing every thread until that query ends.
|
||||||
|
"""
|
||||||
|
with model._conn_lock:
|
||||||
|
count = len(model._connections)
|
||||||
|
wal = model.db_path + "-wal"
|
||||||
|
size = os.path.getsize(wal) if os.path.exists(wal) else 0
|
||||||
|
return [f"database: {count} connections, WAL {size} bytes"]
|
||||||
|
|
||||||
|
|
||||||
|
def kernel_wait_state() -> list[str]:
|
||||||
|
"""Each thread's state and kernel wait channel, where /proc has them."""
|
||||||
|
lines = []
|
||||||
|
for thread in threading.enumerate():
|
||||||
|
task = f"/proc/self/task/{thread.native_id}"
|
||||||
|
try:
|
||||||
|
with open(f"{task}/stat", encoding="ascii") as stat:
|
||||||
|
state = stat.read().rsplit(")", 1)[1].split()[0]
|
||||||
|
with open(f"{task}/wchan", encoding="ascii") as wchan:
|
||||||
|
channel = wchan.read().strip() or "0"
|
||||||
|
except (OSError, IndexError):
|
||||||
|
continue
|
||||||
|
# The id matches the "Thread 0x..." headers of the stacks below.
|
||||||
|
lines.append(
|
||||||
|
f"kernel: {thread.name} (0x{thread.ident:016x}) state {state}, "
|
||||||
|
f"waiting in {channel}"
|
||||||
|
)
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def install(app: web.Application, api) -> None:
|
||||||
|
"""Watch every reset route, and time the executor calls each one makes."""
|
||||||
|
run_in_executor = api._run_in_executor
|
||||||
|
|
||||||
|
async def timed_run_in_executor(func, *args, **kwargs):
|
||||||
|
watch = _current.get()
|
||||||
|
if watch is None:
|
||||||
|
return await run_in_executor(func, *args, **kwargs)
|
||||||
|
queued_at = time.monotonic()
|
||||||
|
started: list[float] = []
|
||||||
|
|
||||||
|
def timed(*call_args, **call_kwargs):
|
||||||
|
started.append(time.monotonic())
|
||||||
|
return func(*call_args, **call_kwargs)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await run_in_executor(timed, *args, **kwargs)
|
||||||
|
finally:
|
||||||
|
end = time.monotonic()
|
||||||
|
begin = started[0] if started else end
|
||||||
|
watch.record_call(
|
||||||
|
getattr(func, "__qualname__", repr(func)),
|
||||||
|
begin - queued_at,
|
||||||
|
end - begin,
|
||||||
|
)
|
||||||
|
|
||||||
|
api._run_in_executor = timed_run_in_executor
|
||||||
|
|
||||||
|
@web.middleware
|
||||||
|
async def watch_resets(request: web.Request, handler):
|
||||||
|
if not is_reset_path(request.path):
|
||||||
|
return await handler(request)
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
|
def describe() -> list[str]:
|
||||||
|
return [
|
||||||
|
*executor_state(loop),
|
||||||
|
*database_state(api.db.model),
|
||||||
|
*kernel_wait_state(),
|
||||||
|
]
|
||||||
|
|
||||||
|
watch = ResetWatch(
|
||||||
|
f"{request.method} {request.path}", describe, threshold=SLOW_RESET_SECONDS
|
||||||
|
).start()
|
||||||
|
token = _current.set(watch)
|
||||||
|
outcome = "failed"
|
||||||
|
try:
|
||||||
|
response = await handler(request)
|
||||||
|
outcome = f"answered {response.status}"
|
||||||
|
return response
|
||||||
|
except web.HTTPException as error:
|
||||||
|
outcome = f"answered {error.status}"
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
_current.reset(token)
|
||||||
|
watch.finish(outcome)
|
||||||
|
|
||||||
|
app.middlewares.append(watch_resets)
|
||||||
@@ -2,6 +2,13 @@ import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage
|
|||||||
import { test, expect } from './fixtures.mjs';
|
import { test, expect } from './fixtures.mjs';
|
||||||
import { seedConcept, openConcept, openProduction } from './concept-inventory.mjs';
|
import { seedConcept, openConcept, openProduction } from './concept-inventory.mjs';
|
||||||
|
|
||||||
|
// The Activity store's periodic list read (activity-store.mjs POLL_MS) and the detail view's read throttle
|
||||||
|
// (activity-job-view.mjs DETAIL_INTERVAL_MS).
|
||||||
|
const POLL_MS = 15_000;
|
||||||
|
const DETAIL_INTERVAL_MS = 1_000;
|
||||||
|
// A request is reported to the test a little after the browser sends it; that delay is not a shorter wait.
|
||||||
|
const REPORT_SLACK_MS = 100;
|
||||||
|
|
||||||
// Activity (#249 Activity-1): the concept's Activity page (app.js renderActivity 732–738), its top-bar button
|
// Activity (#249 Activity-1): the concept's Activity page (app.js renderActivity 732–738), its top-bar button
|
||||||
// (index.html 51–52) and sidebar row (app.js ~341), with the recorded D52 (counts of running jobs), D53 (the progress
|
// (index.html 51–52) and sidebar row (app.js ~341), with the recorded D52 (counts of running jobs), D53 (the progress
|
||||||
// pill) and D54 (real job items). Fake jobs come from tests/browser/workspace_activity_fixture.py.
|
// pill) and D54 (real job items). Fake jobs come from tests/browser/workspace_activity_fixture.py.
|
||||||
@@ -278,6 +285,8 @@ test('reload during a scan reattaches to the running job', async ({ page, reques
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('lost server clears the pill and marks jobs ended', async ({ page, request }) => {
|
test('lost server clears the pill and marks jobs ended', async ({ page, request }) => {
|
||||||
|
// A poll interval after the restart, then failed re-reads backing off for 1 + 2 + 4 s.
|
||||||
|
test.setTimeout(90_000);
|
||||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||||
const job = await startJob(request);
|
const job = await startJob(request);
|
||||||
await step(request, job.id, { processed: 4000 });
|
await step(request, job.id, { processed: 4000 });
|
||||||
@@ -294,9 +303,12 @@ test('lost server clears the pill and marks jobs ended', async ({ page, request
|
|||||||
await expect(items(page).first().locator('p')).toHaveText(LOST);
|
await expect(items(page).first().locator('p')).toHaveText(LOST);
|
||||||
await expect(items(page).first().locator('progress, .row-actions')).toHaveCount(0);
|
await expect(items(page).first().locator('progress, .row-actions')).toHaveCount(0);
|
||||||
await expect(navRow(page).locator('.nav-count')).toHaveCount(0);
|
await expect(navRow(page).locator('.nav-count')).toHaveCount(0);
|
||||||
// One re-read found the restart; there is no retry storm and no finish toast for a job that only vanished.
|
// One re-read found the restart; there is no retry storm and no finish toast for a job that only vanished. The
|
||||||
await page.waitForTimeout(2500);
|
// read after it is the periodic one, a whole poll interval later: any extra read lands in that gap and fails it.
|
||||||
expect(reads.length).toBeLessThanOrEqual(2);
|
const found = reads.length;
|
||||||
|
expect(found, 'reads that found the restart').toBeGreaterThanOrEqual(1);
|
||||||
|
await expect.poll(() => reads.length, { timeout: POLL_MS + 10_000 }).toBeGreaterThan(found);
|
||||||
|
expect(reads[found] - reads[found - 1], 'the next read waits for the poll').toBeGreaterThanOrEqual(POLL_MS - REPORT_SLACK_MS);
|
||||||
await expect(toast(page)).toHaveText('');
|
await expect(toast(page)).toHaveText('');
|
||||||
|
|
||||||
// A server that cannot be read at all also ends the running job, then is read again with a growing backoff.
|
// A server that cannot be read at all also ends the running job, then is read again with a growing backoff.
|
||||||
@@ -308,9 +320,12 @@ test('lost server clears the pill and marks jobs ended', async ({ page, request
|
|||||||
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
|
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
|
||||||
await expect(pill(page)).toBeHidden();
|
await expect(pill(page)).toBeHidden();
|
||||||
await expect(items(page).filter({ hasText: LOST })).toHaveCount(1);
|
await expect(items(page).filter({ hasText: LOST })).toHaveCount(1);
|
||||||
await page.waitForTimeout(3500);
|
// 1 s, then 2 s, then 4 s: each failed re-read waits twice as long as the one before. The gaps between the
|
||||||
// 1 s, then 2 s: at most two re-reads in 3.5 s.
|
// reads themselves are the evidence, so a slow runner noticing the ended job late cannot add a read to a window.
|
||||||
expect(failed.length).toBeLessThanOrEqual(2);
|
await expect.poll(() => failed.length, { timeout: 20_000 }).toBeGreaterThanOrEqual(3);
|
||||||
|
const gaps = failed.slice(1, 3).map((at, index) => at - failed[index]);
|
||||||
|
expect(gaps[0]).toBeGreaterThanOrEqual(2000 - REPORT_SLACK_MS);
|
||||||
|
expect(gaps[1]).toBeGreaterThanOrEqual(4000 - REPORT_SLACK_MS);
|
||||||
await page.unroute('**/prompt_manager/jobs**');
|
await page.unroute('**/prompt_manager/jobs**');
|
||||||
expect(second.id).not.toBe(job.id);
|
expect(second.id).not.toBe(job.id);
|
||||||
});
|
});
|
||||||
@@ -607,6 +622,8 @@ test('filter narrows the selected outcome', async ({ page, request }) => {
|
|||||||
|
|
||||||
test('job view renders 200-row cap note', async ({ page, request }) => {
|
test('job view renders 200-row cap note', async ({ page, request }) => {
|
||||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||||
|
// The page's timers are Playwright's, running at real speed, so the M12 check below can run a read throttle out.
|
||||||
|
await page.clock.install();
|
||||||
const job = await startJob(request);
|
const job = await startJob(request);
|
||||||
await step(request, job.id, { processed: 260, bulk: { group: 'added', count: 250 }, records: [
|
await step(request, job.id, { processed: 260, bulk: { group: 'added', count: 250 }, records: [
|
||||||
...Array.from({ length: 10 }, (_, index) => ({ group: 'no_metadata', path: `misc/plain_${index}.png`, detail: 'No prompt metadata in the file' })),
|
...Array.from({ length: 10 }, (_, index) => ({ group: 'no_metadata', path: `misc/plain_${index}.png`, detail: 'No prompt metadata in the file' })),
|
||||||
@@ -646,11 +663,15 @@ test('job view renders 200-row cap note', async ({ page, request }) => {
|
|||||||
page.on('request', item => { if (new URL(item.url()).pathname === `/prompt_manager/jobs/${job.id}`) reads.push(Date.now()); });
|
page.on('request', item => { if (new URL(item.url()).pathname === `/prompt_manager/jobs/${job.id}`) reads.push(Date.now()); });
|
||||||
await step(request, job.id, { processed: 300, bulk: { group: 'added', count: 40 } });
|
await step(request, job.id, { processed: 300, bulk: { group: 'added', count: 40 } });
|
||||||
await expect(tiles(page).first().locator('dd')).toHaveText('290');
|
await expect(tiles(page).first().locator('dd')).toHaveText('290');
|
||||||
await page.waitForTimeout(1500);
|
// Every read the counter update could have scheduled is due within one throttle interval: run it out, so any such
|
||||||
|
// read is sent before the log opens. Requests reach the test in the order they were sent, so once the log's read
|
||||||
|
// has answered, a read sent before it would already be listed.
|
||||||
|
await page.clock.runFor(DETAIL_INTERVAL_MS);
|
||||||
expect(reads).toEqual([]);
|
expect(reads).toEqual([]);
|
||||||
await scanLog(page).locator('summary').click();
|
await scanLog(page).locator('summary').click();
|
||||||
await expect.poll(() => reads.length).toBe(1);
|
await expect.poll(() => reads.length).toBe(1);
|
||||||
await expect(scanLog(page).locator('pre')).toContainText('bulk/file_00039.png');
|
await expect(scanLog(page).locator('pre')).toContainText('bulk/file_00039.png');
|
||||||
|
expect(reads, 'only the log\'s own read').toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('cancel scan ends cancelled with partial counts', async ({ page, request }) => {
|
test('cancel scan ends cancelled with partial counts', async ({ page, request }) => {
|
||||||
@@ -705,14 +726,17 @@ test('a held legacy job shows no Cancel in the list or its view', async ({ page,
|
|||||||
|
|
||||||
test('busy start names the running job', async ({ page, request }) => {
|
test('busy start names the running job', async ({ page, request }) => {
|
||||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||||
|
// The page's timers are Playwright's, so the toast's lifetime below is run out rather than waited for.
|
||||||
|
await page.clock.install();
|
||||||
const running = await startJob(request, { title: 'Database maintenance', kind: 'maintenance' });
|
const running = await startJob(request, { title: 'Database maintenance', kind: 'maintenance' });
|
||||||
await startFromImport(page);
|
await startFromImport(page);
|
||||||
await expect(toast(page)).toHaveText('Another job is running: Database maintenance.');
|
await expect(toast(page)).toHaveText('Another job is running: Database maintenance.');
|
||||||
await expect(toast(page).getByRole('link', { name: 'Database maintenance', exact: true })).toHaveAttribute('href', `#/activity/${running.id}`);
|
await expect(toast(page).getByRole('link', { name: 'Database maintenance', exact: true })).toHaveAttribute('href', `#/activity/${running.id}`);
|
||||||
await expect(page).toHaveURL(/#\/images$/);
|
await expect(page).toHaveURL(/#\/images$/);
|
||||||
expect((await listed(request)).map(item => item.id)).toEqual([running.id]);
|
expect((await listed(request)).map(item => item.id)).toEqual([running.id]);
|
||||||
// Review M14: a toast with a link outlasts the plain 6.5 s, so the link stays reachable.
|
// Review M14: a toast with a link outlasts the plain 6.5 s, so the link stays reachable. Running the page's timers
|
||||||
await page.waitForTimeout(7000);
|
// 7 s on fires a 6.5 s clear for certain, however slowly this runner reached this line.
|
||||||
|
await page.clock.runFor(7000);
|
||||||
await expect(toast(page).getByRole('link', { name: 'Database maintenance', exact: true })).toBeVisible();
|
await expect(toast(page).getByRole('link', { name: 'Database maintenance', exact: true })).toBeVisible();
|
||||||
await toast(page).getByRole('link', { name: 'Database maintenance', exact: true }).click();
|
await toast(page).getByRole('link', { name: 'Database maintenance', exact: true }).click();
|
||||||
await expect(heading(page).locator('h1')).toHaveText('Database maintenance');
|
await expect(heading(page).locator('h1')).toHaveText('Database maintenance');
|
||||||
|
|||||||
@@ -494,6 +494,23 @@ test('the inline current file names its prompts apart from the frozen run fields
|
|||||||
expect(await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'))).toBe(before);
|
expect(await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'))).toBe(before);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// LI-4 (D117): the recorded history's current-file viewer is the shared full inspector, so it copies the legacy summary.
|
||||||
|
test('the current file viewer copies its settings summary',async({page})=>{
|
||||||
|
await page.addInitScript(()=>{window.__copied=[];Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>{window.__copied.push(text);}}});});
|
||||||
|
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).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();
|
||||||
|
const details=page.locator('dialog#viewer-dialog #viewer-details');await expect(details.locator('.panel-heading')).toBeVisible();
|
||||||
|
const facts=await details.locator(':scope > div > dl.meta-grid dd').allTextContents();
|
||||||
|
await details.getByRole('button',{name:'Copy settings summary',exact:true}).click();
|
||||||
|
await expect(page.locator('#workspace-toast')).toHaveText('Settings summary copied.');
|
||||||
|
const [seed,steps,cfg,sampler,,model]=facts;
|
||||||
|
expect(await page.evaluate(()=>window.__copied)).toEqual([[`Checkpoint: ${model}`,'Positive Prompt: current file positive','Negative Prompt: current file negative',
|
||||||
|
`Steps: ${steps}`,`CFG Scale: ${cfg}`,`Sampler: ${sampler}`,`Seed: ${seed}`].join('\n').replace(/: (Not recorded|Unresolved|Ambiguous|Unavailable within inspection limits)(?=\n|$)/g,': Unknown')]);
|
||||||
|
});
|
||||||
|
|
||||||
test('the recorded run dialog with a resolved current file has no duplicate button or region names',async({page})=>{
|
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();
|
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'}};
|
body.prompts={positive:{status:'resolved',text:'current file positive',source:'graph-inferred'},negative:{status:'resolved',text:'current file negative',source:'graph-inferred'}};
|
||||||
|
|||||||
@@ -907,6 +907,10 @@ test('browser Back closes the open Images dialog and leaves the next page usable
|
|||||||
|
|
||||||
test('count line shows images and positive prompts', async ({ page, request }) => {
|
test('count line shows images and positive prompts', async ({ page, request }) => {
|
||||||
await page.addInitScript(() => { window.IntersectionObserver = undefined; });
|
await page.addInitScript(() => { window.IntersectionObserver = undefined; });
|
||||||
|
// D17's "Today" is the newest file's day: the page's clock stands at that file's mtime, so the fixture's day and
|
||||||
|
// the page's agree on either side of midnight.
|
||||||
|
const { images } = await (await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();
|
||||||
|
await page.clock.setFixedTime(new Date(Math.max(...images.map(image => image.modified_time)) * 1000));
|
||||||
await page.goto(workspace);
|
await page.goto(workspace);
|
||||||
await expect(cards(page)).toHaveCount(2);
|
await expect(cards(page)).toHaveCount(2);
|
||||||
await expect(caption(page).locator('#result-count')).toHaveText('2 of 2 images · 2 positive prompts');
|
await expect(caption(page).locator('#result-count')).toHaveText('2 of 2 images · 2 positive prompts');
|
||||||
|
|||||||
@@ -207,6 +207,40 @@ test('chooser opens the shared full inspector without a second read', async ({ p
|
|||||||
expect(await page.evaluate(() => window.__slices)).toBe(0);
|
expect(await page.evaluate(() => window.__slices)).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// PR L3 Task 10: local roles are the server inspector's (local-resolver.mjs), so for every parity PNG the rail shows the
|
||||||
|
// prompts, their status lines and the facts Images shows for the same bytes in the output folder.
|
||||||
|
const PARITY_PNGS = ['f01b', 'f02', 'f03', 'f06a', 'f06b', 'f06c', 'f06d', 'f06e', 'f07a', 'f07b', 'f08', 'f09', 'f10', 'f11', 'f12',
|
||||||
|
'f14', 'f15', 'f16', 'f17', 'f19', 'f20', 'f21', 'f22', 'f23', 'f24'];
|
||||||
|
const roleSections = locator => locator.evaluate(node => [...node.querySelectorAll('.prompt-section')].map(item => item.textContent));
|
||||||
|
|
||||||
|
test('chooser shows the Images roles and resolution for every parity PNG', async ({ page, request }) => {
|
||||||
|
test.slow();
|
||||||
|
for (const name of PARITY_PNGS) {
|
||||||
|
await expect(await request.post('/__test__/add-local-inspect-file', { data: { name: `${name}.png` } })).toBeOK();
|
||||||
|
}
|
||||||
|
const served = {};
|
||||||
|
await page.goto(`${workspace}#/images`);
|
||||||
|
for (const name of PARITY_PNGS) {
|
||||||
|
await page.locator(`article[data-image-key="0:local-inspect/${name}.png"] .image-open`).click();
|
||||||
|
await expect(viewer(page).locator('#viewer-title')).toHaveText(`${name}.png`);
|
||||||
|
await expect(rail(page).locator('.panel-heading')).toBeVisible();
|
||||||
|
await expect(rail(page).locator('.prompt-section')).not.toHaveCount(0);
|
||||||
|
served[name] = { content: await railContent(rail(page)), roles: await roleSections(rail(page)) };
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect(viewer(page)).toHaveCount(0);
|
||||||
|
}
|
||||||
|
await openInspect(page);
|
||||||
|
for (const name of PARITY_PNGS) {
|
||||||
|
await choose(page, parityPng(`${name}.png`));
|
||||||
|
await openViewer(page);
|
||||||
|
await expect(viewer(page).locator('#viewer-title')).toHaveText(`${name}.png`);
|
||||||
|
await expect(rail(page).locator('.prompt-section')).not.toHaveCount(0);
|
||||||
|
expect({ content: await railContent(rail(page)), roles: await roleSections(rail(page)) }, name).toEqual(served[name]);
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect(viewer(page)).toHaveCount(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('Import → Inspect a local image lands here', async ({ page }) => {
|
test('Import → Inspect a local image lands here', async ({ page }) => {
|
||||||
await page.goto(`${workspace}#/images`);
|
await page.goto(`${workspace}#/images`);
|
||||||
await page.getByRole('button', { name: 'Import', exact: true }).click();
|
await page.getByRole('button', { name: 'Import', exact: true }).click();
|
||||||
@@ -481,15 +515,13 @@ test('replacing a video that just started loading never blocks the page', async
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('a video replaced while still starting is reset once it settles', async ({ context, browserName }) => {
|
test('a video replaced while still starting is reset once it settles', async ({ context, browserName }) => {
|
||||||
// A WebKit (GStreamer) video released exactly at its `loadstart` sometimes blocks the page, with or without any
|
test.setTimeout(180000);
|
||||||
// reset and also with the code before the deferred reset (2 of 24 runs); tracked in TESTING.md (D62). WebKit's
|
// The video is replaced from its own `loadstart` (readyState HAVE_NOTHING), or a few milliseconds after it. On
|
||||||
// no-hang guard is `replacing a video that just started loading never blocks the page`.
|
// WebKit (GStreamer) a video that left the page or the viewport before its pipeline had started deadlocked the
|
||||||
test.skip(browserName === 'webkit', 'WebKit release at loadstart is a tracked follow-up (D62)');
|
// page (TESTING.md D62), so it stays in place, inert and still playing its URL, with the new preview after it.
|
||||||
test.setTimeout(120000);
|
// Once it reports metadata or an error it is paused, loses its src, is reset (load() with no src, which frees its
|
||||||
// The video is replaced from its own `loadstart` (readyState HAVE_NOTHING). It leaves the page with no src before
|
// decoder) and leaves the page, and only then is its URL revoked. Fresh pages share one browser process.
|
||||||
// its URL is revoked, and is reset (load() with no src, which frees its decoder) only once it reports metadata or
|
for (let attempt = 0; attempt < 15; attempt++) {
|
||||||
// an error, so the page never blocks. Fresh pages share one browser process.
|
|
||||||
for (let attempt = 0; attempt < 5; attempt++) {
|
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
try {
|
try {
|
||||||
if (attempt === 0) await startCoverage(page, browserName);
|
if (attempt === 0) await startCoverage(page, browserName);
|
||||||
@@ -510,10 +542,12 @@ test('a video replaced while still starting is reset once it settles', async ({
|
|||||||
const video = slot.querySelector('video');
|
const video = slot.querySelector('video');
|
||||||
if (!video) return;
|
if (!video) return;
|
||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
video.addEventListener('loadstart', () => {
|
const replace = () => {
|
||||||
window.__starting = { node: video, url: video.getAttribute('src'), readyState: video.readyState };
|
window.__starting = { node: video, url: video.getAttribute('src'), readyState: video.readyState };
|
||||||
input.files = window.__pngFiles; input.dispatchEvent(new Event('change', { bubbles: true }));
|
input.files = window.__pngFiles; input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
}, { once: true });
|
window.__starting.replaced = { connected: video.isConnected, src: video.hasAttribute('src'), inert: video.inert, resets: video.__resets ?? 0 };
|
||||||
|
};
|
||||||
|
video.addEventListener('loadstart', () => { if (window.__delay) setTimeout(replace, window.__delay); else replace(); }, { once: true });
|
||||||
}).observe(slot, { childList: true });
|
}).observe(slot, { childList: true });
|
||||||
});
|
});
|
||||||
const png = parityPng('f02.png');
|
const png = parityPng('f02.png');
|
||||||
@@ -522,13 +556,17 @@ test('a video replaced while still starting is reset once it settles', async ({
|
|||||||
transfer.items.add(new File([new Uint8Array(bytes)], 'f02.png', { type: 'image/png' }));
|
transfer.items.add(new File([new Uint8Array(bytes)], 'f02.png', { type: 'image/png' }));
|
||||||
window.__pngFiles = transfer.files;
|
window.__pngFiles = transfer.files;
|
||||||
}, [...png.buffer]);
|
}, [...png.buffer]);
|
||||||
|
await page.evaluate(delay => { window.__delay = delay; }, attempt % 5);
|
||||||
await choose(page, localFixture('core-tags.webm', 'video/webm'));
|
await choose(page, localFixture('core-tags.webm', 'video/webm'));
|
||||||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible({ timeout: 8000 });
|
await expect(preview(page).locator('img.local-file-preview')).toBeVisible({ timeout: 8000 });
|
||||||
await expect(preview(page).locator('video')).toHaveCount(0);
|
await expect(preview(page).locator('video')).toHaveCount(0);
|
||||||
// Released while starting, and never reset synchronously: the URL went once the node had no src.
|
// Replaced while starting (always so at `loadstart` itself): kept in place and untouched. A video that had
|
||||||
expect(await page.evaluate(() => ({ readyState: window.__starting.readyState, revoked: window.__starting.revoked })))
|
// settled is released at once. Either way it is reset once, and its URL goes last.
|
||||||
.toEqual({ readyState: 0, revoked: { src: false, connected: false, resets: 0 } });
|
await expect.poll(() => page.evaluate(() => window.__starting.revoked ?? null)).toEqual({ src: false, connected: false, resets: 1 });
|
||||||
await expect.poll(() => page.evaluate(() => window.__starting.node.__resets ?? 0), { timeout: 15000 }).toBe(1);
|
const { readyState, replaced } = await page.evaluate(() => ({ readyState: window.__starting.readyState, replaced: window.__starting.replaced }));
|
||||||
|
if (attempt % 5 === 0) expect(readyState).toBe(0);
|
||||||
|
expect(replaced).toEqual(readyState === 0 ? { connected: true, src: true, inert: true, resets: 0 } : { connected: false, src: false, inert: false, resets: 1 });
|
||||||
|
expect(await page.evaluate(() => window.__starting.node.__resets)).toBe(1);
|
||||||
// The page still answers.
|
// The page still answers.
|
||||||
await chooser(page).setInputFiles(parityPng('f02.png', 'after.png'), { timeout: 8000 });
|
await chooser(page).setInputFiles(parityPng('f02.png', 'after.png'), { timeout: 8000 });
|
||||||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible({ timeout: 8000 });
|
await expect(preview(page).locator('img.local-file-preview')).toBeVisible({ timeout: 8000 });
|
||||||
@@ -652,13 +690,19 @@ test('local roles send exactly', async ({ page, context }) => {
|
|||||||
|
|
||||||
test('local inspection makes no content request or storage write', async ({ page }) => {
|
test('local inspection makes no content request or storage write', async ({ page }) => {
|
||||||
await instrument(page);
|
await instrument(page);
|
||||||
await openInspect(page);
|
|
||||||
// The shell's own reads (counts, Activity) settle before the baseline.
|
|
||||||
await expect(page.locator('aside.sidebar nav [data-route="images"] .nav-count')).toBeAttached();
|
|
||||||
const baseline = (await urls(page)).writes.length;
|
|
||||||
const requests = [];
|
const requests = [];
|
||||||
page.on('request', request => requests.push({ url: request.url(), method: request.method(), body: request.postData() }));
|
page.on('request', request => requests.push({ url: request.url(), method: request.method(), body: request.postData() }));
|
||||||
page.on('websocket', socket => requests.push({ url: socket.url(), method: 'WS', body: null }));
|
page.on('websocket', socket => requests.push({ url: socket.url(), method: 'WS', body: null }));
|
||||||
|
const table = /\/prompt_manager\/js\/inspection-semantics\.json$/;
|
||||||
|
const tableRead = page.waitForResponse(response => table.test(response.url()));
|
||||||
|
await openInspect(page);
|
||||||
|
// The one static file Inspect reads, its node table, is fetched as the page mounts: before any selection.
|
||||||
|
expect((await tableRead).ok()).toBe(true);
|
||||||
|
// The shell's own reads (counts, Activity) settle before the baseline.
|
||||||
|
await expect(page.locator('aside.sidebar nav [data-route="images"] .nav-count')).toBeAttached();
|
||||||
|
const baseline = (await urls(page)).writes.length;
|
||||||
|
const before = requests.length;
|
||||||
|
expect(requests.filter(entry => table.test(entry.url))).toHaveLength(1);
|
||||||
for (const file of [sendablePng('private-name.png'), localFixture('core-tags.webm', 'video/webm', 'private-clip.webm')]) {
|
for (const file of [sendablePng('private-name.png'), localFixture('core-tags.webm', 'video/webm', 'private-clip.webm')]) {
|
||||||
await choose(page, file);
|
await choose(page, file);
|
||||||
await openViewer(page);
|
await openViewer(page);
|
||||||
@@ -666,7 +710,8 @@ test('local inspection makes no content request or storage write', async ({ page
|
|||||||
await page.keyboard.press('Escape');
|
await page.keyboard.press('Escape');
|
||||||
await expect(viewer(page)).toHaveCount(0);
|
await expect(viewer(page)).toHaveCount(0);
|
||||||
}
|
}
|
||||||
const network = requests.filter(entry => !entry.url.startsWith('blob:') && !entry.url.startsWith('data:'));
|
const network = requests.slice(before).filter(entry => !entry.url.startsWith('blob:') && !entry.url.startsWith('data:'));
|
||||||
|
expect(requests.filter(entry => table.test(entry.url)), 'the table is read once per page').toHaveLength(1);
|
||||||
expect(network.filter(entry => /private|lighthouse|fox/.test(`${entry.url} ${entry.body ?? ''}`))).toEqual([]);
|
expect(network.filter(entry => /private|lighthouse|fox/.test(`${entry.url} ${entry.body ?? ''}`))).toEqual([]);
|
||||||
expect(network.filter(entry => entry.method !== 'GET')).toEqual([]);
|
expect(network.filter(entry => entry.method !== 'GET')).toEqual([]);
|
||||||
expect(network.filter(entry => !/\/prompt_manager\/(activity|images\/output|library|vocabulary)/.test(entry.url))).toEqual([]);
|
expect(network.filter(entry => !/\/prompt_manager\/(activity|images\/output|library|vocabulary)/.test(entry.url))).toEqual([]);
|
||||||
@@ -685,3 +730,80 @@ test('touch layout at 390 has no horizontal overflow at 200% zoom', async ({ pag
|
|||||||
expect(area).toBe(true);
|
expect(area).toBe(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// LI-4 (D117): "Copy settings summary" after the six facts is the legacy metadata page's COPY ALL, in the shared full
|
||||||
|
// inspector, so a file served in Images and the same bytes chosen here copy the same summary of what the rail shows.
|
||||||
|
/** Records clipboard writes in the page, or (`clipboard: false`) leaves the page without a clipboard. */
|
||||||
|
async function stubClipboard(page, { clipboard = true } = {}) {
|
||||||
|
await page.addInitScript(available => {
|
||||||
|
window.__copied = [];
|
||||||
|
Object.defineProperty(navigator, 'clipboard', { configurable: true,
|
||||||
|
value: available ? { writeText: async text => { window.__copied.push(text); } } : undefined });
|
||||||
|
}, clipboard);
|
||||||
|
}
|
||||||
|
/** The summary the rail's own prompts and facts make, in the legacy COPY ALL lines and placeholders. */
|
||||||
|
const expectedSummary = locator => locator.evaluate(node => {
|
||||||
|
const facts = Object.fromEntries([...node.querySelectorAll(':scope .meta-grid:not(details .meta-grid) > div')]
|
||||||
|
.map(item => [item.querySelector('dt').textContent, item.querySelector('dd').textContent]));
|
||||||
|
// A confirmed prompt has its Copy action; an explicitly empty one takes the legacy placeholder too.
|
||||||
|
const prompt = role => {
|
||||||
|
const text = node.querySelector(`.prompt-section.${role} [data-copy-role]`) ? node.querySelector(`.prompt-section.${role} p.prompt-text`).textContent : null;
|
||||||
|
return text === `No ${role} prompt (explicitly empty)` ? null : text;
|
||||||
|
};
|
||||||
|
return [`Checkpoint: ${facts.Model}`, `Positive Prompt: ${prompt('positive') ?? 'No prompt found'}`,
|
||||||
|
`Negative Prompt: ${prompt('negative') ?? 'No negative prompt found'}`, `Steps: ${facts.Steps}`, `CFG Scale: ${facts.CFG}`,
|
||||||
|
`Sampler: ${facts.Sampler}`, `Seed: ${facts.Seed}`].join('\n');
|
||||||
|
});
|
||||||
|
async function copySummary(page) {
|
||||||
|
const button = rail(page).getByRole('button', { name: 'Copy settings summary', exact: true });
|
||||||
|
// It follows the six facts, before "File & provenance".
|
||||||
|
expect(await button.evaluate(node => [node.previousElementSibling?.localName, node.nextElementSibling?.localName])).toEqual(['dl', 'details']);
|
||||||
|
await button.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Copy settings summary copies the same legacy summary in Images and Inspect', async ({ page, request }) => {
|
||||||
|
await stubClipboard(page);
|
||||||
|
await expect(await request.post('/__test__/add-local-inspect-file', { data: { name: 'f02.png' } })).toBeOK();
|
||||||
|
await page.goto(`${workspace}#/images`);
|
||||||
|
await page.locator('article[data-image-key="0:local-inspect/f02.png"] .image-open').click();
|
||||||
|
await expect(rail(page).locator('.prompt-section.positive p.prompt-text')).toBeVisible();
|
||||||
|
const served = await expectedSummary(rail(page));
|
||||||
|
expect(served).toMatch(/^Checkpoint: .+\nPositive Prompt: [\s\S]+\nNegative Prompt: [\s\S]+\nSteps: .+\nCFG Scale: .+\nSampler: .+\nSeed: .+$/);
|
||||||
|
expect(served).not.toContain('Unknown');
|
||||||
|
await copySummary(page);
|
||||||
|
await expect(page.locator('#workspace-toast')).toHaveText('Settings summary copied.');
|
||||||
|
expect(await page.evaluate(() => window.__copied)).toEqual([served]);
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect(viewer(page)).toHaveCount(0);
|
||||||
|
|
||||||
|
await openInspect(page);
|
||||||
|
await choose(page, parityPng('f02.png'));
|
||||||
|
await openViewer(page);
|
||||||
|
expect(await expectedSummary(rail(page))).toBe(served);
|
||||||
|
await copySummary(page);
|
||||||
|
await expect.poll(() => page.evaluate(() => window.__copied)).toEqual([served, served]);
|
||||||
|
// The panel variant (the Images side panel) keeps the concept's footer: no summary action there.
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.goto(`${workspace}#/images`);
|
||||||
|
await expect(page.locator('#image-inspector .panel-heading')).toBeVisible();
|
||||||
|
await expect(page.locator('#image-inspector').getByRole('button', { name: 'Copy settings summary', exact: true })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Copy settings summary without a clipboard opens the select-to-copy dialog', async ({ page }) => {
|
||||||
|
await stubClipboard(page, { clipboard: false });
|
||||||
|
await openInspect(page);
|
||||||
|
await choose(page, sendablePng());
|
||||||
|
await openViewer(page);
|
||||||
|
const summary = await expectedSummary(rail(page));
|
||||||
|
expect(summary).toContain(`Positive Prompt: ${POSITIVE_LINES}`);
|
||||||
|
await copySummary(page);
|
||||||
|
const fallback = page.getByRole('dialog', { name: 'Copy settings summary', exact: true });
|
||||||
|
await expect(fallback).toBeVisible();
|
||||||
|
await expect(fallback.getByRole('textbox', { name: 'Select and copy this text', exact: true })).toHaveValue(summary);
|
||||||
|
await expect(fallback.getByRole('textbox', { name: 'Select and copy this text', exact: true })).toBeFocused();
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect(fallback).toHaveCount(0);
|
||||||
|
// Escape closes only the copy dialog; focus returns to the action in the still-open viewer.
|
||||||
|
await expect(viewer(page)).toBeVisible();
|
||||||
|
await expect(rail(page).getByRole('button', { name: 'Copy settings summary', exact: true })).toBeFocused();
|
||||||
|
});
|
||||||
|
|||||||
@@ -610,7 +610,9 @@ test('card matches the concept and never wraps the category mid-word',async({pag
|
|||||||
await expect(await request.post('/prompt_manager/bulk/category',{data:{prompt_ids:[dual.id],category:''}})).toBeOK();
|
await expect(await request.post('/prompt_manager/bulk/category',{data:{prompt_ids:[dual.id],category:''}})).toBeOK();
|
||||||
// A long saved title fills the title row, so only the chip's own min-content keeps "Uncategorized" and "asian" whole.
|
// A long saved title fills the title row, so only the chip's own min-content keeps "Uncategorized" and "asian" whole.
|
||||||
const wide='A remarkably long saved positive prompt title that fills the whole card title row';
|
const wide='A remarkably long saved positive prompt title that fills the whole card title row';
|
||||||
await patchShelf(page,item=>item.id===cat.id?{run_count:4,last_used_at:new Date().toISOString(),rating:5}:item.id===dual.id||item.id===dog.id?{title:wide,run_count:1,last_used_at:null,rating:null}:{});
|
// One "now" for the patched last use and the page's clock, so it reads "Used today" on either side of midnight.
|
||||||
|
const usedAt=new Date();await page.clock.setFixedTime(usedAt);
|
||||||
|
await patchShelf(page,item=>item.id===cat.id?{run_count:4,last_used_at:usedAt.toISOString(),rating:5}:item.id===dual.id||item.id===dog.id?{title:wide,run_count:1,last_used_at:null,rating:null}:{});
|
||||||
for(const [width,height] of [[1600,1100],[390,844]]) {
|
for(const [width,height] of [[1600,1100],[390,844]]) {
|
||||||
await page.setViewportSize({width,height});await page.goto(route);await expect(cards(page)).toHaveCount(3);
|
await page.setViewportSize({width,height});await page.goto(route);await expect(cards(page)).toHaveCount(3);
|
||||||
const card=page.locator(`article[data-prompt-id="${cat.id}"]`);
|
const card=page.locator(`article[data-prompt-id="${cat.id}"]`);
|
||||||
|
|||||||
@@ -340,6 +340,9 @@ test('Tag opens the dialog without selecting the card', async ({ page }) => {
|
|||||||
const panel = page.getByRole('complementary', { name: 'Selected image details', exact: true });
|
const panel = page.getByRole('complementary', { name: 'Selected image details', exact: true });
|
||||||
await page.goto(images);
|
await page.goto(images);
|
||||||
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
|
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
|
||||||
|
// Capture the panel once it shows the first card's details, not while it is still reading them.
|
||||||
|
await expect(panel.getByRole('heading', { level: 2 })).toBeVisible();
|
||||||
|
await expect(panel).not.toContainText('Loading file details');
|
||||||
const before = await panel.innerText();
|
const before = await panel.innerText();
|
||||||
await openTag(page, SECOND, { goto: false });
|
await openTag(page, SECOND, { goto: false });
|
||||||
await page.keyboard.press('Escape');
|
await page.keyboard.press('Escape');
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ the same sidebar counts (24 images, 6 prompts, 15 tags). Nothing is mocked.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
import datetime
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
@@ -157,6 +158,8 @@ CONCEPT_MODEL = "illustrative-model.safetensors"
|
|||||||
MAX_EXTRA_IMAGES = 120
|
MAX_EXTRA_IMAGES = 120
|
||||||
# Unlinked, seedless images (D11 "W × H · PNG") newer than the concept's, for D3 checks.
|
# Unlinked, seedless images (D11 "W × H · PNG") newer than the concept's, for D3 checks.
|
||||||
MAX_SEEDLESS_IMAGES = 2
|
MAX_SEEDLESS_IMAGES = 2
|
||||||
|
# A test's chosen "now" (epoch seconds) stays before the year 3000.
|
||||||
|
MAX_EPOCH_SECONDS = 32_503_680_000
|
||||||
SEEDLESS_SIZE = (1536, 1152)
|
SEEDLESS_SIZE = (1536, 1152)
|
||||||
# The same 4:3 shape with a short "96 × 72 · PNG" subtitle that stays on one line at
|
# The same 4:3 shape with a short "96 × 72 · PNG" subtitle that stays on one line at
|
||||||
# 390px beside the card's Info and Tag (the D3 footer check).
|
# 390px beside the card's Info and Tag (the D3 footer check).
|
||||||
@@ -342,6 +345,17 @@ def _bounded(name: str, value: int, maximum: int) -> None:
|
|||||||
raise ValueError(f"{name} is out of range")
|
raise ValueError(f"{name} is out of range")
|
||||||
|
|
||||||
|
|
||||||
|
def _instant(now: float | None) -> float:
|
||||||
|
"""The fixture's one "now": ``now`` itself, or the wall clock when omitted."""
|
||||||
|
if now is None:
|
||||||
|
return time.time()
|
||||||
|
if isinstance(now, bool) or not isinstance(now, (int, float)):
|
||||||
|
raise TypeError("now must be a number of epoch seconds")
|
||||||
|
if not 0 < now < MAX_EPOCH_SECONDS:
|
||||||
|
raise ValueError("now is out of range")
|
||||||
|
return float(now)
|
||||||
|
|
||||||
|
|
||||||
def seed_workspace_concept(
|
def seed_workspace_concept(
|
||||||
api: PromptManagerAPI,
|
api: PromptManagerAPI,
|
||||||
root: Path,
|
root: Path,
|
||||||
@@ -349,9 +363,16 @@ def seed_workspace_concept(
|
|||||||
extra_images: int = 0,
|
extra_images: int = 0,
|
||||||
seedless_images: int = 0,
|
seedless_images: int = 0,
|
||||||
short_seedless: bool = False,
|
short_seedless: bool = False,
|
||||||
|
now: float | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Replace this server's disposable rows and files with the concept's data.
|
"""Replace this server's disposable rows and files with the concept's data.
|
||||||
|
|
||||||
|
Every relative time the fixture writes (the prompts' last use, the files'
|
||||||
|
mtimes, the runs' observation times) is taken from one instant, ``now``
|
||||||
|
(epoch seconds, the wall clock when omitted), returned as ``manifest["now"]``.
|
||||||
|
A browser whose clock is fixed at that instant reads the concept's "Used
|
||||||
|
today" and caption day however close to midnight the run starts.
|
||||||
|
|
||||||
``extra_images`` continues the concept's rounds (same prompts, dimensions and
|
``extra_images`` continues the concept's rounds (same prompts, dimensions and
|
||||||
seed rule) after its 24 images, for bounded-rendering and Load more checks.
|
seed rule) after its 24 images, for bounded-rendering and Load more checks.
|
||||||
``seedless_images`` adds unlinked PNGs without metadata, newest of all;
|
``seedless_images`` adds unlinked PNGs without metadata, newest of all;
|
||||||
@@ -361,6 +382,10 @@ def seed_workspace_concept(
|
|||||||
raise TypeError("short_seedless must be a boolean")
|
raise TypeError("short_seedless must be a boolean")
|
||||||
_bounded("extra_images", extra_images, MAX_EXTRA_IMAGES)
|
_bounded("extra_images", extra_images, MAX_EXTRA_IMAGES)
|
||||||
_bounded("seedless_images", seedless_images, MAX_SEEDLESS_IMAGES)
|
_bounded("seedless_images", seedless_images, MAX_SEEDLESS_IMAGES)
|
||||||
|
now = _instant(now)
|
||||||
|
used_at = datetime.datetime.fromtimestamp(now, datetime.timezone.utc).isoformat(
|
||||||
|
timespec="microseconds"
|
||||||
|
)
|
||||||
_clear(api, root)
|
_clear(api, root)
|
||||||
db = api.db
|
db = api.db
|
||||||
epoch = db.get_execution_epoch()
|
epoch = db.get_execution_epoch()
|
||||||
@@ -385,8 +410,15 @@ def seed_workspace_concept(
|
|||||||
db.update_prompt_pair_metadata(pair_ids[0], title=first_name)
|
db.update_prompt_pair_metadata(pair_ids[0], title=first_name)
|
||||||
for name, text in prompt.negatives[1:]:
|
for name, text in prompt.negatives[1:]:
|
||||||
pair_ids.append(db.save_prompt_pair(prompt.positive, text, title=name))
|
pair_ids.append(db.save_prompt_pair(prompt.positive, text, title=name))
|
||||||
# Real queue-hook counting (D23): four runs, the last one now.
|
# Real queue-hook counting (D23): four runs, the last one now, at the
|
||||||
|
# fixture's own instant rather than the database's later wall clock.
|
||||||
db.record_prompt_use(positive_id, times=CONCEPT_RUN_COUNT)
|
db.record_prompt_use(positive_id, times=CONCEPT_RUN_COUNT)
|
||||||
|
with db.model.get_connection() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE prompts SET last_used_at = ? WHERE id = ?",
|
||||||
|
(used_at, positive_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
prompts.insert(
|
prompts.insert(
|
||||||
0, {"id": prompt.id, "positive_id": positive_id, "pair_ids": pair_ids}
|
0, {"id": prompt.id, "positive_id": positive_id, "pair_ids": pair_ids}
|
||||||
)
|
)
|
||||||
@@ -400,7 +432,6 @@ def seed_workspace_concept(
|
|||||||
favorite_root = _favorite_root_key(list(api._get_all_output_dirs())[0])
|
favorite_root = _favorite_root_key(list(api._get_all_output_dirs())[0])
|
||||||
total = len(CONCEPT_PROMPTS) * CONCEPT_IMAGE_ROUNDS + extra_images
|
total = len(CONCEPT_PROMPTS) * CONCEPT_IMAGE_ROUNDS + extra_images
|
||||||
rounds = math.ceil(total / len(CONCEPT_PROMPTS))
|
rounds = math.ceil(total / len(CONCEPT_PROMPTS))
|
||||||
now = time.time()
|
|
||||||
images, runs = [], []
|
images, runs = [], []
|
||||||
slots = [
|
slots = [
|
||||||
(round_index, prompt_index, prompt)
|
(round_index, prompt_index, prompt)
|
||||||
@@ -484,6 +515,8 @@ def seed_workspace_concept(
|
|||||||
api.invalidate_gallery_cache()
|
api.invalidate_gallery_cache()
|
||||||
return {
|
return {
|
||||||
"epoch": epoch,
|
"epoch": epoch,
|
||||||
|
# The fixture's "now" (epoch seconds), for the browser's fixed clock.
|
||||||
|
"now": now,
|
||||||
# The newest file's mtime (epoch seconds), for the D17 caption date.
|
# The newest file's mtime (epoch seconds), for the D17 caption date.
|
||||||
"newest_modified": now - 10 if seedless_images else now - 60,
|
"newest_modified": now - 10 if seedless_images else now - 60,
|
||||||
"seedless": seedless,
|
"seedless": seedless,
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -14,6 +14,11 @@
|
|||||||
// the carrier rule alone, as the server selects from ffprobe's tag map.
|
// the carrier rule alone, as the server selects from ffprobe's tag map.
|
||||||
// A {records} entry (records null = an unreadable file) answers
|
// A {records} entry (records null = an unreadable file) answers
|
||||||
// {classified: null, result: null, metadata} for the projection alone.
|
// {classified: null, result: null, metadata} for the projection alone.
|
||||||
|
// A {mode: 'resolve'} entry (PR L3) answers {evidence}: resolveLocalEvidence of its {records} texts
|
||||||
|
// with the repository semantics table. With a {path} it reads the file locally and also answers
|
||||||
|
// {facts} (and {unreadable: true} with the server's unreadable evidence when nothing could be read):
|
||||||
|
// extractGenerationFacts on the local payload through projectInspectionPayload, exactly as
|
||||||
|
// tests/js/support/inspection-parity-facts.mjs reads the server's response for vectors.json.
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { createRequire } from 'node:module';
|
import { createRequire } from 'node:module';
|
||||||
@@ -28,6 +33,22 @@ const web = path.join(here, '..', '..', '..', 'web', 'js', 'workspace');
|
|||||||
const { selectVideoCarrier } = await import(moduleUrl(path.join(web, 'local-video.mjs')));
|
const { selectVideoCarrier } = await import(moduleUrl(path.join(web, 'local-video.mjs')));
|
||||||
const { projectMetadata } = await import(moduleUrl(path.join(web, 'local-projection.mjs')));
|
const { projectMetadata } = await import(moduleUrl(path.join(web, 'local-projection.mjs')));
|
||||||
const { readLocalFile, toInspectionRead } = await import(moduleUrl(path.join(web, 'local-inspection.mjs')));
|
const { readLocalFile, toInspectionRead } = await import(moduleUrl(path.join(web, 'local-inspection.mjs')));
|
||||||
|
const { resolveLocalEvidence, compileSemantics, unreadableEvidence } = await import(moduleUrl(path.join(web, 'local-resolver.mjs')));
|
||||||
|
const { buildLocalPayload } = await import(moduleUrl(path.join(web, 'local-inspection.mjs')));
|
||||||
|
const { projectInspectionPayload } = await import(moduleUrl(path.join(web, 'inspection-state.mjs')));
|
||||||
|
const semantics = compileSemantics(JSON.parse(fs.readFileSync(path.join(here, '..', '..', '..', 'web', 'js', 'inspection-semantics.json'), 'utf8')));
|
||||||
|
const { extractGenerationFacts } = require(path.join(here, '..', '..', '..', 'web', 'js', 'comfy-metadata.js'));
|
||||||
|
|
||||||
|
/** The local evidence for one set of record texts. */
|
||||||
|
const resolved = records => ({ evidence: resolveLocalEvidence(records, semantics) });
|
||||||
|
|
||||||
|
/** The local wire's evidence and six facts for one read (no legacy fill, as in the vectors' own harness). */
|
||||||
|
function localPayload(read, file, classification) {
|
||||||
|
const payload = buildLocalPayload({ read, file, classification, semantics });
|
||||||
|
const record = projectInspectionPayload(payload, { kind: 'local-file', key: 'local:1', path: file.name }, null, extractGenerationFacts);
|
||||||
|
const facts = Object.fromEntries(Object.entries(record.generation).map(([field, fact]) => [field, { status: fact.status, value: fact.value }]));
|
||||||
|
return { evidence: { prompts: payload.prompts, resolution: payload.resolution }, facts };
|
||||||
|
}
|
||||||
|
|
||||||
const selectionRead = (selection) => selection.status === 'complete' ?
|
const selectionRead = (selection) => selection.status === 'complete' ?
|
||||||
{ records: selection.records, record_formats: selection.record_formats, carrierText: selection.carrierText, limited: selection.limited } : null;
|
{ records: selection.records, record_formats: selection.record_formats, carrierText: selection.carrierText, limited: selection.limited } : null;
|
||||||
@@ -37,6 +58,19 @@ for await (const chunk of process.stdin) chunks.push(chunk);
|
|||||||
const output = {};
|
const output = {};
|
||||||
for (const entry of JSON.parse(Buffer.concat(chunks).toString('utf8'))) {
|
for (const entry of JSON.parse(Buffer.concat(chunks).toString('utf8'))) {
|
||||||
const { name } = entry;
|
const { name } = entry;
|
||||||
|
if (entry.mode === 'resolve') {
|
||||||
|
if (entry.records !== undefined) { output[name] = resolved(entry.records); continue; }
|
||||||
|
const blob = await fs.openAsBlob(entry.path);
|
||||||
|
const { classification, result } = await readLocalFile(blob, { pngMetadata });
|
||||||
|
const read = classification === null ? null : toInspectionRead(classification, result).read;
|
||||||
|
if (classification === null) { output[name] = { unreadable: true }; continue; }
|
||||||
|
// The evidence is the local wire's own (buildLocalPayload), and it equals resolveLocalEvidence of the records.
|
||||||
|
const local = localPayload(result, { name: path.basename(entry.path), size: blob.size }, classification);
|
||||||
|
const direct = read === null ? unreadableEvidence() : resolved(read.records).evidence;
|
||||||
|
if (JSON.stringify(direct) !== JSON.stringify(local.evidence)) throw new Error(`${name}: the wire's roles are not the resolver's`);
|
||||||
|
output[name] = read === null ? { unreadable: true, ...local } : local;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (entry.records !== undefined) {
|
if (entry.records !== undefined) {
|
||||||
const read = entry.records === null ? null : {
|
const read = entry.records === null ? null : {
|
||||||
records: entry.records,
|
records: entry.records,
|
||||||
|
|||||||
@@ -215,3 +215,151 @@ test('none and unavailable render no image chips', async () => {
|
|||||||
assert.deepEqual(inspectorTagChips(tagged({state, tags:[]}, {status:'none', tags:[], tags_scope:null}), {filterable:true}), []);
|
assert.deepEqual(inspectorTagChips(tagged({state, tags:[]}, {status:'none', tags:[], tags_scope:null}), {filterable:true}), []);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// LI-4 (D117): "Copy settings summary" after the six facts of the full inspector consolidates the legacy metadata
|
||||||
|
// page's COPY ALL (web/metadata.html copyAllData). The legacy template is read from that page itself and run on the
|
||||||
|
// legacy parse, so the summary's text is the legacy page's, line for line, wherever the two agree on the values.
|
||||||
|
const METADATA_PAGE = fs.readFileSync(new URL('../../web/metadata.html', import.meta.url), 'utf8');
|
||||||
|
function legacyCopyAll(params) {
|
||||||
|
const template = METADATA_PAGE.match(/const allData = `([\s\S]*?)`;/)[1];
|
||||||
|
return new Function('currentWorkflowData', `return \`${template}\`;`)(params);
|
||||||
|
}
|
||||||
|
const legacyParams = record => ComfyMetadata.extractGenerationParams({prompt:record.metadata.prompt_graph.status === 'parsed' ? record.metadata.prompt_graph.value : null,
|
||||||
|
workflow:record.metadata.workflow_graph.status === 'parsed' ? record.metadata.workflow_graph.value : null});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Each summary line against the legacy page's, allowing only the recorded differences (D117): a number keeps the
|
||||||
|
* inspector's (the server's) spelling ("8.0" where the legacy page wrote 8); a fact the legacy parse left "Unknown" may
|
||||||
|
* be known; and a negative the #249 ZeroOut guard zeroed reads "No negative prompt found" where the legacy parse read
|
||||||
|
* the positive's text through the zero-out.
|
||||||
|
*/
|
||||||
|
function assertLegacySummary(summary, legacy, record) {
|
||||||
|
const lines = summary.split('\n'), expected = legacy.split('\n');
|
||||||
|
assert.deepEqual(lines.map(line => line.split(':')[0]), expected.map(line => line.split(':')[0]), 'the legacy labels, in order');
|
||||||
|
lines.forEach((line, index) => {
|
||||||
|
if (line === expected[index]) return;
|
||||||
|
const [label, value] = [line.slice(0, line.indexOf(': ')), line.slice(line.indexOf(': ') + 2)];
|
||||||
|
const old = expected[index].slice(label.length + 2);
|
||||||
|
if (label === 'Negative Prompt' && record.prompts.negative?.source === 'zeroed-conditioning' && value === 'No negative prompt found') return;
|
||||||
|
if (old === 'Unknown' && !/Prompt$/.test(label)) return;
|
||||||
|
assert.ok(value.trim() !== '' && Number(value) === Number(old), `${label}: "${value}" differs from the legacy "${old}"`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('settings summary is the legacy COPY ALL text for a local file', async () => {
|
||||||
|
const {settingsSummary} = await load();
|
||||||
|
assert.equal(typeof settingsSummary, 'function', 'settingsSummary is exported');
|
||||||
|
const record = await localRecord(localPng('fox.png'));
|
||||||
|
assertLegacySummary(settingsSummary(record), legacyCopyAll(legacyParams(record)), record);
|
||||||
|
assert.equal(settingsSummary(record), ['Checkpoint: synthetic-v1.safetensors', 'Positive Prompt: a red fox in snow, detailed fur',
|
||||||
|
'Negative Prompt: blurry, lowres', 'Steps: 20', 'CFG Scale: 8.0', 'Sampler: euler', 'Seed: 7'].join('\n'));
|
||||||
|
// Every parity PNG read in this browser: the legacy page's text but for the recorded differences, and exactly the
|
||||||
|
// legacy text for most of them (f09 among them: the server is ambiguous, so the record shows the legacy fill).
|
||||||
|
const exact = [];
|
||||||
|
for (const name of fs.readdirSync(PARITY).filter(item => item.endsWith('.png')).sort()) {
|
||||||
|
const parity = await localRecord(new File([fs.readFileSync(new URL(name, PARITY))], name));
|
||||||
|
const summary = settingsSummary(parity), legacy = legacyCopyAll(legacyParams(parity));
|
||||||
|
assertLegacySummary(summary, legacy, parity);
|
||||||
|
if (summary === legacy) exact.push(name);
|
||||||
|
}
|
||||||
|
assert.ok(exact.includes('f09.png'));
|
||||||
|
assert.deepEqual(fs.readdirSync(PARITY).filter(item => item.endsWith('.png') && !exact.includes(item)).sort(), ['f01b.png', 'f17.png', 'f23.png']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('settings summary uses the legacy placeholders for what the record does not know', async () => {
|
||||||
|
const {settingsSummary} = await load();
|
||||||
|
const unknown = {...record(), prompts:{positive:{status:'unresolved'}, negative:{status:'ambiguous'}}, generation:{}};
|
||||||
|
assert.equal(settingsSummary(unknown), legacyCopyAll({}).replace('Positive Prompt: undefined', 'Positive Prompt: No prompt found')
|
||||||
|
.replace('Negative Prompt: undefined', 'Negative Prompt: No negative prompt found'));
|
||||||
|
assert.equal(settingsSummary(unknown), ['Checkpoint: Unknown', 'Positive Prompt: No prompt found', 'Negative Prompt: No negative prompt found',
|
||||||
|
'Steps: Unknown', 'CFG Scale: Unknown', 'Sampler: Unknown', 'Seed: Unknown'].join('\n'));
|
||||||
|
// A server answer is shown as the inspector shows it: known facts verbatim, every other state the legacy "Unknown".
|
||||||
|
const server = {...record(), prompts:{positive:{status:'resolved', text:'a\n猫'}, negative:{status:'resolved', text:''}}, generation:{
|
||||||
|
model:{status:'known', value:'m.safetensors'}, steps:{status:'not-recorded', value:null}, cfg:{status:'known', value:'6.50'},
|
||||||
|
sampler:{status:'ambiguous', value:null}, seed:{status:'known', value:'9007199254740993'}, scheduler:{status:'known', value:'karras'}}};
|
||||||
|
assert.equal(settingsSummary(server), ['Checkpoint: m.safetensors', 'Positive Prompt: a\n猫', 'Negative Prompt: No negative prompt found',
|
||||||
|
'Steps: Unknown', 'CFG Scale: 6.50', 'Sampler: Unknown', 'Seed: 9007199254740993'].join('\n'));
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The full inspector with a toast node and `clipboard` as `navigator.clipboard`; returns the summary button. */
|
||||||
|
function renderSummary(record, {variant = 'full', clipboard} = {}) {
|
||||||
|
const f = mountedDom(), scope = createScope(), fallbacks = [], toast = new f.Node('div');
|
||||||
|
f.doc.getElementById = id => id === 'workspace-toast' ? toast : null;
|
||||||
|
const previous = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
|
||||||
|
Object.defineProperty(globalThis, 'navigator', {configurable:true, value:clipboard === undefined ? {} : {clipboard}});
|
||||||
|
const view = mountFileInspector(f.container, {sender:{open() {}}, parentScope:scope, variant, copyFallback:(text, options) => fallbacks.push({text, ...options})});
|
||||||
|
view.setState({loading:false, error:false, record});
|
||||||
|
const button = f.walk(f.container, n => n.tagName === 'button' && n.textContent === 'Copy settings summary')[0] ?? null;
|
||||||
|
return {f, button, toast, fallbacks, scope, done() {
|
||||||
|
view.dispose(); scope.dispose(); f.restore();
|
||||||
|
if (previous) Object.defineProperty(globalThis, 'navigator', previous); else delete globalThis.navigator;
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Copy settings summary follows the facts in the full inspector only', async () => {
|
||||||
|
const full = renderSummary(record());
|
||||||
|
try {
|
||||||
|
assert.ok(full.button, 'the full inspector offers Copy settings summary');
|
||||||
|
const inner = full.button.parent, index = inner.children.indexOf(full.button);
|
||||||
|
assert.equal(inner.children[index - 1].tagName, 'dl', 'it follows the six facts');
|
||||||
|
assert.equal(inner.children[index + 1].tagName, 'details', 'File & provenance follows it');
|
||||||
|
// M7: the D117 wrap rule targets this button by its class, never any button of a details block.
|
||||||
|
assert.equal(full.button.className, 'settings-summary');
|
||||||
|
} finally { full.done(); }
|
||||||
|
const panel = renderSummary(record(), {variant:'panel'});
|
||||||
|
try { assert.equal(panel.button, null, 'the Images side panel keeps the concept footer'); } finally { panel.done(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Copy settings summary copies the summary and shows the shared toast', async () => {
|
||||||
|
const {settingsSummary} = await load(), copied = [];
|
||||||
|
const view = renderSummary(record(), {clipboard:{writeText:async text => {copied.push(text);}}});
|
||||||
|
try {
|
||||||
|
await view.button.listeners.get('click')[0].callback({target:view.button});
|
||||||
|
assert.deepEqual(copied, [settingsSummary(record())]);
|
||||||
|
assert.equal(view.toast.textContent, 'Settings summary copied.');
|
||||||
|
assert.equal(view.fallbacks.length, 0);
|
||||||
|
} finally { view.done(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Copy settings summary without a clipboard opens the select-to-copy fallback', async () => {
|
||||||
|
const {settingsSummary} = await load();
|
||||||
|
for (const clipboard of [undefined, {writeText:async () => {throw new Error('Denied');}}]) {
|
||||||
|
const view = renderSummary(record(), {clipboard});
|
||||||
|
try {
|
||||||
|
await view.button.listeners.get('click')[0].callback({target:view.button});
|
||||||
|
assert.equal(view.fallbacks.length, 1);
|
||||||
|
assert.equal(view.fallbacks[0].text, settingsSummary(record()));
|
||||||
|
assert.equal(view.fallbacks[0].title, 'Copy settings summary');
|
||||||
|
assert.equal(view.fallbacks[0].trigger, view.button);
|
||||||
|
assert.notEqual(view.toast.textContent, 'Settings summary copied.');
|
||||||
|
} finally { view.done(); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the inspector is busy while a read is loading, then not', async () => {
|
||||||
|
const f = mountedDom(), scope = createScope();
|
||||||
|
const view = mountFileInspector(f.container, {sender:{open() {}}, parentScope:scope, variant:'full'});
|
||||||
|
try {
|
||||||
|
view.setState({loading:true, error:false, record:null});
|
||||||
|
assert.equal(f.container.getAttribute('aria-busy'), 'true');
|
||||||
|
assert.ok(f.walk(f.container, n => n.textContent === 'Loading file details…').length);
|
||||||
|
view.setState({loading:false, error:false, record:record()});
|
||||||
|
assert.equal(f.container.getAttribute('aria-busy'), null);
|
||||||
|
view.setState({loading:true, error:false, record:null});
|
||||||
|
view.setState({loading:false, error:true, record:null});
|
||||||
|
assert.equal(f.container.getAttribute('aria-busy'), null);
|
||||||
|
view.setState({loading:true, error:false, record:null});
|
||||||
|
} finally {
|
||||||
|
view.dispose();
|
||||||
|
assert.equal(f.container.getAttribute('aria-busy'), null, 'dispose clears the busy state');
|
||||||
|
scope.dispose(); f.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the D117 wrap rule targets only the settings summary button', async () => {
|
||||||
|
const fs = await import('node:fs');
|
||||||
|
const css = fs.readFileSync(new URL('../../web/js/workspace/workspace.css', import.meta.url), 'utf8');
|
||||||
|
const rules = css.split('\n').filter(line => line.includes('D117'));
|
||||||
|
assert.deepEqual(rules.map(line => line.trim()), ['.ca-tools #viewer-details .settings-summary { white-space: normal; /* D117 */ }']);
|
||||||
|
assert.doesNotMatch(css, /#viewer-details > div > button/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -20,6 +20,16 @@ const { extractGenerationFacts } = ComfyMetadata;
|
|||||||
// The page loads both as scripts; the legacy fill and the PNG reader find them on the global.
|
// The page loads both as scripts; the legacy fill and the PNG reader find them on the global.
|
||||||
globalThis.PngMetadata = PngMetadata;
|
globalThis.PngMetadata = PngMetadata;
|
||||||
globalThis.ComfyMetadata = ComfyMetadata;
|
globalThis.ComfyMetadata = ComfyMetadata;
|
||||||
|
// The page's API client and the node table it serves (local-semantics.mjs fetches it once).
|
||||||
|
const SEMANTICS = fs.readFileSync(path.join(here, "..", "..", "web", "js", "inspection-semantics.json"), "utf8");
|
||||||
|
globalThis.window = { ApiClient: { resolveUrl: (value) => value } };
|
||||||
|
// A test can hold the table request (`tableGate`) to stand for a slow or stalled proxy.
|
||||||
|
let tableGate = null;
|
||||||
|
globalThis.fetch = async (url) => {
|
||||||
|
if (url !== "/prompt_manager/js/inspection-semantics.json") throw new Error(`unexpected request ${url}`);
|
||||||
|
if (tableGate) await tableGate;
|
||||||
|
return new Response(SEMANTICS, { status: 200 });
|
||||||
|
};
|
||||||
const m = await import("../../web/js/workspace/local-inspection.mjs").catch((error) => {
|
const m = await import("../../web/js/workspace/local-inspection.mjs").catch((error) => {
|
||||||
if (error.code !== "ERR_MODULE_NOT_FOUND") throw error;
|
if (error.code !== "ERR_MODULE_NOT_FOUND") throw error;
|
||||||
return {};
|
return {};
|
||||||
@@ -59,6 +69,37 @@ test("contract", () => {
|
|||||||
assert.ok(Object.isFrozen(m.localFileAdapter));
|
assert.ok(Object.isFrozen(m.localFileAdapter));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// M3: the first read waits for the page's one table request; an aborted, replaced or cleared selection must not.
|
||||||
|
test("a read waiting for the node table ends with its selection", async () => {
|
||||||
|
let release;
|
||||||
|
tableGate = new Promise((resolve) => { release = resolve; });
|
||||||
|
try {
|
||||||
|
const file = pngFile("slow.png", [tEXt("prompt", PROMPT)]);
|
||||||
|
const aborted = new AbortController();
|
||||||
|
const reading = m.localFileAdapter.read(await handleFor(file), { signal: aborted.signal });
|
||||||
|
const settled = (promise) => Promise.race([promise.then(() => "resolved", (error) => error.name), new Promise((resolve) => setTimeout(() => resolve("pending"), 200))]);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
aborted.abort();
|
||||||
|
assert.equal(await settled(reading), "AbortError");
|
||||||
|
// Through the controller: clearing the selection ends its read; nothing is published for it.
|
||||||
|
const controller = createInspectionController({ api: null, adapter: m.localFileAdapter, extractFacts: extractGenerationFacts });
|
||||||
|
const states = [];
|
||||||
|
controller.subscribe((state) => states.push(state));
|
||||||
|
const selected = controller.select(await handleFor(file));
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
controller.clear();
|
||||||
|
assert.equal(await settled(selected), "resolved");
|
||||||
|
assert.ok(states.every((state) => state.record === null && state.error === false), "no record or error for a cleared selection");
|
||||||
|
controller.dispose();
|
||||||
|
} finally {
|
||||||
|
tableGate = null;
|
||||||
|
release();
|
||||||
|
}
|
||||||
|
// The shared request still lands for the next selection.
|
||||||
|
const { wire } = await recordFor(pngFile("next.png", [tEXt("prompt", PROMPT)]));
|
||||||
|
assert.equal(wire.resolution.state, "resolved");
|
||||||
|
});
|
||||||
|
|
||||||
test("a PNG File becomes the server-shaped wire with local provenance", async () => {
|
test("a PNG File becomes the server-shaped wire with local provenance", async () => {
|
||||||
const file = pngFile("Fox Render.PNG", [tEXt("prompt", PROMPT), tEXt("workflow", WORKFLOW)]);
|
const file = pngFile("Fox Render.PNG", [tEXt("prompt", PROMPT), tEXt("workflow", WORKFLOW)]);
|
||||||
const { wire, record, handle } = await recordFor(file);
|
const { wire, record, handle } = await recordFor(file);
|
||||||
@@ -66,8 +107,12 @@ test("a PNG File becomes the server-shaped wire with local provenance", async ()
|
|||||||
size: file.size, width: 640, height: 480, modified_time: 1_700_000_000.123 });
|
size: file.size, width: 640, height: 480, modified_time: 1_700_000_000.123 });
|
||||||
assert.equal(wire.success, true);
|
assert.equal(wire.success, true);
|
||||||
assert.equal(wire.inspection_version, 1);
|
assert.equal(wire.inspection_version, 1);
|
||||||
assert.deepEqual(wire.prompts, { positive: { status: "unresolved" }, negative: { status: "unresolved" } });
|
// The roles the server's inspector resolves for the same records (local-resolver.mjs).
|
||||||
assert.deepEqual(wire.resolution, { state: "unresolved", sampler_id: null, reason: "Resolved in this browser from the workflow.", reason_code: null, node: null });
|
assert.deepEqual(wire.prompts, {
|
||||||
|
positive: { status: "resolved", text: "a red fox in snow, detailed fur", source: "graph-inferred" },
|
||||||
|
negative: { status: "resolved", text: "blurry, lowres", source: "graph-inferred" },
|
||||||
|
});
|
||||||
|
assert.deepEqual(wire.resolution, { state: "resolved", sampler_id: "3", reason: "", reason_code: null, node: null });
|
||||||
assert.deepEqual(wire.library, { status: "unavailable", scope: "saved-library", tags: [], category_scope: null, tags_scope: null });
|
assert.deepEqual(wire.library, { status: "unavailable", scope: "saved-library", tags: [], category_scope: null, tags_scope: null });
|
||||||
assert.equal(wire.metadata.status, "available");
|
assert.equal(wire.metadata.status, "available");
|
||||||
assert.deepEqual(wire.metadata.raw, { prompt: PROMPT, workflow: WORKFLOW });
|
assert.deepEqual(wire.metadata.raw, { prompt: PROMPT, workflow: WORKFLOW });
|
||||||
@@ -96,6 +141,33 @@ test("a PNG File becomes the server-shaped wire with local provenance", async ()
|
|||||||
assert.equal(exported.file.source_label, "Selected local file");
|
assert.equal(exported.file.source_label, "Selected local file");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("roles answer as the server's inspector: unreadable, unavailable table, ambiguous", async () => {
|
||||||
|
const png = { media: "image", kind: "png" };
|
||||||
|
const unreadable = m.buildLocalPayload({ read: null, file: { name: "a.png" }, classification: png });
|
||||||
|
assert.deepEqual(unreadable.resolution, { state: "unresolved", sampler_id: null, reason: "Media metadata could not be read", reason_code: null, node: null });
|
||||||
|
assert.equal(unreadable.prompts.positive.message, "Metadata in this file could not be read.");
|
||||||
|
const records = { prompt: PROMPT };
|
||||||
|
const read = { status: "complete", records, limited: [], width: 2, height: 3 };
|
||||||
|
// Without a table (it could not be fetched) the roles stay open, with the server's own line.
|
||||||
|
const missing = m.buildLocalPayload({ read, file: { name: "a.png" }, classification: png });
|
||||||
|
assert.equal(missing.resolution.reason, "Inspection node semantics are unavailable");
|
||||||
|
assert.equal(missing.prompts.positive.message, "The inspector's node table is unavailable.");
|
||||||
|
const semantics = await (await import("../../web/js/workspace/local-semantics.mjs")).loadInspectionSemantics();
|
||||||
|
const graph = JSON.parse(PROMPT);
|
||||||
|
graph["9"] = { ...graph["3"], inputs: { ...graph["3"].inputs, positive: ["8", 0] } };
|
||||||
|
graph["8"] = { class_type: "CLIPTextEncode", inputs: { text: "a cat", clip: ["4", 1] } };
|
||||||
|
const several = m.buildLocalPayload({ read: { ...read, records: { prompt: JSON.stringify(graph) } }, file: { name: "a.png" }, classification: png, semantics });
|
||||||
|
assert.equal(several.resolution.reason, "Multiple sampler branches");
|
||||||
|
assert.deepEqual(several.prompts.positive, { status: "ambiguous", text: null, source: null });
|
||||||
|
// The legacy fill then applies exactly as it does to the server's answer for the same bytes in Images.
|
||||||
|
const handle = await handleFor(pngFile("grid.png", [tEXt("prompt", JSON.stringify(graph))]));
|
||||||
|
const local = m.localFileAdapter.toRecord(several, handle, extractGenerationFacts);
|
||||||
|
const asServed = { ...several, file: { ...several.file, observation: "current-file", source_label: "output" } };
|
||||||
|
const served = projectInspectionPayload(asServed, { kind: "rooted-file", key: "0:grid.png", root_index: 0, path: "grid.png", root_scope: null }, null, extractGenerationFacts);
|
||||||
|
assert.deepEqual(local.prompts, served.prompts);
|
||||||
|
assert.deepEqual(local.generation, served.generation);
|
||||||
|
});
|
||||||
|
|
||||||
test("file facts follow the server: extension like Path.suffix, null when unknown", async () => {
|
test("file facts follow the server: extension like Path.suffix, null when unknown", async () => {
|
||||||
const cases = [["a.b.PnG", ".png"], [".hidden", ""], ["noext", ""], ["dot.", ""], ["x.tar.gz", ".gz"]];
|
const cases = [["a.b.PnG", ".png"], [".hidden", ""], ["noext", ""], ["dot.", ""], ["x.tar.gz", ".gz"]];
|
||||||
for (const [name, extension] of cases) {
|
for (const [name, extension] of cases) {
|
||||||
@@ -307,3 +379,89 @@ test("the parity corpus PNGs read into renderable records", async () => {
|
|||||||
assert.ok(["parsed", "limited", "malformed"].includes(wire.metadata.prompt_graph.status));
|
assert.ok(["parsed", "limited", "malformed"].includes(wire.metadata.prompt_graph.status));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// M4: resolution runs on the page's main thread, so a read yields between its bounded steps (the busy state paints
|
||||||
|
// and input is handled) and no step may exceed a budget on the largest record the readers keep (2 MiB of roles).
|
||||||
|
const ROLE_BYTES = 2 * 1024 * 1024;
|
||||||
|
// Generous for a shared CI runner: a desktop resolves these in 130-455 ms.
|
||||||
|
const STEP_BUDGET_MS = 2500;
|
||||||
|
const ENCODE = (text) => ({ class_type: "CLIPTextEncode", inputs: { text, clip: ["4", 1] } });
|
||||||
|
const KSAMPLER = (positive, negative, latent) => ({ class_type: "KSampler", inputs: { seed: 1, steps: 20, cfg: 8.0, sampler_name: "euler", scheduler: "normal",
|
||||||
|
denoise: 1.0, model: ["4", 0], positive, negative, latent_image: latent } });
|
||||||
|
const LOADERS = { "4": { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "m.safetensors" } }, "5": { class_type: "EmptyLatentImage", inputs: { width: 64, height: 64, batch_size: 1 } } };
|
||||||
|
/** The largest graph of this shape whose JSON fits the role budget. */
|
||||||
|
function maximal(build, high) {
|
||||||
|
let low = 1;
|
||||||
|
while (low < high) {
|
||||||
|
const middle = (low + high + 1) >> 1;
|
||||||
|
if (Buffer.byteLength(JSON.stringify(build(middle)), "utf8") <= ROLE_BYTES) low = middle;
|
||||||
|
else high = middle - 1;
|
||||||
|
}
|
||||||
|
return JSON.stringify(build(low));
|
||||||
|
}
|
||||||
|
const MAXIMAL_GRAPHS = {
|
||||||
|
// Many candidates: every sampler is a final stage with its own pair.
|
||||||
|
independent: maximal((count) => {
|
||||||
|
const graph = { ...LOADERS };
|
||||||
|
for (let index = 0; index < count; index++) {
|
||||||
|
graph[`s${index}`] = KSAMPLER([`p${index}`, 0], [`n${index}`, 0], ["5", 0]);
|
||||||
|
graph[`p${index}`] = ENCODE(`fox ${index}`);
|
||||||
|
graph[`n${index}`] = ENCODE(`blur ${index}`);
|
||||||
|
}
|
||||||
|
return graph;
|
||||||
|
}, 6666),
|
||||||
|
// One long stage chain through the latent: the deepest stage walk.
|
||||||
|
chain: maximal((count) => {
|
||||||
|
const graph = { ...LOADERS, "6": ENCODE("fox"), "7": ENCODE("blur") };
|
||||||
|
for (let index = 0; index < count; index++) graph[`s${index}`] = KSAMPLER(["6", 0], ["7", 0], index ? [`s${index - 1}`, 0] : ["5", 0]);
|
||||||
|
return graph;
|
||||||
|
}, 19996),
|
||||||
|
};
|
||||||
|
|
||||||
|
test("resolution of a maximal 2 MiB graph stays within the step budget", async () => {
|
||||||
|
const { resolveLocalEvidence } = await import("../../web/js/workspace/local-resolver.mjs");
|
||||||
|
const semantics = await (await import("../../web/js/workspace/local-semantics.mjs")).loadInspectionSemantics();
|
||||||
|
for (const [name, prompt] of Object.entries(MAXIMAL_GRAPHS)) {
|
||||||
|
assert.ok(Buffer.byteLength(prompt, "utf8") > ROLE_BYTES - 1024, `${name} fills the role budget`);
|
||||||
|
const started = performance.now();
|
||||||
|
const evidence = resolveLocalEvidence({ prompt }, semantics);
|
||||||
|
const elapsed = performance.now() - started;
|
||||||
|
assert.ok(elapsed < STEP_BUDGET_MS, `${name}: ${Math.round(elapsed)} ms`);
|
||||||
|
assert.notEqual(evidence.resolution.reason_code, "malformed", name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a read yields between bounded steps and answers byte for byte as one synchronous build", async () => {
|
||||||
|
const semantics = await (await import("../../web/js/workspace/local-semantics.mjs")).loadInspectionSemantics();
|
||||||
|
const file = pngFile("maximal.png", [tEXt("prompt", MAXIMAL_GRAPHS.independent)]);
|
||||||
|
const handle = await handleFor(file);
|
||||||
|
const marks = [];
|
||||||
|
const yieldControl = async (signal) => {
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
marks.push(performance.now());
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
};
|
||||||
|
const started = performance.now();
|
||||||
|
const wire = await m.localFileAdapter.read(handle, { signal: new AbortController().signal, yieldControl });
|
||||||
|
marks.push(performance.now());
|
||||||
|
// Resolution and the projection each run in a turn of their own after the table is in.
|
||||||
|
assert.ok(marks.length >= 3, `yields: ${marks.length - 1}`);
|
||||||
|
const gaps = marks.map((mark, index) => mark - (index ? marks[index - 1] : started));
|
||||||
|
assert.ok(Math.max(...gaps) < STEP_BUDGET_MS, `longest step ${Math.round(Math.max(...gaps))} ms`);
|
||||||
|
const { result } = await m.readLocalFile(file, { classification: handle.classification });
|
||||||
|
const direct = await m.fitLocalPayload(m.buildLocalPayload({ read: result, file: { name: handle.filename, size: file.size, lastModified: file.lastModified },
|
||||||
|
classification: handle.classification, semantics }));
|
||||||
|
assert.equal(JSON.stringify(wire), JSON.stringify(direct));
|
||||||
|
assert.equal(wire.resolution.state, "ambiguous");
|
||||||
|
// An abort at any yield ends the read there.
|
||||||
|
for (let stop = 0; stop < marks.length - 1; stop++) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
let seen = 0;
|
||||||
|
const stopping = async (signal) => {
|
||||||
|
if (seen++ === stop) controller.abort();
|
||||||
|
signal.throwIfAborted();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
};
|
||||||
|
await assert.rejects(m.localFileAdapter.read(handle, { signal: controller.signal, yieldControl: stopping }), { name: "AbortError" }, `yield ${stop}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
// PR L3 Tasks 8-9: the server's inspection resolver in this browser. The values below are what CPython answers
|
||||||
|
// (json.loads, len, str.strip, repr, utils/prompt_inspector.inspect_prompt_evidence); the full comparison with the
|
||||||
|
// server runs in tests/test_local_inspect_parity.py through tests/js/support/local-inspect-oracle.mjs.
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
|
||||||
|
const py = await import('../../web/js/workspace/py-json.mjs');
|
||||||
|
const resolver = await import('../../web/js/workspace/local-resolver.mjs');
|
||||||
|
const semantics = resolver.compileSemantics(JSON.parse(fs.readFileSync(new URL('../../web/js/inspection-semantics.json', import.meta.url), 'utf8')));
|
||||||
|
|
||||||
|
test('json.loads keeps Python value kinds and dict order', () => {
|
||||||
|
const value = py.pyJsonLoads('{"9": 1, "10": 1.0, "a": NaN, "9": [true, -0, 9007199254740993, -Infinity]}');
|
||||||
|
assert.ok(value instanceof Map);
|
||||||
|
// A repeated key keeps its first position and takes its last value; integer-like keys keep text order.
|
||||||
|
assert.deepEqual([...value.keys()], ['9', '10', 'a']);
|
||||||
|
const [flag, zero, big, negative] = value.get('9');
|
||||||
|
assert.equal(flag, true); assert.equal(zero, 0); assert.equal(big, 9007199254740993n);
|
||||||
|
assert.ok(negative instanceof py.PyFloat); assert.equal(negative.value, -Infinity);
|
||||||
|
assert.ok(value.get('10') instanceof py.PyFloat, '1.0 is a float, not an int');
|
||||||
|
assert.ok(Number.isNaN(value.get('a').value));
|
||||||
|
assert.equal(py.isInt(value.get('10')), false);
|
||||||
|
assert.equal(py.pyJsonLoads('"\\ud83d\\ude00\\ud800"'), '\u{1f600}\ud800');
|
||||||
|
for (const bad of ['{}', '{"a":1,}', '[01]', '{"a" 1}', '"\u0001"', '"\\x"', '1 2', '-', `${'9'.repeat(4301)}`, '"\\ud800\\uzzzz"']) {
|
||||||
|
assert.throws(() => py.pyJsonLoads(bad), py.PyValueError, bad);
|
||||||
|
}
|
||||||
|
assert.equal(py.pyJsonLoads(` ${'9'.repeat(4300)}\n`), BigInt('9'.repeat(4300)));
|
||||||
|
});
|
||||||
|
|
||||||
|
// One depth cap for both browser CPython-JSON parsers (py-json.mjs here, local-video.mjs for video envelopes): every
|
||||||
|
// array or object counts, an empty one too, as CPython's scanner enters recursion for each; past the cap it refuses.
|
||||||
|
test('json.loads refuses nesting past the shared depth cap, counting every container', () => {
|
||||||
|
const video = import('../../web/js/workspace/local-video.mjs');
|
||||||
|
assert.equal(py.JSON_MAX_DEPTH, 1000);
|
||||||
|
const nest = (depth, inner = '') => '['.repeat(depth) + inner + ']'.repeat(depth);
|
||||||
|
const objects = (depth, inner) => '{"a":'.repeat(depth) + inner + '}'.repeat(depth);
|
||||||
|
for (const text of [nest(1000), nest(1000, '1'), nest(999, '{}'), objects(1000, '1'), objects(999, '[]')]) assert.doesNotThrow(() => py.pyJsonLoads(text));
|
||||||
|
for (const text of [nest(1001), nest(1001, '1'), nest(1000, '{}'), objects(1001, '1'), objects(1000, '[]')]) {
|
||||||
|
assert.throws(() => py.pyJsonLoads(text), py.PyValueError);
|
||||||
|
}
|
||||||
|
return video.then(({ pythonJsonLoads, PythonJsonError }) => {
|
||||||
|
for (const text of [nest(1000), nest(1000, '1'), objects(999, '[]')]) assert.doesNotThrow(() => pythonJsonLoads(text));
|
||||||
|
for (const text of [nest(1001), nest(1001, '1'), objects(1000, '[]')]) {
|
||||||
|
assert.throws(() => pythonJsonLoads(text), error => error instanceof PythonJsonError && error.recursion === true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('str helpers measure, slice and strip as CPython', () => {
|
||||||
|
assert.equal(py.pyLen('a\u{1f600}\ud800'), 3);
|
||||||
|
assert.equal(py.pySlice('\u{1f600}\u{1f600}x', 2), '\u{1f600}\u{1f600}');
|
||||||
|
// str.isspace() includes \x1c-\x1f and \x85 and excludes , unlike String.prototype.trim.
|
||||||
|
assert.equal(py.pyStrip('\x1c\x85 a b \x1f'), 'a b');
|
||||||
|
assert.equal(py.pyStrip('a'), 'a');
|
||||||
|
assert.deepEqual([1.0, 0.0001, 1e-5, 1e16, 123456789012345.6, -2.5, 1e22].map(py.pyFloatRepr),
|
||||||
|
['1.0', '0.0001', '1e-05', '1e+16', '123456789012345.6', '-2.5', '1e+22']);
|
||||||
|
assert.deepEqual([null, true, 0, 1n, new py.PyFloat(2), '', [], new Map()].map(py.pyTruthy), [false, true, false, true, true, false, false, false]);
|
||||||
|
assert.deepEqual([1, 1n, true, new py.PyFloat(1), '1', 2].map(py.pyEqualsOne), [true, true, true, true, false, false]);
|
||||||
|
});
|
||||||
|
|
||||||
|
const encode = text => ({class_type: 'CLIPTextEncode', inputs: {text, clip: ['4', 1]}});
|
||||||
|
const sampler = (extra = {}) => ({class_type: 'KSampler', inputs: {seed: 1, positive: ['6', 0], negative: ['7', 0], latent_image: ['5', 0], model: ['4', 0], ...extra}});
|
||||||
|
const base = (nodes = {}) => ({'3': sampler(), '4': {class_type: 'CheckpointLoaderSimple', inputs: {ckpt_name: 'm.safetensors'}},
|
||||||
|
'5': {class_type: 'EmptyLatentImage', inputs: {width: 64, height: 64, batch_size: 1}}, '6': encode('a fox'), '7': encode('blurry'), ...nodes});
|
||||||
|
|
||||||
|
test('a single chain resolves both roles with their sources', () => {
|
||||||
|
const evidence = resolver.resolveLocalEvidence({prompt: JSON.stringify(base())}, semantics);
|
||||||
|
assert.deepEqual(evidence, {
|
||||||
|
prompts: {positive: {status: 'resolved', text: 'a fox', source: 'graph-inferred'}, negative: {status: 'resolved', text: 'blurry', source: 'graph-inferred'}},
|
||||||
|
resolution: {state: 'resolved', sampler_id: '3', reason: '', reason_code: null, node: null},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('records without roles, malformed text and a missing sampler answer as the server', () => {
|
||||||
|
assert.deepEqual(resolver.resolveLocalEvidence({workflow: '{}'}, semantics).resolution,
|
||||||
|
{state: 'absent', sampler_id: null, reason: 'No parsed prompt metadata', reason_code: null, node: null});
|
||||||
|
const malformed = resolver.resolveLocalEvidence({prompt: '{broken'}, semantics);
|
||||||
|
assert.equal(malformed.resolution.reason, 'Prompt evidence is malformed');
|
||||||
|
assert.equal(malformed.prompts.positive.message, 'Prompt metadata in this file is malformed.');
|
||||||
|
const none = resolver.resolveLocalEvidence({prompt: JSON.stringify({'1': encode('x'), '2': {class_type: 'MySampler', inputs: {positive: ['1', 0]}}})}, semantics);
|
||||||
|
assert.deepEqual(none.resolution, {state: 'unresolved', sampler_id: null, reason: 'No supported sampler pair found (saw MySampler)', reason_code: 'no-sampler', node: null});
|
||||||
|
assert.equal(none.prompts.negative.message, 'No supported sampler was found.');
|
||||||
|
assert.equal(resolver.resolveLocalEvidence({prompt: JSON.stringify(base()), parameters: 'x'}, null).resolution.reason, 'Inspection node semantics are unavailable');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a float output slot is not a link, and a runtime node is named', () => {
|
||||||
|
const floatSlot = resolver.resolveLocalEvidence({prompt: JSON.stringify(base()).replace('"positive":["6",0]', '"positive":["6",0.0]')}, semantics);
|
||||||
|
assert.deepEqual(floatSlot.resolution, {state: 'unresolved', sampler_id: '3', reason: 'Expected a typed graph link or text input', reason_code: 'malformed', node: null});
|
||||||
|
const runtime = resolver.resolveLocalEvidence({prompt: JSON.stringify(base({'6': {class_type: 'PromptSearchList', inputs: {}}}))}, semantics);
|
||||||
|
assert.deepEqual(runtime.resolution.node, {id: '6', class_type: 'PromptSearchList'});
|
||||||
|
assert.equal(runtime.prompts.positive.message, 'Prompt is chosen at run time (PromptSearchList).');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('execution evidence replaces a PromptManagerPair text when it matches the node inputs', () => {
|
||||||
|
const pair = {class_type: 'PromptManagerPair', inputs: {text: 'fox', negative_text: 'blur', prepend_text: '', append_text: ''}};
|
||||||
|
const record = {text: 'fox', negative_text: 'blur', positive_text: 'fox, executed', negative_effective_text: 'blur', prepend_text: '', append_text: ''};
|
||||||
|
const graph = base({'3': sampler({positive: ['8', 0], negative: ['8', 1]}), '8': pair});
|
||||||
|
const evidence = resolver.resolveLocalEvidence({prompt: JSON.stringify(graph), comfyassets_prompt_pairs: JSON.stringify({version: 1, nodes: {'8': {records: [record]}}})}, semantics);
|
||||||
|
assert.deepEqual(evidence.prompts.positive, {status: 'resolved', text: 'fox, executed', source: 'execution'});
|
||||||
|
const stale = resolver.resolveLocalEvidence({prompt: JSON.stringify(graph), comfyassets_prompt_pairs: JSON.stringify({version: 1, nodes: {'8': {records: [{...record, text: 'other'}]}}})}, semantics);
|
||||||
|
assert.equal(stale.resolution.reason, 'Execution evidence does not match recorded node inputs');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('identical sampler branches resolve to their shared pair, differing ones stay ambiguous (#270)', () => {
|
||||||
|
const shared = resolver.resolveLocalEvidence({prompt: JSON.stringify(base({'9': sampler()}))}, semantics);
|
||||||
|
assert.deepEqual(shared.resolution, {state: 'resolved', sampler_id: null, reason: 'Every sampler branch uses the same prompt pair', reason_code: null, node: null});
|
||||||
|
assert.equal(shared.prompts.positive.text, 'a fox');
|
||||||
|
const differ = resolver.resolveLocalEvidence({prompt: JSON.stringify(base({'9': sampler({positive: ['8', 0]}), '8': encode('a cat')}))}, semantics);
|
||||||
|
assert.deepEqual(differ.resolution, {state: 'ambiguous', sampler_id: null, reason: 'Multiple sampler branches', reason_code: null, node: null});
|
||||||
|
assert.equal(differ.prompts.positive.status, 'ambiguous');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a detailer stage is compared with the sampler in its image ancestry', () => {
|
||||||
|
const graph = base({'10': {class_type: 'VAEDecode', inputs: {samples: ['3', 0]}}, '8': encode('a cat'), '12': {class_type: 'SaveImage', inputs: {images: ['11', 0]}},
|
||||||
|
'11': {class_type: 'FaceDetailer', inputs: {image: ['10', 0], model: ['4', 0], positive: ['8', 0], negative: ['7', 0]}}});
|
||||||
|
const evidence = resolver.resolveLocalEvidence({prompt: JSON.stringify(graph)}, semantics);
|
||||||
|
assert.deepEqual(evidence.resolution, {state: 'ambiguous', sampler_id: '11', reason: 'Sampler stages have different prompts', reason_code: 'multistage-differs', node: null});
|
||||||
|
assert.equal(evidence.prompts.positive.message, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('A1111 parameters split on the unique settings footer as Python does', () => {
|
||||||
|
const read = text => resolver.resolveLocalEvidence({parameters: text}, semantics);
|
||||||
|
assert.deepEqual(read('a fox\nNegative prompt: blur\nSteps: 20, Sampler: euler').prompts, {
|
||||||
|
positive: {status: 'resolved', text: 'a fox', source: 'embedded-parameters'}, negative: {status: 'resolved', text: 'blur', source: 'embedded-parameters'},
|
||||||
|
});
|
||||||
|
// Python's multiline ^ and $ break only at \n: a U+2028 or a lone \r never starts the footer line.
|
||||||
|
for (const text of ['a fox\u2028Steps: 20, x', 'a fox\rSteps: 20, x']) assert.equal(read(text).resolution.reason, 'A1111 settings footer is not unique');
|
||||||
|
// \d is any Unicode decimal digit and \s spans line breaks, as in Python's re.
|
||||||
|
assert.equal(read('a fox\nSteps: \u0662\u0660, x').resolution.reason, 'Negative prompt delimiter is absent');
|
||||||
|
assert.equal(read('a fox\nSteps:\n 20 \n, x').resolution.state, 'absent');
|
||||||
|
assert.equal(read('a fox\nSteps: 1, x\nNegative Template: n\nTemplate: t').resolution.reason_code, 'malformed');
|
||||||
|
assert.equal(read('\u{1f600}'.repeat(60000) + '\nSteps: 1, x').resolution.state, 'absent', 'the length limit counts code points');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unusable semantics table is refused', () => {
|
||||||
|
for (const doc of [null, {schema: 2}, {schema: 1, samplers: []}]) assert.throws(() => resolver.compileSemantics(doc), resolver.SemanticsError);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The committed server answers (tests/fixtures/local-inspect/resolver-vectors.json, written by
|
||||||
|
// `python3 -m tests.local_resolver_cases --write` and checked current by the Python suite).
|
||||||
|
test('committed server answers equal the local evidence', () => {
|
||||||
|
const {vectors} = JSON.parse(fs.readFileSync(new URL('../fixtures/local-inspect/resolver-vectors.json', import.meta.url), 'utf8'));
|
||||||
|
const canonical = value => Array.isArray(value) ? value.map(canonical) :
|
||||||
|
value !== null && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value;
|
||||||
|
let compared = 0;
|
||||||
|
for (const {name, records, evidence} of vectors) {
|
||||||
|
assert.deepEqual(canonical(resolver.resolveLocalEvidence(records, semantics)), canonical(evidence), name);
|
||||||
|
compared++;
|
||||||
|
}
|
||||||
|
assert.ok(compared > 280, `compared ${compared}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a list or object workflow node type is skipped, as on the server (#286)', () => {
|
||||||
|
const graph = base({'6': {class_type: 'PromptManager', inputs: {text: 'a fox', clip: ['4', 1]}}});
|
||||||
|
for (const type of [['PromptManager'], {name: 'PromptManager'}]) {
|
||||||
|
const evidence = resolver.resolveLocalEvidence({prompt: JSON.stringify(graph), workflow: JSON.stringify({nodes: [{id: 6, type, widgets_values: ['x']}]})}, semantics);
|
||||||
|
assert.equal(evidence.resolution.state, 'resolved');
|
||||||
|
assert.equal(evidence.prompts.positive.status, 'resolved');
|
||||||
|
assert.equal(evidence.prompts.positive.text, 'a fox');
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// web/js/workspace/local-semantics.mjs (#249 local Inspect, PR L3 Task 10): the node table local Inspect resolves
|
||||||
|
// with is the server's own web/js/inspection-semantics.json, fetched once through the API client before a file is
|
||||||
|
// chosen. A table that cannot be fetched or used answers as the server does when its table is unusable.
|
||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs";
|
||||||
|
|
||||||
|
const TABLE = fs.readFileSync(new URL("../../web/js/inspection-semantics.json", import.meta.url), "utf8");
|
||||||
|
let instance = 0;
|
||||||
|
/** A fresh module instance (its cache is per page) with `fetch` and the API client stubbed. */
|
||||||
|
async function load(respond) {
|
||||||
|
const calls = [];
|
||||||
|
globalThis.window = { ApiClient: { resolveUrl: (path) => `/proxy${path}` } };
|
||||||
|
globalThis.fetch = async (url, init) => { calls.push([url, init]); return respond(); };
|
||||||
|
const module = await import(`../../web/js/workspace/local-semantics.mjs?instance=${++instance}`);
|
||||||
|
return { module, calls };
|
||||||
|
}
|
||||||
|
test.after(() => { delete globalThis.window; delete globalThis.fetch; });
|
||||||
|
|
||||||
|
test("the table is fetched once through the API client and compiled", async () => {
|
||||||
|
const { module, calls } = await load(() => new Response(TABLE, { status: 200 }));
|
||||||
|
const [first, second] = await Promise.all([module.loadInspectionSemantics(), module.loadInspectionSemantics()]);
|
||||||
|
assert.equal(first, second);
|
||||||
|
assert.ok(first.samplers.has("KSampler"));
|
||||||
|
assert.equal(await module.loadInspectionSemantics(), first);
|
||||||
|
assert.deepEqual(calls, [["/proxy/prompt_manager/js/inspection-semantics.json", { credentials: "same-origin" }]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a failed or unusable table is null, and a later selection asks again", async () => {
|
||||||
|
for (const respond of [() => new Response("nope", { status: 404 }), () => new Response("{\"schema\": 2}", { status: 200 }), () => { throw new TypeError("offline"); }]) {
|
||||||
|
const { module, calls } = await load(respond);
|
||||||
|
assert.equal(await module.loadInspectionSemantics(), null);
|
||||||
|
assert.equal(await module.loadInspectionSemantics(), null);
|
||||||
|
assert.equal(calls.length, 2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("without the API client nothing is fetched", async () => {
|
||||||
|
const { module, calls } = await load(() => new Response(TABLE));
|
||||||
|
delete globalThis.window;
|
||||||
|
assert.equal(await module.loadInspectionSemantics(), null);
|
||||||
|
assert.deepEqual(calls, []);
|
||||||
|
});
|
||||||
@@ -193,8 +193,8 @@ test("unclassified input creates no object URL", async () => {
|
|||||||
// declares a supported type it opens the inspector's unreadable state, with no preview and no URL (ledger ruling).
|
// declares a supported type it opens the inspector's unreadable state, with no preview and no URL (ledger ruling).
|
||||||
const gone = pngFile("gone.png", "fox", { gate: () => Promise.reject(new DOMException("The file was removed", "NotReadableError")) });
|
const gone = pngFile("gone.png", "fox", { gate: () => Promise.reject(new DOMException("The file was removed", "NotReadableError")) });
|
||||||
assert.equal(value.select([gone]), "accepted");
|
assert.equal(value.select([gone]), "accepted");
|
||||||
await settle();
|
// The read yields between its steps (local-inspection.mjs): wait for the answer, not a fixed number of turns.
|
||||||
const unreadable = value.controller.state;
|
const unreadable = await recorded(value.controller);
|
||||||
assert.ok(unreadable.record, "the unreadable record is published");
|
assert.ok(unreadable.record, "the unreadable record is published");
|
||||||
assert.deepEqual([value.state.phase, value.state.message, value.state.preview], ["ready", m.LOCAL_SESSION_MESSAGES.unreadable, null]);
|
assert.deepEqual([value.state.phase, value.state.message, value.state.preview], ["ready", m.LOCAL_SESSION_MESSAGES.unreadable, null]);
|
||||||
assert.equal(m.LOCAL_SESSION_MESSAGES.unreadable, "Embedded metadata could not be read.");
|
assert.equal(m.LOCAL_SESSION_MESSAGES.unreadable, "Embedded metadata could not be read.");
|
||||||
|
|||||||
@@ -521,6 +521,10 @@ test('copy fallback selects the text', async () => {
|
|||||||
assert.equal(note.className, 'small');
|
assert.equal(note.className, 'small');
|
||||||
assert.equal(note.textContent, 'Automatic clipboard access requires a secure browser context.');
|
assert.equal(note.textContent, 'Automatic clipboard access requires a secure browser context.');
|
||||||
assert.throws(() => openCopyFallback(42, { scope }), TypeError);
|
assert.throws(() => openCopyFallback(42, { scope }), TypeError);
|
||||||
|
// LI-4 (D117): the settings summary names its own dialog; the select-to-copy body is the same.
|
||||||
|
openCopyFallback('Seed: 7', { scope, title: 'Copy settings summary' });
|
||||||
|
assert.equal(byId(dom, 'modal-title').textContent, 'Copy settings summary');
|
||||||
|
assert.equal(byId(dom, 'copy-text').value, 'Seed: 7');
|
||||||
} finally {
|
} finally {
|
||||||
closeWorkspaceDialog();
|
closeWorkspaceDialog();
|
||||||
scope.dispose();
|
scope.dispose();
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,574 @@
|
|||||||
|
"""Candidate-selection record sets for the local resolver parity test (PR L3 Task 9).
|
||||||
|
|
||||||
|
``local_resolver_cases`` covers one sampler's tracing. These cases reach the
|
||||||
|
rest of ``_resolve``: several sampler candidates and the identical-branch rule
|
||||||
|
(#270), stages that only reach previews, detailer stages and their upstream
|
||||||
|
samplers (multistage), and A1111 ``parameters`` with every footer shape. The
|
||||||
|
server's ``inspect_prompt_evidence`` is the oracle; nothing here states an
|
||||||
|
expected answer. All content is synthetic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
|
||||||
|
try:
|
||||||
|
from tests.local_resolver_cases import EXECUTION_KEY, SPACES, WORDS, _Builder
|
||||||
|
except ImportError: # pragma: no cover - discover -s tests puts tests/ first
|
||||||
|
from local_resolver_cases import EXECUTION_KEY, SPACES, WORDS, _Builder
|
||||||
|
|
||||||
|
|
||||||
|
def _encode(text):
|
||||||
|
return {"class_type": "CLIPTextEncode", "inputs": {"text": text, "clip": ["4", 1]}}
|
||||||
|
|
||||||
|
|
||||||
|
def _sampler(positive, negative, latent=None, name="KSampler"):
|
||||||
|
inputs = {"seed": 1, "model": ["4", 0], "positive": positive}
|
||||||
|
if negative is not None:
|
||||||
|
inputs["negative"] = negative
|
||||||
|
inputs["latent_image"] = latent or ["5", 0]
|
||||||
|
return {"class_type": name, "inputs": inputs}
|
||||||
|
|
||||||
|
|
||||||
|
def _decode(sampler_id):
|
||||||
|
return {"class_type": "VAEDecode", "inputs": {"samples": [sampler_id, 0]}}
|
||||||
|
|
||||||
|
|
||||||
|
def _save(image, name="SaveImage"):
|
||||||
|
return {"class_type": name, "inputs": {"images": image}}
|
||||||
|
|
||||||
|
|
||||||
|
def _detailer(image, positive, negative, name="FaceDetailer", **extra):
|
||||||
|
inputs = {"image": image, "model": ["4", 0], "positive": positive}
|
||||||
|
inputs["negative"] = negative
|
||||||
|
inputs.update(extra)
|
||||||
|
return {"class_type": name, "inputs": inputs}
|
||||||
|
|
||||||
|
|
||||||
|
def _base(**nodes):
|
||||||
|
graph = {
|
||||||
|
"4": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "m"}},
|
||||||
|
"5": {"class_type": "EmptyLatentImage", "inputs": {"width": 64}},
|
||||||
|
"6": _encode("a fox"),
|
||||||
|
"7": _encode("blurry"),
|
||||||
|
"8": _encode("a cat"),
|
||||||
|
}
|
||||||
|
graph.update(nodes)
|
||||||
|
return graph
|
||||||
|
|
||||||
|
|
||||||
|
def _samplers():
|
||||||
|
"""Several unanchored samplers: identical, differing and mixed branches."""
|
||||||
|
same = _sampler(["6", 0], ["7", 0])
|
||||||
|
cases = {
|
||||||
|
"two-identical": _base(**{"3": same, "9": same}),
|
||||||
|
"two-differ": _base(**{"3": same, "9": _sampler(["8", 0], ["7", 0])}),
|
||||||
|
"two-negative-differs": _base(**{"3": same, "9": _sampler(["6", 0], ["8", 0])}),
|
||||||
|
"identical-without-negative": _base(
|
||||||
|
**{"3": _sampler(["6", 0], None), "9": _sampler(["6", 0], None)}
|
||||||
|
),
|
||||||
|
"one-unresolved": _base(**{"3": same, "9": _sampler(["missing", 0], ["7", 0])}),
|
||||||
|
"both-unresolved": _base(
|
||||||
|
**{"3": _sampler(["x", 0], ["7", 0]), "9": _sampler(["y", 0], ["7", 0])}
|
||||||
|
),
|
||||||
|
"three-identical-one-advanced": _base(
|
||||||
|
**{
|
||||||
|
"3": same,
|
||||||
|
"9": same,
|
||||||
|
"10": _sampler(["6", 0], ["7", 0], name="KSamplerAdvanced"),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
# Equal texts, different recipes: a PromptManager pair against an encoder.
|
||||||
|
"same-text-other-recipe": _base(
|
||||||
|
**{
|
||||||
|
"3": same,
|
||||||
|
"9": _sampler(["11", 0], ["7", 0]),
|
||||||
|
"11": {
|
||||||
|
"class_type": "PromptManager",
|
||||||
|
"inputs": {
|
||||||
|
"text": "fox",
|
||||||
|
"prepend_text": "a",
|
||||||
|
"append_text": "",
|
||||||
|
"clip": ["4", 1],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"multistage-latent": _base(
|
||||||
|
**{"3": same, "9": _sampler(["6", 0], ["7", 0], latent=["3", 0])}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
selector = {
|
||||||
|
"class_type": "easy ifElse",
|
||||||
|
"inputs": {"boolean": ["4", 0], "on_true": ["6", 0], "on_false": ["8", 0]},
|
||||||
|
}
|
||||||
|
cases["selector-and-plain"] = _base(
|
||||||
|
**{"3": same, "9": _sampler(["12", 0], ["7", 0]), "12": selector}
|
||||||
|
)
|
||||||
|
return cases
|
||||||
|
|
||||||
|
|
||||||
|
def _previews():
|
||||||
|
"""Stages whose outputs reach only previews are not the saved file's."""
|
||||||
|
same, other = _sampler(["6", 0], ["7", 0]), _sampler(["8", 0], ["7", 0])
|
||||||
|
return {
|
||||||
|
"preview-dropped": _base(
|
||||||
|
**{
|
||||||
|
"3": same,
|
||||||
|
"9": other,
|
||||||
|
"13": _decode("3"),
|
||||||
|
"14": _decode("9"),
|
||||||
|
"15": _save(["13", 0]),
|
||||||
|
"16": _save(["14", 0], "PreviewImage"),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"all-previews-kept": _base(
|
||||||
|
**{
|
||||||
|
"3": same,
|
||||||
|
"9": other,
|
||||||
|
"13": _decode("3"),
|
||||||
|
"14": _decode("9"),
|
||||||
|
"15": _save(["13", 0], "PreviewImage"),
|
||||||
|
"16": _save(["14", 0], "Image Comparer (rgthree)"),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"preview-and-file": _base(
|
||||||
|
**{
|
||||||
|
"3": same,
|
||||||
|
"9": other,
|
||||||
|
"13": _decode("3"),
|
||||||
|
"14": _decode("9"),
|
||||||
|
"15": _save(["13", 0]),
|
||||||
|
"16": _save(["14", 0], "PreviewImage"),
|
||||||
|
"17": _save(["14", 0]),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"unconsumed-stage-kept": _base(
|
||||||
|
**{
|
||||||
|
"3": same,
|
||||||
|
"9": other,
|
||||||
|
"14": _decode("9"),
|
||||||
|
"16": _save(["14", 0], "PreviewImage"),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
# A list or object class_type is never a preview class (#286).
|
||||||
|
"preview-unhashable-class": _base(
|
||||||
|
**{
|
||||||
|
"3": same,
|
||||||
|
"9": other,
|
||||||
|
"14": _decode("9"),
|
||||||
|
"16": {"class_type": ["PreviewImage"], "inputs": {"images": ["14", 0]}},
|
||||||
|
"17": {"class_type": {"a": 1}, "inputs": {"images": ["3", 0]}},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"preview-through-bad-node": _base(
|
||||||
|
**{
|
||||||
|
"3": same,
|
||||||
|
"9": other,
|
||||||
|
"13": _decode("3"),
|
||||||
|
"15": _save(["13", 0]),
|
||||||
|
"16": _save(["9", 0], "PreviewImage"),
|
||||||
|
"18": "not a node",
|
||||||
|
"19": {"class_type": "Note", "inputs": {"x": ["18", 0]}},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _detailers():
|
||||||
|
"""Detailer stages judged with the samplers in their image ancestry."""
|
||||||
|
same = _sampler(["6", 0], ["7", 0])
|
||||||
|
cases = {
|
||||||
|
"detailer-same-prompt": _base(
|
||||||
|
**{
|
||||||
|
"3": same,
|
||||||
|
"10": _decode("3"),
|
||||||
|
"11": _detailer(["10", 0], ["6", 0], ["7", 0]),
|
||||||
|
"12": _save(["11", 0]),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"detailer-differs": _base(
|
||||||
|
**{
|
||||||
|
"3": same,
|
||||||
|
"10": _decode("3"),
|
||||||
|
"11": _detailer(["10", 0], ["8", 0], ["7", 0]),
|
||||||
|
"12": _save(["11", 0]),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"detailer-and-own-output": _base(
|
||||||
|
**{
|
||||||
|
"3": same,
|
||||||
|
"10": _decode("3"),
|
||||||
|
"11": _detailer(["10", 0], ["8", 0], ["7", 0]),
|
||||||
|
"12": _save(["11", 0]),
|
||||||
|
"13": _save(["10", 0]),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"detailer-image-literal": _base(
|
||||||
|
**{
|
||||||
|
"11": _detailer("not a link", ["6", 0], ["7", 0]),
|
||||||
|
"12": _save(["11", 0]),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"detailer-cycle": _base(
|
||||||
|
**{
|
||||||
|
"11": _detailer(["10", 0], ["6", 0], ["7", 0]),
|
||||||
|
"10": {"class_type": "ImageScale", "inputs": {"image": ["11", 0]}},
|
||||||
|
"12": _save(["11", 0]),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"detailer-chain": _base(
|
||||||
|
**{
|
||||||
|
"3": same,
|
||||||
|
"10": _decode("3"),
|
||||||
|
"11": _detailer(["10", 0], ["6", 0], ["7", 0]),
|
||||||
|
"21": _detailer(
|
||||||
|
["11", 0], ["8", 0], ["7", 0], name="UltimateSDUpscale"
|
||||||
|
),
|
||||||
|
"12": _save(["21", 0]),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"detailer-chain-same": _base(
|
||||||
|
**{
|
||||||
|
"3": same,
|
||||||
|
"10": _decode("3"),
|
||||||
|
"11": _detailer(["10", 0], ["6", 0], ["7", 0]),
|
||||||
|
"21": _detailer(["11", 0], ["6", 0], ["7", 0], name="DetailerForEach"),
|
||||||
|
"12": _save(["21", 0]),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"detailer-two-upstream": _base(
|
||||||
|
**{
|
||||||
|
"3": same,
|
||||||
|
"9": _sampler(["8", 0], ["7", 0]),
|
||||||
|
"10": {
|
||||||
|
"class_type": "ImageBlend",
|
||||||
|
"inputs": {"image1": ["13", 0], "image2": ["14", 0]},
|
||||||
|
},
|
||||||
|
"13": _decode("3"),
|
||||||
|
"14": _decode("9"),
|
||||||
|
"11": _detailer(["10", 0], ["6", 0], ["7", 0]),
|
||||||
|
"12": _save(["11", 0]),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"detailer-unsupported-stage": _base(
|
||||||
|
**{
|
||||||
|
"10": {
|
||||||
|
"class_type": "MysterySampler",
|
||||||
|
"inputs": {"positive": ["6", 0], "model": ["4", 0]},
|
||||||
|
},
|
||||||
|
"11": _detailer(["10", 0], ["6", 0], ["7", 0]),
|
||||||
|
"12": _save(["11", 0]),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"detailer-upstream-unresolved": _base(
|
||||||
|
**{
|
||||||
|
"3": _sampler(["gone", 0], ["7", 0]),
|
||||||
|
"10": _decode("3"),
|
||||||
|
"11": _detailer(["10", 0], ["6", 0], ["7", 0]),
|
||||||
|
"12": _save(["11", 0]),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"detailer-upstream-selector": _base(
|
||||||
|
**{
|
||||||
|
"3": _sampler(["15", 0], ["7", 0]),
|
||||||
|
"15": {
|
||||||
|
"class_type": "easy ifElse",
|
||||||
|
"inputs": {
|
||||||
|
"boolean": ["4", 0],
|
||||||
|
"on_true": ["6", 0],
|
||||||
|
"on_false": ["8", 0],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"10": _decode("3"),
|
||||||
|
"11": _detailer(["10", 0], ["6", 0], ["7", 0]),
|
||||||
|
"12": _save(["11", 0]),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"detailer-wildcard": _base(
|
||||||
|
**{
|
||||||
|
"3": same,
|
||||||
|
"10": _decode("3"),
|
||||||
|
"11": _detailer(["10", 0], ["6", 0], ["7", 0], wildcard="[x|y]"),
|
||||||
|
"12": _save(["11", 0]),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"detailer-no-upscaled-image": _base(
|
||||||
|
**{
|
||||||
|
"3": same,
|
||||||
|
"10": _decode("3"),
|
||||||
|
"11": _detailer(
|
||||||
|
["10", 0], ["6", 0], ["7", 0], name="UltimateSDUpscaleNoUpscale"
|
||||||
|
),
|
||||||
|
"12": _save(["11", 0]),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"detailer-no-sampler-upstream": _base(
|
||||||
|
**{
|
||||||
|
"10": {"class_type": "LoadImage", "inputs": {"image": "a.png"}},
|
||||||
|
"11": _detailer(["10", 0], ["6", 0], ["7", 0]),
|
||||||
|
"12": _save(["11", 0]),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
deep = {"3": _sampler(["6", 0], ["7", 0]), "d0": _decode("3")}
|
||||||
|
for index in range(1, 70):
|
||||||
|
deep[f"d{index}"] = {
|
||||||
|
"class_type": "ImageScale",
|
||||||
|
"inputs": {"image": [f"d{index - 1}", 0]},
|
||||||
|
}
|
||||||
|
deep["11"] = _detailer(["d69", 0], ["6", 0], ["7", 0])
|
||||||
|
deep["12"] = _save(["11", 0])
|
||||||
|
cases["detailer-deep-ancestry"] = _base(**deep)
|
||||||
|
# More nested detailer stages than the depth bound.
|
||||||
|
stacked = {"3": _sampler(["6", 0], ["7", 0]), "s0": _decode("3")}
|
||||||
|
previous = "s0"
|
||||||
|
for index in range(1, 67):
|
||||||
|
stacked[f"s{index}"] = _detailer([previous, 0], ["6", 0], ["7", 0])
|
||||||
|
previous = f"s{index}"
|
||||||
|
stacked["12"] = _save([previous, 0])
|
||||||
|
cases["detailer-stacked-past-depth"] = _base(**stacked)
|
||||||
|
# Each detailer walks a wide shared image ancestry: the total visit budget
|
||||||
|
# runs out across stages before any candidate is read.
|
||||||
|
wide = {
|
||||||
|
f"l{index}": {"class_type": "LoadImage", "inputs": {}} for index in range(1500)
|
||||||
|
}
|
||||||
|
wide["hub"] = {
|
||||||
|
"class_type": "ImageBatchMany",
|
||||||
|
"inputs": {f"i{index}": [f"l{index}", 0] for index in range(1500)},
|
||||||
|
}
|
||||||
|
for index in range(15):
|
||||||
|
wide[f"w{index}"] = _detailer(["hub", 0], ["6", 0], ["7", 0])
|
||||||
|
wide[f"o{index}"] = _save([f"w{index}", 0])
|
||||||
|
cases["detailers-exhaust-total-visits"] = _base(**wide)
|
||||||
|
cases["detailer-mutual-cycle"] = _base(
|
||||||
|
**{
|
||||||
|
"11": _detailer(["21", 0], ["6", 0], ["7", 0]),
|
||||||
|
"21": _detailer(["11", 0], ["6", 0], ["7", 0]),
|
||||||
|
"12": _save(["21", 0]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# The sampler's output reaches a preview through more nodes than one
|
||||||
|
# detailer's allowance: the walk runs out, so the sampler is not absorbed.
|
||||||
|
long = {"3": _sampler(["6", 0], ["7", 0]), "10": _decode("3")}
|
||||||
|
previous = "10"
|
||||||
|
for index in range(2100):
|
||||||
|
long[f"c{index}"] = {
|
||||||
|
"class_type": "ImageScale",
|
||||||
|
"inputs": {"image": [previous, 0]},
|
||||||
|
}
|
||||||
|
previous = f"c{index}"
|
||||||
|
long["p"] = _save([previous, 0], "PreviewImage")
|
||||||
|
long["11"] = _detailer(["10", 0], ["6", 0], ["7", 0])
|
||||||
|
long["12"] = _save(["11", 0])
|
||||||
|
cases["detailer-output-walk-exhausted"] = _base(**long)
|
||||||
|
# Every detailer's ancestry walk has its own allowance: the first one is
|
||||||
|
# wide, and the second still absorbs the first.
|
||||||
|
chained = {"3": _sampler(["6", 0], ["7", 0]), "10": _decode("3")}
|
||||||
|
chained["h1"] = {
|
||||||
|
"class_type": "ImageBatchMany",
|
||||||
|
"inputs": {
|
||||||
|
"i": ["10", 0],
|
||||||
|
**{f"i{index}": [f"a{index}", 0] for index in range(1500)},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
chained["h2"] = {
|
||||||
|
"class_type": "ImageBatchMany",
|
||||||
|
"inputs": {
|
||||||
|
"i": ["11", 0],
|
||||||
|
**{f"i{index}": [f"b{index}", 0] for index in range(600)},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for index in range(1500):
|
||||||
|
chained[f"a{index}"] = {"class_type": "LoadImage", "inputs": {}}
|
||||||
|
for index in range(600):
|
||||||
|
chained[f"b{index}"] = {"class_type": "LoadImage", "inputs": {}}
|
||||||
|
chained["11"] = _detailer(["h1", 0], ["6", 0], ["7", 0])
|
||||||
|
chained["21"] = _detailer(["h2", 0], ["6", 0], ["7", 0])
|
||||||
|
chained["12"] = _save(["21", 0])
|
||||||
|
cases["detailers-each-reset"] = _base(**chained)
|
||||||
|
return cases
|
||||||
|
|
||||||
|
|
||||||
|
_FOOTER = "Steps: 20, Sampler: euler, CFG scale: 7"
|
||||||
|
|
||||||
|
|
||||||
|
def _parameters():
|
||||||
|
"""A1111 ``parameters`` texts: every footer shape and delimiter rule."""
|
||||||
|
texts = {
|
||||||
|
"basic": f"a fox\nNegative prompt: blur\n{_FOOTER}",
|
||||||
|
"no-negative": f"a fox\n{_FOOTER}",
|
||||||
|
"two-delimiters": f"a\nNegative prompt: b\nNegative prompt: c\n{_FOOTER}",
|
||||||
|
"leading-delimiter": f"Negative prompt: blur\n{_FOOTER}",
|
||||||
|
"blank-positive": f" \x85\nNegative prompt: blur\n{_FOOTER}",
|
||||||
|
"footer-not-last": f"a fox\n{_FOOTER}\nmore text",
|
||||||
|
"two-footers": f"a\n{_FOOTER}\n{_FOOTER}",
|
||||||
|
"bare-footer": "a fox\nNegative prompt: blur\nSteps: 20",
|
||||||
|
"bare-and-full": f"a fox\nSteps: 5\n{_FOOTER}",
|
||||||
|
"two-bare": "a\nSteps: 1\nSteps: 2",
|
||||||
|
"template": f"a fox\n{_FOOTER}\nTemplate: {{a|b}}",
|
||||||
|
"both-templates": f"a\n{_FOOTER}\nTemplate: x\nNegative Template: y",
|
||||||
|
"templates-reversed": f"a\n{_FOOTER}\nNegative Template: y\nTemplate: x",
|
||||||
|
"two-templates": f"a\n{_FOOTER}\nTemplate: x\nTemplate: y",
|
||||||
|
"template-multiline": f"a\n{_FOOTER}\nTemplate: x\nmore",
|
||||||
|
"crlf": f"a fox\r\nNegative prompt: blur\r\n{_FOOTER}\r\n",
|
||||||
|
"cr-only": f"a fox\rNegative prompt: blur\r{_FOOTER}",
|
||||||
|
"cr-template": f"a\n{_FOOTER}\rTemplate: x",
|
||||||
|
"line-separator": f"a fox
{_FOOTER}",
|
||||||
|
"next-line": f"a fox\x85{_FOOTER}",
|
||||||
|
"spaced-steps": "a fox\nSteps:\n 20 \n, x",
|
||||||
|
"steps-newline-bare": "a fox\nSteps:\n20\n",
|
||||||
|
"arabic-digits": "a fox\nSteps: ٢٠, Sampler: euler",
|
||||||
|
"fullwidth-digits": "a fox\nSteps: 2, x",
|
||||||
|
"no-digits": "a fox\nSteps: , x",
|
||||||
|
"trailing-spaces": f"a fox \nNegative prompt: blur \n{_FOOTER} \n \n",
|
||||||
|
"empty-negative": f"a fox\nNegative prompt:\n{_FOOTER}",
|
||||||
|
"astral-within-limit": "\U0001f600" * 60_000 + f"\n{_FOOTER}",
|
||||||
|
"over-limit": "x" * 100_001,
|
||||||
|
"at-limit": "x" * (100_000 - len(_FOOTER) - 1) + "\n" + _FOOTER,
|
||||||
|
"empty": "",
|
||||||
|
"only-footer": _FOOTER,
|
||||||
|
"lone-surrogate": f"\ud800 fox\nNegative prompt: \udfff\n{_FOOTER}",
|
||||||
|
}
|
||||||
|
cases = {f"parameters-{name}": {"parameters": text} for name, text in texts.items()}
|
||||||
|
cases["parameters-with-execution"] = {
|
||||||
|
"parameters": texts["basic"],
|
||||||
|
EXECUTION_KEY: json.dumps({"version": 1, "nodes": {}}),
|
||||||
|
}
|
||||||
|
return cases
|
||||||
|
|
||||||
|
|
||||||
|
def selection_cases():
|
||||||
|
"""Named record sets for every Task 9 path, each a ``read_inspection_file`` dict."""
|
||||||
|
cases = {}
|
||||||
|
for group in (_samplers(), _previews(), _detailers()):
|
||||||
|
for name, graph in group.items():
|
||||||
|
cases[f"select-{name}"] = {"prompt": json.dumps(graph, ensure_ascii=False)}
|
||||||
|
cases.update(_parameters())
|
||||||
|
return cases
|
||||||
|
|
||||||
|
|
||||||
|
class _Selection(_Builder):
|
||||||
|
"""Random graphs with several samplers, outputs, previews and detailers."""
|
||||||
|
|
||||||
|
def sampler(self):
|
||||||
|
rng = self.rng
|
||||||
|
positive = self.conditioning(0)
|
||||||
|
negative = self.conditioning(0) if rng.random() < 0.85 else None
|
||||||
|
name = rng.choice(["KSampler", "KSampler", "KSamplerAdvanced", "SamplerCustom"])
|
||||||
|
node_id = self.new_id()
|
||||||
|
self.nodes[node_id] = _sampler(positive, negative, name=name)
|
||||||
|
return node_id
|
||||||
|
|
||||||
|
def shared(self, ids):
|
||||||
|
"""A copy of an earlier sampler: an identical branch."""
|
||||||
|
node_id = self.new_id()
|
||||||
|
self.nodes[node_id] = json.loads(json.dumps(self.nodes[self.rng.choice(ids)]))
|
||||||
|
return node_id
|
||||||
|
|
||||||
|
def output(self, image):
|
||||||
|
rng = self.rng
|
||||||
|
choice = rng.randrange(5)
|
||||||
|
if choice == 4:
|
||||||
|
return
|
||||||
|
name = ["SaveImage", "SaveImage", "PreviewImage", "PreviewAny"][choice]
|
||||||
|
self.nodes[self.new_id()] = _save(image, name)
|
||||||
|
|
||||||
|
def detailer(self, image):
|
||||||
|
rng = self.rng
|
||||||
|
node_id = self.new_id()
|
||||||
|
positive = self.conditioning(1) if rng.random() < 0.5 else None
|
||||||
|
name = rng.choice(list(_DETAILER_NAMES))
|
||||||
|
node = _detailer(image, positive, self.conditioning(1), name=name)
|
||||||
|
if positive is None:
|
||||||
|
node["inputs"]["positive"] = self.reuse_positive()
|
||||||
|
if name == "UltimateSDUpscaleNoUpscale":
|
||||||
|
node["inputs"]["upscaled_image"] = node["inputs"].pop("image")
|
||||||
|
if name in ("FaceDetailer", "DetailerForEach") and rng.random() < 0.15:
|
||||||
|
node["inputs"]["wildcard"] = rng.choice(["", "x", ["4", 0]])
|
||||||
|
self.nodes[node_id] = node
|
||||||
|
return node_id
|
||||||
|
|
||||||
|
def reuse_positive(self):
|
||||||
|
samplers = [
|
||||||
|
node
|
||||||
|
for node in self.nodes.values()
|
||||||
|
if node.get("class_type")
|
||||||
|
in ("KSampler", "KSamplerAdvanced", "SamplerCustom")
|
||||||
|
]
|
||||||
|
if samplers:
|
||||||
|
return self.rng.choice(samplers)["inputs"]["positive"]
|
||||||
|
return self.conditioning(1)
|
||||||
|
|
||||||
|
def build(self):
|
||||||
|
rng = self.rng
|
||||||
|
ids = [self.sampler()]
|
||||||
|
for _ in range(rng.randrange(3)):
|
||||||
|
ids.append(self.shared(ids) if rng.random() < 0.4 else self.sampler())
|
||||||
|
for sampler_id in list(ids):
|
||||||
|
decoded = self.new_id()
|
||||||
|
self.nodes[decoded] = _decode(sampler_id)
|
||||||
|
image = [decoded, 0]
|
||||||
|
for _ in range(rng.randrange(3)):
|
||||||
|
if rng.random() < 0.5:
|
||||||
|
image = [self.detailer(image), 0]
|
||||||
|
else:
|
||||||
|
self.output(image)
|
||||||
|
self.output(image)
|
||||||
|
if rng.random() < 0.1:
|
||||||
|
self.nodes[sampler_id]["inputs"]["latent_image"] = [rng.choice(ids), 0]
|
||||||
|
graph = {
|
||||||
|
"4": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "m"}},
|
||||||
|
"5": {"class_type": "EmptyLatentImage", "inputs": {"width": 64}},
|
||||||
|
}
|
||||||
|
graph.update(self.nodes)
|
||||||
|
return graph
|
||||||
|
|
||||||
|
|
||||||
|
_DETAILER_NAMES = (
|
||||||
|
"FaceDetailer",
|
||||||
|
"DetailerForEach",
|
||||||
|
"UltimateSDUpscale",
|
||||||
|
"UltimateSDUpscaleNoUpscale",
|
||||||
|
)
|
||||||
|
_PARAMETER_LINES = [
|
||||||
|
"Negative prompt:",
|
||||||
|
"Negative prompt: blur",
|
||||||
|
"Steps: 20, Sampler: euler",
|
||||||
|
"Steps: 7",
|
||||||
|
"Steps: x",
|
||||||
|
"Template: t",
|
||||||
|
"Negative Template: n",
|
||||||
|
"Steps:٣, a",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _parameter_text(rng):
|
||||||
|
parts = []
|
||||||
|
for _ in range(rng.randint(1, 5)):
|
||||||
|
if rng.random() < 0.5:
|
||||||
|
parts.append(rng.choice(_PARAMETER_LINES))
|
||||||
|
else:
|
||||||
|
parts.append(rng.choice(SPACES) + rng.choice(WORDS) + rng.choice(SPACES))
|
||||||
|
joints = ["\n", "\n", "\r\n", "\r", "
", " "]
|
||||||
|
return "".join(part + rng.choice(joints) for part in parts).rstrip(
|
||||||
|
rng.choice(["", "\n", " "])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def selection_fuzz_cases(seed, count):
|
||||||
|
"""Seeded several-sampler graphs (sometimes corrupted) and parameters texts."""
|
||||||
|
rng = random.Random(seed)
|
||||||
|
cases = {}
|
||||||
|
for index in range(count):
|
||||||
|
if rng.random() < 0.2:
|
||||||
|
cases[f"select-fuzz-{seed}-{index}"] = {"parameters": _parameter_text(rng)}
|
||||||
|
continue
|
||||||
|
builder = _Selection(rng)
|
||||||
|
graph = builder.build()
|
||||||
|
if rng.random() < 0.2:
|
||||||
|
builder.nodes = graph
|
||||||
|
builder.corrupt()
|
||||||
|
records = {"prompt": json.dumps(graph, ensure_ascii=rng.random() < 0.5)}
|
||||||
|
records.update(builder.records)
|
||||||
|
cases[f"select-fuzz-{seed}-{index}"] = records
|
||||||
|
return cases
|
||||||
@@ -486,6 +486,30 @@ class TestStaticRoutes(CoreRoutesTestCase):
|
|||||||
self.assertEqual(resp.status, 200)
|
self.assertEqual(resp.status, 200)
|
||||||
self.assertIn("application/javascript", resp.headers["Content-Type"])
|
self.assertIn("application/javascript", resp.headers["Content-Type"])
|
||||||
|
|
||||||
|
async def test_inspection_semantics_table_is_the_one_json_served(self):
|
||||||
|
"""Local Inspect reads the node table the server resolves with (#249 PR L3).
|
||||||
|
|
||||||
|
Only that exact file is served; every other JSON stays forbidden.
|
||||||
|
"""
|
||||||
|
resp = await self.client.request(
|
||||||
|
"GET", "/prompt_manager/js/inspection-semantics.json"
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status, 200)
|
||||||
|
self.assertIn("application/json", resp.headers["Content-Type"])
|
||||||
|
table = Path(__file__).resolve().parents[1] / "web" / "js"
|
||||||
|
self.assertEqual(
|
||||||
|
await resp.read(), (table / "inspection-semantics.json").read_bytes()
|
||||||
|
)
|
||||||
|
for path in (
|
||||||
|
"js/shared/toolkit/provenance.json",
|
||||||
|
"lib/viewerjs/provenance.json",
|
||||||
|
"lib/inspection-semantics.json",
|
||||||
|
"js/workspace/..%2Finspection-semantics.json",
|
||||||
|
"js/INSPECTION-SEMANTICS.json",
|
||||||
|
):
|
||||||
|
resp = await self.client.request("GET", f"/prompt_manager/{path}")
|
||||||
|
self.assertEqual(resp.status, 403, path)
|
||||||
|
|
||||||
async def test_lib_served(self):
|
async def test_lib_served(self):
|
||||||
web_dir = self._fake_root()
|
web_dir = self._fake_root()
|
||||||
(web_dir / "lib" / "x.css").write_text("body{}")
|
(web_dir / "lib" / "x.css").write_text("body{}")
|
||||||
@@ -578,6 +602,28 @@ class TestStaticRoutes(CoreRoutesTestCase):
|
|||||||
resp = await self.client.request("GET", "/prompt_manager/lib/link.js")
|
resp = await self.client.request("GET", "/prompt_manager/lib/link.js")
|
||||||
self.assertEqual(resp.status, 403)
|
self.assertEqual(resp.status, 403)
|
||||||
|
|
||||||
|
async def test_inspection_semantics_symlink_escaping_web_dir_is_403(self):
|
||||||
|
"""The exact-path JSON gets the same realpath containment as any file."""
|
||||||
|
web_dir = self._fake_root()
|
||||||
|
secret = Path(self.tmpdir) / "secret.json"
|
||||||
|
secret.write_text('{"secret": true}')
|
||||||
|
try:
|
||||||
|
os.symlink(secret, web_dir / "js" / "inspection-semantics.json")
|
||||||
|
except (OSError, NotImplementedError):
|
||||||
|
self.skipTest("symlinks not available")
|
||||||
|
resp = await self.client.request(
|
||||||
|
"GET", "/prompt_manager/js/inspection-semantics.json"
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status, 403)
|
||||||
|
self.assertNotIn("secret", await resp.text())
|
||||||
|
|
||||||
|
async def test_inspection_semantics_missing_is_404(self):
|
||||||
|
self._fake_root()
|
||||||
|
resp = await self.client.request(
|
||||||
|
"GET", "/prompt_manager/js/inspection-semantics.json"
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status, 404)
|
||||||
|
|
||||||
async def test_empty_path_is_not_served(self):
|
async def test_empty_path_is_not_served(self):
|
||||||
for prefix in ("lib", "js"):
|
for prefix in ("lib", "js"):
|
||||||
resp = await self.client.request("GET", f"/prompt_manager/{prefix}/")
|
resp = await self.client.request("GET", f"/prompt_manager/{prefix}/")
|
||||||
|
|||||||
@@ -1643,6 +1643,62 @@ class TestPromptInspector(ImageAPITestCase):
|
|||||||
str(path), False, report_ambiguity=True
|
str(path), False, report_ambiguity=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def test_unhashable_workflow_node_type_is_skipped(self):
|
||||||
|
"""A UI workflow node whose ``type`` is a list or dict is skipped (#286).
|
||||||
|
|
||||||
|
The R1 check looks each node's ``type`` up among subgraph IDs; an
|
||||||
|
unhashable ``type`` used to raise there and the route answered 500.
|
||||||
|
Such a node can be neither a subgraph instance nor a PromptManager
|
||||||
|
node, so the walk skips it and a prompt that resolves still resolves.
|
||||||
|
"""
|
||||||
|
source = graph()
|
||||||
|
source["1"]["class_type"] = "PromptManager"
|
||||||
|
for kind in (["PromptManager"], {"name": "PromptManager"}):
|
||||||
|
with self.subTest(kind=type(kind).__name__):
|
||||||
|
workflow = json.dumps(
|
||||||
|
{"nodes": [{"id": 1, "type": kind, "widgets_values": ["x"]}]}
|
||||||
|
)
|
||||||
|
png(
|
||||||
|
self.output_dir / "image.png",
|
||||||
|
prompt=json.dumps(source),
|
||||||
|
workflow=workflow,
|
||||||
|
)
|
||||||
|
response, result = await self.inspect()
|
||||||
|
self.assertEqual(response.status, 200)
|
||||||
|
self.assertEqual(result["resolution"]["state"], "resolved")
|
||||||
|
self.assertEqual(result["resolution"]["sampler_id"], "3")
|
||||||
|
self.assertEqual(result["prompts"]["positive"]["status"], "resolved")
|
||||||
|
self.assertEqual(result["prompts"]["positive"]["text"], "portrait")
|
||||||
|
self.assertEqual(result["prompts"]["negative"]["text"], "blurry")
|
||||||
|
self.assertNotIn("unhashable", json.dumps(result))
|
||||||
|
|
||||||
|
async def test_unhashable_graph_class_type_is_not_a_preview_output(self):
|
||||||
|
"""A list or object ``class_type`` never names a preview output (#286).
|
||||||
|
|
||||||
|
With several samplers the inspector first drops stages that reach only
|
||||||
|
previews; looking such a ``class_type`` up among the preview classes
|
||||||
|
used to raise, and the route answered 500.
|
||||||
|
"""
|
||||||
|
for kind in (["PreviewImage"], {"name": "PreviewImage"}):
|
||||||
|
with self.subTest(kind=type(kind).__name__):
|
||||||
|
source = graph()
|
||||||
|
source["9"] = {
|
||||||
|
"class_type": "KSampler",
|
||||||
|
"inputs": {"positive": ["8", 0], "negative": ["2", 0]},
|
||||||
|
}
|
||||||
|
source["8"] = {
|
||||||
|
"class_type": "CLIPTextEncode",
|
||||||
|
"inputs": {"text": "cat"},
|
||||||
|
}
|
||||||
|
source["10"] = {"class_type": kind, "inputs": {"images": ["9", 0]}}
|
||||||
|
png(self.output_dir / "image.png", prompt=json.dumps(source))
|
||||||
|
response, result = await self.inspect()
|
||||||
|
self.assertEqual(response.status, 200)
|
||||||
|
self.assertEqual(
|
||||||
|
result["resolution"]["reason"], "Multiple sampler branches"
|
||||||
|
)
|
||||||
|
self.assertEqual(result["resolution"]["state"], "ambiguous")
|
||||||
|
|
||||||
|
|
||||||
class TestInspectorImageTags(ImageAPITestCase):
|
class TestInspectorImageTags(ImageAPITestCase):
|
||||||
"""B3: the inspector envelope's ``image_tags`` facet (spec amendment S1)."""
|
"""B3: the inspector envelope's ``image_tags`` facet (spec amendment S1)."""
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
"""A slow page-workflow reset reports where the server waited.
|
||||||
|
|
||||||
|
CI run 39904288262 had one ``/__test__/reset`` wait 16 s with nothing in the
|
||||||
|
server's stderr. tests/browser/slow_reset_watch.py reports any reset past
|
||||||
|
SLOW_RESET_SECONDS with every thread's stack, taken while it still waits.
|
||||||
|
These tests hold a real SQLite write lock behind a real test server's reset.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import glob
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from tests.browser import slow_reset_watch
|
||||||
|
from tests.browser.slow_reset_watch import REPORT_PREFIX, ResetWatch, is_reset_path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
SERVER_TIMEOUT_S = 60
|
||||||
|
HOLD_SECONDS = slow_reset_watch.SLOW_RESET_SECONDS + 4
|
||||||
|
# The reset may take a little longer than the hold, never the 30 s busy timeout.
|
||||||
|
RESET_MARGIN_SECONDS = 10
|
||||||
|
# While the reset waits, the server still answers other requests this fast.
|
||||||
|
# Reading the waiting connection from the report's thread froze it (the GIL is
|
||||||
|
# held while SQLite's connection mutex is awaited) until the lock was released.
|
||||||
|
RESPONSIVE_SECONDS = 2.0
|
||||||
|
PROBE_INTERVAL_SECONDS = 0.1
|
||||||
|
|
||||||
|
|
||||||
|
def blocked_here(gate: threading.Event) -> None:
|
||||||
|
gate.wait()
|
||||||
|
|
||||||
|
|
||||||
|
class TestResetWatch(unittest.TestCase):
|
||||||
|
def test_reset_paths_are_the_test_reset_routes(self):
|
||||||
|
self.assertTrue(is_reset_path("/__test__/reset"))
|
||||||
|
self.assertTrue(is_reset_path("/__test__/tagging/reset"))
|
||||||
|
self.assertFalse(is_reset_path("/prompt_manager/reset"))
|
||||||
|
self.assertFalse(is_reset_path("/__test__/reset/extra"))
|
||||||
|
|
||||||
|
def test_a_stalled_reset_reports_state_and_stacks_while_it_waits(self):
|
||||||
|
stream = io.StringIO()
|
||||||
|
reported = threading.Event()
|
||||||
|
|
||||||
|
class Stream(io.StringIO):
|
||||||
|
def flush(self):
|
||||||
|
reported.set()
|
||||||
|
|
||||||
|
stream = Stream()
|
||||||
|
gate = threading.Event()
|
||||||
|
worker = threading.Thread(target=blocked_here, args=(gate,), name="held-up")
|
||||||
|
worker.start()
|
||||||
|
self.addCleanup(worker.join)
|
||||||
|
self.addCleanup(gate.set)
|
||||||
|
watch = ResetWatch(
|
||||||
|
"POST /__test__/reset",
|
||||||
|
lambda: ["database: 1 connections"],
|
||||||
|
threshold=0.05,
|
||||||
|
stream=stream,
|
||||||
|
).start()
|
||||||
|
self.assertTrue(reported.wait(SERVER_TIMEOUT_S))
|
||||||
|
report = stream.getvalue().splitlines()
|
||||||
|
self.assertTrue(all(line.startswith(REPORT_PREFIX) for line in report))
|
||||||
|
text = "\n".join(report)
|
||||||
|
self.assertIn("POST /__test__/reset still running after", text)
|
||||||
|
self.assertIn("database: 1 connections", text)
|
||||||
|
self.assertIn(f"thread 0x{worker.ident:016x} is held-up", text)
|
||||||
|
self.assertIn("in blocked_here", text)
|
||||||
|
|
||||||
|
watch.record_call("seed", 0.25, 1.5)
|
||||||
|
watch.finish("answered 200")
|
||||||
|
self.assertIn("POST /__test__/reset answered 200 after", stream.getvalue())
|
||||||
|
self.assertIn(
|
||||||
|
"executor call seed: queued 0.250s, ran 1.500s", stream.getvalue()
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_a_fast_reset_writes_nothing_and_stops_its_timer(self):
|
||||||
|
stream = io.StringIO()
|
||||||
|
watch = ResetWatch("reset", lambda: [], threshold=60, stream=stream).start()
|
||||||
|
watch.finish("answered 200")
|
||||||
|
self.assertTrue(watch._timer.finished.is_set())
|
||||||
|
self.assertEqual(stream.getvalue(), "")
|
||||||
|
|
||||||
|
def test_a_failing_state_probe_still_reports_the_stacks(self):
|
||||||
|
def broken():
|
||||||
|
raise RuntimeError("no state")
|
||||||
|
|
||||||
|
reported = threading.Event()
|
||||||
|
|
||||||
|
class Stream(io.StringIO):
|
||||||
|
def flush(self):
|
||||||
|
reported.set()
|
||||||
|
|
||||||
|
stream = Stream()
|
||||||
|
ResetWatch("reset", broken, threshold=0.01, stream=stream).start()
|
||||||
|
self.assertTrue(reported.wait(SERVER_TIMEOUT_S))
|
||||||
|
self.assertIn("state unavailable: RuntimeError('no state')", stream.getvalue())
|
||||||
|
self.assertIn("most recent call first", stream.getvalue())
|
||||||
|
|
||||||
|
|
||||||
|
class TestSlowResetOnTheServer(unittest.TestCase):
|
||||||
|
"""A reset behind another process's write lock, on tests/browser/server.py."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.tmp.cleanup)
|
||||||
|
env = {**os.environ, "TMPDIR": self.tmp.name}
|
||||||
|
env.update(TEMP=self.tmp.name, TMP=self.tmp.name)
|
||||||
|
self.stderr = tempfile.TemporaryFile()
|
||||||
|
self.addCleanup(self.stderr.close)
|
||||||
|
process = subprocess.Popen(
|
||||||
|
[sys.executable, "-m", "tests.browser.server", "--port", "0"],
|
||||||
|
cwd=REPO_ROOT,
|
||||||
|
env=env,
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=self.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
def stop():
|
||||||
|
if process.poll() is None:
|
||||||
|
process.kill()
|
||||||
|
process.wait(timeout=SERVER_TIMEOUT_S)
|
||||||
|
for stream in (process.stdin, process.stdout):
|
||||||
|
stream.close()
|
||||||
|
|
||||||
|
self.addCleanup(stop)
|
||||||
|
line = process.stdout.readline().decode()
|
||||||
|
self.assertTrue(line.startswith("PM_TEST_SERVER_PORT="), line)
|
||||||
|
self.base = f"http://127.0.0.1:{int(line.split('=', 1)[1])}"
|
||||||
|
deadline = time.monotonic() + SERVER_TIMEOUT_S
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen( # nosec B310
|
||||||
|
self.base + "/prompt_manager/test", timeout=1
|
||||||
|
):
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
self.assertIsNone(process.poll(), "the server exited early")
|
||||||
|
self.assertLess(time.monotonic(), deadline, "never ready")
|
||||||
|
time.sleep(0.1)
|
||||||
|
[self.db_path] = glob.glob(
|
||||||
|
os.path.join(self.tmp.name, "pm-browser-*", "browser.db")
|
||||||
|
)
|
||||||
|
|
||||||
|
def reset(self) -> tuple[int, float]:
|
||||||
|
request = urllib.request.Request(
|
||||||
|
self.base + "/__test__/reset",
|
||||||
|
data=b"{}",
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
start = time.monotonic()
|
||||||
|
with urllib.request.urlopen( # nosec B310
|
||||||
|
request, timeout=SERVER_TIMEOUT_S
|
||||||
|
) as response:
|
||||||
|
return response.status, time.monotonic() - start
|
||||||
|
|
||||||
|
def server_stderr(self) -> str:
|
||||||
|
self.stderr.seek(0)
|
||||||
|
return self.stderr.read().decode("utf-8", "replace")
|
||||||
|
|
||||||
|
def test_a_reset_held_behind_a_write_lock_names_the_wait_and_ends_with_it(self):
|
||||||
|
self.assertEqual(self.reset()[0], 200, self.server_stderr())
|
||||||
|
self.assertNotIn(REPORT_PREFIX, self.server_stderr())
|
||||||
|
|
||||||
|
held = threading.Event()
|
||||||
|
|
||||||
|
def hold_write_lock():
|
||||||
|
holder = sqlite3.connect(self.db_path, timeout=SERVER_TIMEOUT_S)
|
||||||
|
try:
|
||||||
|
holder.execute("BEGIN IMMEDIATE")
|
||||||
|
held.set()
|
||||||
|
time.sleep(HOLD_SECONDS)
|
||||||
|
holder.rollback()
|
||||||
|
finally:
|
||||||
|
holder.close()
|
||||||
|
|
||||||
|
lock = threading.Thread(target=hold_write_lock)
|
||||||
|
lock.start()
|
||||||
|
self.addCleanup(lock.join)
|
||||||
|
self.assertTrue(held.wait(SERVER_TIMEOUT_S))
|
||||||
|
outcome = []
|
||||||
|
resetting = threading.Thread(target=lambda: outcome.append(self.reset()))
|
||||||
|
resetting.start()
|
||||||
|
self.addCleanup(resetting.join)
|
||||||
|
slowest = 0.0
|
||||||
|
while resetting.is_alive():
|
||||||
|
start = time.monotonic()
|
||||||
|
with urllib.request.urlopen( # nosec B310
|
||||||
|
self.base + "/prompt_manager/test", timeout=SERVER_TIMEOUT_S
|
||||||
|
):
|
||||||
|
slowest = max(slowest, time.monotonic() - start)
|
||||||
|
time.sleep(PROBE_INTERVAL_SECONDS)
|
||||||
|
resetting.join()
|
||||||
|
lock.join()
|
||||||
|
self.assertEqual(len(outcome), 1, self.server_stderr())
|
||||||
|
[(status, elapsed)] = outcome
|
||||||
|
|
||||||
|
self.assertLess(slowest, RESPONSIVE_SECONDS)
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertGreaterEqual(elapsed, HOLD_SECONDS - 0.5)
|
||||||
|
self.assertLess(elapsed, HOLD_SECONDS + RESET_MARGIN_SECONDS)
|
||||||
|
report = [
|
||||||
|
line[len(REPORT_PREFIX) :]
|
||||||
|
for line in self.server_stderr().splitlines()
|
||||||
|
if line.startswith(REPORT_PREFIX)
|
||||||
|
]
|
||||||
|
text = "\n".join(report)
|
||||||
|
self.assertIn("POST /__test__/reset still running after", text)
|
||||||
|
self.assertIn("default executor:", text)
|
||||||
|
if sys.platform.startswith("linux"):
|
||||||
|
# SQLite's busy handler sleeps between its retries.
|
||||||
|
self.assertRegex(
|
||||||
|
text, r"kernel: asyncio_\d+ \(0x[0-9a-f]{16}\) state \w, waiting in "
|
||||||
|
)
|
||||||
|
# The plain reset's seed() waits on its first DELETE.
|
||||||
|
self.assertRegex(text, r"line \d+ in seed\n")
|
||||||
|
self.assertIn("POST /__test__/reset answered 200 after", text)
|
||||||
|
self.assertRegex(
|
||||||
|
text,
|
||||||
|
r"executor call create_app.<locals>.seed: queued \d+\.\d+s, ran \d+\.\d+s",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -299,45 +299,126 @@ class OutputWriterTests(fixtures.unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(writer.context_budget.charged_bytes, 0)
|
self.assertEqual(writer.context_budget.charged_bytes, 0)
|
||||||
|
|
||||||
|
def isolated_writer(self) -> HistoryWriter:
|
||||||
|
"""A ready writer whose budget no other writer or test can charge."""
|
||||||
|
writer = HistoryWriter(
|
||||||
|
lambda: self.db, clock=self.clock, context_budget=ContextBudget()
|
||||||
|
)
|
||||||
|
self.writers.append(writer)
|
||||||
|
writer.start()
|
||||||
|
self.wait_for(lambda: writer.status().accepting)
|
||||||
|
return writer
|
||||||
|
|
||||||
|
def rotate_between_transactions(self, writer, start, after, restore, hold=None):
|
||||||
|
"""Wrap ``after`` so a restore or invalidation lands right after it.
|
||||||
|
|
||||||
|
Returns the patch and an event set once that rotation has returned on
|
||||||
|
the worker. A restore is a full verify/migrate/backup/replace cycle, so
|
||||||
|
the handoff waits on this event with the generous ``BLOCK_SECONDS``
|
||||||
|
ceiling instead of the short worker-behavior deadline (TESTING.md,
|
||||||
|
Windows portability preflight).
|
||||||
|
"""
|
||||||
|
backup = os.path.join(self.directory.name, f"restore-{start.run_id}.db")
|
||||||
|
self.assertTrue(self.db.model.backup_database(backup))
|
||||||
|
original = getattr(self.db, after)
|
||||||
|
rotated = threading.Event()
|
||||||
|
|
||||||
|
def write(*args, **kwargs):
|
||||||
|
result = original(*args, **kwargs)
|
||||||
|
try:
|
||||||
|
if hold is not None:
|
||||||
|
entered, release = hold
|
||||||
|
entered.set()
|
||||||
|
release.wait(BLOCK_SECONDS)
|
||||||
|
if restore:
|
||||||
|
self.db.model.restore_from_file(backup)
|
||||||
|
else:
|
||||||
|
writer.invalidate()
|
||||||
|
finally:
|
||||||
|
rotated.set()
|
||||||
|
return result
|
||||||
|
|
||||||
|
return patch.object(self.db, after, write), rotated
|
||||||
|
|
||||||
def test_restore_or_generation_rotation_between_transactions_discards_evidence(
|
def test_restore_or_generation_rotation_between_transactions_discards_evidence(
|
||||||
self,
|
self,
|
||||||
):
|
):
|
||||||
for after in ("write_execution_finish", "write_execution_contexts_for_finish"):
|
for after in ("write_execution_finish", "write_execution_contexts_for_finish"):
|
||||||
for restore in (False, True):
|
for restore in (False, True):
|
||||||
with self.subTest(after=after, restore=restore):
|
with self.subTest(after=after, restore=restore):
|
||||||
writer = self.writer()
|
# Each subtest owns its writer and budget and retires them
|
||||||
start = self.start_command(len(self.writers))
|
# before the next starts, so one slow restore cannot leave
|
||||||
self.db.write_execution_start(start)
|
# in-flight charges or an epoch rotation for the next.
|
||||||
backup = os.path.join(self.directory.name, "restore.db")
|
writer = self.isolated_writer()
|
||||||
self.assertTrue(self.db.model.backup_database(backup))
|
try:
|
||||||
original = getattr(self.db, after)
|
start = self.start_command(len(self.writers))
|
||||||
|
self.db.write_execution_start(start)
|
||||||
|
rotation, rotated = self.rotate_between_transactions(
|
||||||
|
writer, start, after, restore
|
||||||
|
)
|
||||||
|
with rotation:
|
||||||
|
self.finalization(writer, start)
|
||||||
|
self.assertTrue(rotated.wait(BLOCK_SECONDS))
|
||||||
|
self.wait_for(lambda: writer.status().pending == 0)
|
||||||
|
self.assertEqual(
|
||||||
|
self.db.get_execution_outputs(start.run_id)["state"],
|
||||||
|
"not_captured",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.db.get_execution_contexts(start.run_id)["state"],
|
||||||
|
(
|
||||||
|
"complete"
|
||||||
|
if after == "write_execution_contexts_for_finish"
|
||||||
|
and not restore
|
||||||
|
else "not_captured"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(writer.context_budget.charged_bytes, 0)
|
||||||
|
finally:
|
||||||
|
self.assertTrue(writer.close(BLOCK_SECONDS))
|
||||||
|
|
||||||
def write(*args, **kwargs):
|
def test_slow_restore_between_transactions_holds_charge_only_while_in_flight(
|
||||||
result = original(*args, **kwargs)
|
self,
|
||||||
if restore:
|
):
|
||||||
self.db.model.restore_from_file(backup)
|
writer = self.isolated_writer()
|
||||||
else:
|
start = self.start_command()
|
||||||
writer.invalidate()
|
self.db.write_execution_start(start)
|
||||||
return result
|
entered, release = self.block()
|
||||||
|
rotation, rotated = self.rotate_between_transactions(
|
||||||
with patch.object(self.db, after, write):
|
writer, start, "write_execution_finish", True, (entered, release)
|
||||||
self.finalization(writer, start)
|
)
|
||||||
self.wait_for(lambda: writer.status().pending == 0)
|
with rotation:
|
||||||
self.assertEqual(
|
context = self.sealed(writer)
|
||||||
self.db.get_execution_outputs(start.run_id)["state"],
|
outputs = self.outputs(writer, context.lease.byte_count)
|
||||||
"not_captured",
|
charge = writer.context_budget.charged_bytes
|
||||||
)
|
self.assertGreater(charge, 0)
|
||||||
self.assertEqual(
|
self.assertTrue(
|
||||||
self.db.get_execution_contexts(start.run_id)["state"],
|
writer.submit_finalization(self.finish_command(start), context, outputs)
|
||||||
(
|
)
|
||||||
"complete"
|
self.assertTrue(entered.wait(BLOCK_SECONDS))
|
||||||
if after == "write_execution_contexts_for_finish"
|
# The worker is between transactions: the payload is owned, not leaked.
|
||||||
and not restore
|
self.assertEqual(writer.status().pending, 1)
|
||||||
else "not_captured"
|
self.assertEqual(writer.context_budget.charged_bytes, charge)
|
||||||
),
|
release.set()
|
||||||
)
|
self.assertTrue(rotated.wait(BLOCK_SECONDS))
|
||||||
self.assertEqual(writer.context_budget.charged_bytes, 0)
|
self.wait_for(lambda: writer.status().pending == 0)
|
||||||
self.assertTrue(writer.close())
|
self.assertEqual(writer.context_budget.charged_bytes, 0)
|
||||||
|
self.assertFalse(writer.status().accepting)
|
||||||
|
self.assertEqual(
|
||||||
|
self.db.get_execution_outputs(start.run_id)["state"], "not_captured"
|
||||||
|
)
|
||||||
|
# The restored epoch is probed again and new evidence is accepted.
|
||||||
|
self.clock.advance(5)
|
||||||
|
self.wait_for(lambda: writer.status().accepting)
|
||||||
|
restored = self.start_command(2)
|
||||||
|
self.db.write_execution_start(restored)
|
||||||
|
self.finalization(writer, restored)
|
||||||
|
self.wait_for(lambda: writer.status().pending == 0)
|
||||||
|
self.assertEqual(
|
||||||
|
self.db.get_execution_outputs(restored.run_id)["state"], "complete"
|
||||||
|
)
|
||||||
|
self.assertEqual(writer.context_budget.charged_bytes, 0)
|
||||||
|
self.assertTrue(writer.close(BLOCK_SECONDS))
|
||||||
|
|
||||||
def test_invalidation_during_output_preparation_rechecks_actual_commit(self):
|
def test_invalidation_during_output_preparation_rechecks_actual_commit(self):
|
||||||
import database.execution_outputs as outputs_db
|
import database.execution_outputs as outputs_db
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import threading
|
||||||
import types
|
import types
|
||||||
import unittest
|
import unittest
|
||||||
import unittest.mock as mock
|
import unittest.mock as mock
|
||||||
@@ -368,10 +369,29 @@ class TestConsoleBanner(unittest.TestCase):
|
|||||||
self.assertIn("Loaded:", banner)
|
self.assertIn("Loaded:", banner)
|
||||||
|
|
||||||
|
|
||||||
|
class PriorFileHandler(logging.FileHandler):
|
||||||
|
"""A caller-owned sink that stays closed once its owner closes it.
|
||||||
|
|
||||||
|
``FileHandler.emit`` reopens the file when ``stream`` is None, so a record
|
||||||
|
that selected this handler before ``close()`` would reopen prior.log after
|
||||||
|
it, and Windows then refuses the temp directory cleanup (WinError 32).
|
||||||
|
``Handler.handle`` and ``FileHandler.close`` hold the same handler lock, so
|
||||||
|
checking ``_closed`` in ``emit`` is race free (as in the product's
|
||||||
|
``_OwnedRotatingFileHandler``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def emit(self, record):
|
||||||
|
if not self._closed:
|
||||||
|
super().emit(record)
|
||||||
|
|
||||||
|
|
||||||
class TestPackageLoggerIsolation(unittest.TestCase):
|
class TestPackageLoggerIsolation(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.tmp = tempfile.TemporaryDirectory()
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
self.addCleanup(self.tmp.cleanup)
|
self.addCleanup(self.tmp.cleanup)
|
||||||
|
# Runs after restore_logging, before the cleanup Windows refuses while
|
||||||
|
# prior.log is open; /proc makes Linux fail the same way.
|
||||||
|
self.addCleanup(self.assert_tmp_has_no_open_handles)
|
||||||
self.logger = logging.getLogger("prompt_manager")
|
self.logger = logging.getLogger("prompt_manager")
|
||||||
self.previous = (
|
self.previous = (
|
||||||
self.logger.handlers[:],
|
self.logger.handlers[:],
|
||||||
@@ -381,7 +401,7 @@ class TestPackageLoggerIsolation(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.addCleanup(self.restore_logging)
|
self.addCleanup(self.restore_logging)
|
||||||
self.path = os.path.join(self.tmp.name, "prior.log")
|
self.path = os.path.join(self.tmp.name, "prior.log")
|
||||||
self.handler = logging.FileHandler(self.path)
|
self.handler = PriorFileHandler(self.path)
|
||||||
self.logger.handlers = [self.handler]
|
self.logger.handlers = [self.handler]
|
||||||
self.logger.setLevel(logging.DEBUG)
|
self.logger.setLevel(logging.DEBUG)
|
||||||
self.logger.propagate = True
|
self.logger.propagate = True
|
||||||
@@ -398,16 +418,20 @@ class TestPackageLoggerIsolation(unittest.TestCase):
|
|||||||
self.server.PromptServer.instance.prompt_queue.task_done = lambda *a, **k: None
|
self.server.PromptServer.instance.prompt_queue.task_done = lambda *a, **k: None
|
||||||
|
|
||||||
def restore_logging(self):
|
def restore_logging(self):
|
||||||
for handler in self.logger.handlers:
|
# Detach before closing, so new records stop selecting these handlers.
|
||||||
if handler not in self.previous[0]:
|
retired = [h for h in self.logger.handlers if h not in self.previous[0]]
|
||||||
handler.close()
|
|
||||||
self.handler.close()
|
|
||||||
(
|
(
|
||||||
self.logger.handlers,
|
self.logger.handlers,
|
||||||
self.logger.level,
|
self.logger.level,
|
||||||
self.logger.propagate,
|
self.logger.propagate,
|
||||||
self.logger.disabled,
|
self.logger.disabled,
|
||||||
) = self.previous
|
) = self.previous
|
||||||
|
for handler in retired:
|
||||||
|
handler.close()
|
||||||
|
self.handler.close()
|
||||||
|
|
||||||
|
def assert_tmp_has_no_open_handles(self):
|
||||||
|
self.assertEqual(open_handles_under(self.tmp.name), [])
|
||||||
|
|
||||||
def assert_logging_restored(self, handlers, registration):
|
def assert_logging_restored(self, handlers, registration):
|
||||||
for key, module in self.saved_modules.items():
|
for key, module in self.saved_modules.items():
|
||||||
@@ -456,6 +480,49 @@ class TestPackageLoggerIsolation(unittest.TestCase):
|
|||||||
self.assertEqual(open_at_disposal, [True])
|
self.assertEqual(open_at_disposal, [True])
|
||||||
self.assert_logging_restored(handlers, registration)
|
self.assert_logging_restored(handlers, registration)
|
||||||
|
|
||||||
|
def test_record_in_flight_at_restore_cannot_reopen_prior_log(self):
|
||||||
|
"""A record that selected prior.log before restore never reopens it.
|
||||||
|
|
||||||
|
Leaked daemon threads of other tests (image workers, watchdog) log
|
||||||
|
under ``prompt_manager.*``. One that reaches the prior handler while
|
||||||
|
restore_logging closes it would, with a plain FileHandler, reopen the
|
||||||
|
file on emit and keep it open through TemporaryDirectory.cleanup().
|
||||||
|
"""
|
||||||
|
message = "record selected before restore_logging"
|
||||||
|
selected = threading.Event()
|
||||||
|
resume = threading.Event()
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
def pause_before_handler_lock(record):
|
||||||
|
if record.getMessage() == message:
|
||||||
|
selected.set()
|
||||||
|
if not resume.wait(5):
|
||||||
|
raise RuntimeError("logging thread was not released")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def log_from_background_thread():
|
||||||
|
try:
|
||||||
|
logging.getLogger("prompt_manager.late_emitter").warning(message)
|
||||||
|
except BaseException as error:
|
||||||
|
errors.append(error)
|
||||||
|
|
||||||
|
self.handler.addFilter(pause_before_handler_lock)
|
||||||
|
worker = threading.Thread(target=log_from_background_thread, daemon=True)
|
||||||
|
worker.start()
|
||||||
|
try:
|
||||||
|
self.assertTrue(selected.wait(5), "record did not select prior.log")
|
||||||
|
self.restore_logging()
|
||||||
|
self.assertIsNone(self.handler.stream)
|
||||||
|
finally:
|
||||||
|
resume.set()
|
||||||
|
worker.join(5)
|
||||||
|
self.assertFalse(worker.is_alive(), "logging thread did not finish")
|
||||||
|
self.assertEqual(errors, [])
|
||||||
|
self.assertIsNone(self.handler.stream)
|
||||||
|
self.assert_tmp_has_no_open_handles()
|
||||||
|
with open(self.path, encoding="utf-8") as log:
|
||||||
|
self.assertNotIn(message, log.read())
|
||||||
|
|
||||||
def test_failed_package_import_closes_handlers_and_restores_logging(self):
|
def test_failed_package_import_closes_handlers_and_restores_logging(self):
|
||||||
name = "prompt_manager_pkg_logger_failure"
|
name = "prompt_manager_pkg_logger_failure"
|
||||||
captured = {}
|
captured = {}
|
||||||
|
|||||||
@@ -51,12 +51,19 @@ from utils.prompt_inspector import (
|
|||||||
read_inspection_file,
|
read_inspection_file,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from utils.prompt_inspector import inspect_prompt_evidence, unreadable_evidence
|
||||||
|
from utils.prompt_pair_resolver import INSPECTION_PROFILE
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from tests import inspection_parity as parity
|
from tests import inspection_parity as parity
|
||||||
|
from tests import local_resolver_cases as resolver_cases
|
||||||
|
from tests import local_resolver_selection_cases as selection_cases
|
||||||
from tests.open_handles import assert_closed
|
from tests.open_handles import assert_closed
|
||||||
from tests.test_utils_video_metadata import HAVE_FFMPEG, write_video
|
from tests.test_utils_video_metadata import HAVE_FFMPEG, write_video
|
||||||
except ImportError: # pragma: no cover - discover -s tests puts tests/ first
|
except ImportError: # pragma: no cover - discover -s tests puts tests/ first
|
||||||
import inspection_parity as parity
|
import inspection_parity as parity
|
||||||
|
import local_resolver_cases as resolver_cases
|
||||||
|
import local_resolver_selection_cases as selection_cases
|
||||||
from open_handles import assert_closed
|
from open_handles import assert_closed
|
||||||
from test_utils_video_metadata import HAVE_FFMPEG, write_video
|
from test_utils_video_metadata import HAVE_FFMPEG, write_video
|
||||||
|
|
||||||
@@ -1111,5 +1118,306 @@ class LocalProjectionParityTests(unittest.TestCase):
|
|||||||
self.assertTrue({"parsed", "malformed", "limited"} <= seen)
|
self.assertTrue({"parsed", "malformed", "limited"} <= seen)
|
||||||
|
|
||||||
|
|
||||||
|
def _server_evidence(records):
|
||||||
|
return inspect_prompt_evidence(records, None, profile=INSPECTION_PROFILE)
|
||||||
|
|
||||||
|
|
||||||
|
class LocalResolverParityTests(unittest.TestCase):
|
||||||
|
"""``resolveLocalEvidence`` answers as ``inspect_prompt_evidence`` (L3 Tasks 8-9).
|
||||||
|
|
||||||
|
Python is the oracle: every expected value is the server's own answer on the
|
||||||
|
same record texts. Task 8 ported one sampler's tracing; Task 9 ports
|
||||||
|
candidate selection (several samplers, the identical-branch rule of #270,
|
||||||
|
preview-only stages, detailer stages) and A1111 ``parameters``, so no
|
||||||
|
record set is left to the server any more.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
temporary = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(temporary.cleanup)
|
||||||
|
self.addCleanup(assert_closed, self, temporary.name)
|
||||||
|
self.directory = Path(temporary.name)
|
||||||
|
|
||||||
|
def _compare(self, cases):
|
||||||
|
"""Assert each record set's local evidence equal; return the server answers."""
|
||||||
|
observed = _run_oracle(
|
||||||
|
self,
|
||||||
|
[
|
||||||
|
{"name": name, "mode": "resolve", "records": records}
|
||||||
|
for name, records in cases.items()
|
||||||
|
],
|
||||||
|
)
|
||||||
|
answers = {}
|
||||||
|
for name, records in cases.items():
|
||||||
|
with self.subTest(case=name):
|
||||||
|
local = observed[name]
|
||||||
|
server = _server_evidence(records)
|
||||||
|
answers[name] = server
|
||||||
|
self.assertEqual(
|
||||||
|
_canonical_bytes(local["evidence"]), _canonical_bytes(server)
|
||||||
|
)
|
||||||
|
return answers
|
||||||
|
|
||||||
|
def test_local_roles_match_execution_and_single_chain_vectors(self):
|
||||||
|
files = {}
|
||||||
|
for name in parity.COMMITTED:
|
||||||
|
files[name] = parity.fixture_path(name)
|
||||||
|
for name in parity.TEST_TIME:
|
||||||
|
path = parity.fixture_path(name, self.directory)
|
||||||
|
parity.write_png(path, parity.build(name))
|
||||||
|
files[name] = path
|
||||||
|
observed = _run_oracle(
|
||||||
|
self,
|
||||||
|
[
|
||||||
|
{"name": name, "mode": "resolve", "path": str(path)}
|
||||||
|
for name, path in files.items()
|
||||||
|
],
|
||||||
|
)
|
||||||
|
vectors = {
|
||||||
|
vector["fixture"]: vector
|
||||||
|
for vector in json.loads(parity.VECTORS_PATH.read_text(encoding="utf-8"))
|
||||||
|
if vector["profile"] == "inspection"
|
||||||
|
}
|
||||||
|
compared = 0
|
||||||
|
for name, path in files.items():
|
||||||
|
with self.subTest(fixture=name):
|
||||||
|
local = observed[name]
|
||||||
|
try:
|
||||||
|
records = read_inspection_file(path).metadata
|
||||||
|
except MetadataReadError:
|
||||||
|
self.assertTrue(local["unreadable"])
|
||||||
|
continue
|
||||||
|
# The single-chain subset (Task 8); the rest is Task 9's test below.
|
||||||
|
if not resolver_cases.single_chain(records):
|
||||||
|
continue
|
||||||
|
server = _server_evidence(records)
|
||||||
|
self.assertEqual(
|
||||||
|
_canonical_bytes(local["evidence"]), _canonical_bytes(server)
|
||||||
|
)
|
||||||
|
# The committed vector agrees with the server read here.
|
||||||
|
expect = vectors[name]["expect"]
|
||||||
|
self.assertEqual(server["resolution"]["state"], expect["state"])
|
||||||
|
self.assertEqual(
|
||||||
|
server["resolution"]["reason_code"], expect["reason_code"]
|
||||||
|
)
|
||||||
|
compared += 1
|
||||||
|
self.assertGreaterEqual(compared, 20)
|
||||||
|
|
||||||
|
def test_local_roles_match_inspection_vectors(self):
|
||||||
|
"""Every inspection vector: local prompts, resolution and facts equal Images'.
|
||||||
|
|
||||||
|
The facts are ``extractGenerationFacts`` (with the legacy fill) on the
|
||||||
|
local payload, against the facts the committed vectors pin for the
|
||||||
|
server's response on the same file.
|
||||||
|
"""
|
||||||
|
vectors = [
|
||||||
|
vector
|
||||||
|
for vector in json.loads(parity.VECTORS_PATH.read_text(encoding="utf-8"))
|
||||||
|
if vector["profile"] == "inspection"
|
||||||
|
]
|
||||||
|
files = {}
|
||||||
|
for vector in vectors:
|
||||||
|
name = vector["fixture"]
|
||||||
|
if name in parity.TEST_TIME:
|
||||||
|
path = parity.fixture_path(name, self.directory)
|
||||||
|
parity.write_png(path, parity.build(name))
|
||||||
|
else:
|
||||||
|
path = parity.fixture_path(name)
|
||||||
|
files[name] = path
|
||||||
|
observed = _run_oracle(
|
||||||
|
self,
|
||||||
|
[
|
||||||
|
{"name": name, "mode": "resolve", "path": str(path)}
|
||||||
|
for name, path in files.items()
|
||||||
|
],
|
||||||
|
)
|
||||||
|
for vector in vectors:
|
||||||
|
name = vector["fixture"]
|
||||||
|
with self.subTest(fixture=name):
|
||||||
|
local, expect = observed[name], vector["expect"]
|
||||||
|
try:
|
||||||
|
server = _server_evidence(
|
||||||
|
read_inspection_file(files[name]).metadata
|
||||||
|
)
|
||||||
|
except MetadataReadError:
|
||||||
|
server = unreadable_evidence()
|
||||||
|
self.assertTrue(local["unreadable"])
|
||||||
|
self.assertEqual(
|
||||||
|
_canonical_bytes(local["evidence"]), _canonical_bytes(server)
|
||||||
|
)
|
||||||
|
resolution, prompts = (
|
||||||
|
local["evidence"]["resolution"],
|
||||||
|
local["evidence"]["prompts"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
(
|
||||||
|
resolution["state"],
|
||||||
|
resolution["reason"],
|
||||||
|
resolution["reason_code"],
|
||||||
|
),
|
||||||
|
(expect["state"], expect["reason"], expect["reason_code"]),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[
|
||||||
|
parity._role_text(
|
||||||
|
prompts[role]["status"], prompts[role]["text"]
|
||||||
|
)
|
||||||
|
for role in ("positive", "negative")
|
||||||
|
],
|
||||||
|
[expect["positive"], expect["negative"]],
|
||||||
|
)
|
||||||
|
self.assertEqual(local["facts"], expect["facts"])
|
||||||
|
self.assertEqual(len(vectors), 28)
|
||||||
|
|
||||||
|
def test_local_roles_match_selection_record_sets(self):
|
||||||
|
answers = self._compare(selection_cases.selection_cases())
|
||||||
|
reasons = {answer["resolution"]["reason"] for answer in answers.values()}
|
||||||
|
self.assertTrue(
|
||||||
|
{
|
||||||
|
"Every sampler branch uses the same prompt pair",
|
||||||
|
"Multiple sampler branches",
|
||||||
|
"Sampler stages have different prompts",
|
||||||
|
"Cyclic sampler stage ancestry",
|
||||||
|
"Expected an image link",
|
||||||
|
"Unsupported sampler stage ancestry",
|
||||||
|
"Resolution depth limit reached",
|
||||||
|
"A1111 settings footer is not unique",
|
||||||
|
"Multiple negative prompt delimiters",
|
||||||
|
"Negative prompt delimiter is absent",
|
||||||
|
"Parameters exceed the length limit",
|
||||||
|
"No A1111 parameters",
|
||||||
|
}
|
||||||
|
<= reasons
|
||||||
|
)
|
||||||
|
# A stage reaching only previews is dropped: one sampler is named.
|
||||||
|
self.assertEqual(
|
||||||
|
answers["select-preview-dropped"]["resolution"]["sampler_id"], "3"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_local_roles_match_fuzzed_selection_sets(self):
|
||||||
|
cases = {}
|
||||||
|
for seed in range(4):
|
||||||
|
cases.update(selection_cases.selection_fuzz_cases(seed, 150))
|
||||||
|
answers = self._compare(cases)
|
||||||
|
reasons = [answer["resolution"]["reason"] for answer in answers.values()]
|
||||||
|
self.assertGreater(reasons.count("Multiple sampler branches"), 20)
|
||||||
|
self.assertGreater(reasons.count("Sampler stages have different prompts"), 0)
|
||||||
|
self.assertGreater(
|
||||||
|
reasons.count("Every sampler branch uses the same prompt pair"), 0
|
||||||
|
)
|
||||||
|
self.assertGreater(reasons.count("A1111 settings footer is not unique"), 0)
|
||||||
|
|
||||||
|
def test_local_roles_match_golden_record_sets(self):
|
||||||
|
answers = self._compare(resolver_cases.golden_cases())
|
||||||
|
codes = {answer["resolution"]["reason_code"] for answer in answers.values()}
|
||||||
|
self.assertTrue(
|
||||||
|
{
|
||||||
|
"malformed",
|
||||||
|
"unsupported-node",
|
||||||
|
"runtime-value",
|
||||||
|
"composite",
|
||||||
|
"work-limit",
|
||||||
|
"depth-limit",
|
||||||
|
"text-limit",
|
||||||
|
"graph-ceiling",
|
||||||
|
"no-sampler",
|
||||||
|
None,
|
||||||
|
}
|
||||||
|
<= codes
|
||||||
|
)
|
||||||
|
sources = {
|
||||||
|
side["source"]
|
||||||
|
for answer in answers.values()
|
||||||
|
for side in answer["prompts"].values()
|
||||||
|
}
|
||||||
|
self.assertTrue(
|
||||||
|
{"execution", "graph-inferred", "zeroed-conditioning"} <= sources
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_committed_resolver_vectors_are_current(self):
|
||||||
|
"""The Node unit test's committed answers are still the server's."""
|
||||||
|
committed = json.loads(resolver_cases.VECTORS_PATH.read_text(encoding="utf-8"))
|
||||||
|
self.assertEqual(
|
||||||
|
committed,
|
||||||
|
json.loads(json.dumps(resolver_cases.committed_vectors())),
|
||||||
|
"regenerate with: python3 -m tests.local_resolver_cases --write",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_local_roles_match_at_every_work_bound(self):
|
||||||
|
answers = self._compare(resolver_cases.boundary_cases())
|
||||||
|
for name in ("visits", "depth", "scanned"):
|
||||||
|
self.assertEqual(answers[f"{name}-edge"]["resolution"]["state"], "resolved")
|
||||||
|
self.assertIn(
|
||||||
|
answers[f"{name}-over"]["resolution"]["reason_code"],
|
||||||
|
("work-limit", "depth-limit"),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
answers["named-astral"]["resolution"]["node"]["class_type"],
|
||||||
|
"\U0001f600" * 64,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_local_json_depth_cap_at_its_boundary(self):
|
||||||
|
"""Both kinds of deep record at the browser's JSON depth cap (1,000).
|
||||||
|
|
||||||
|
Locally a record nested past the cap is invalid JSON: a prompt is
|
||||||
|
malformed and a workflow is ignored, as the server answers when
|
||||||
|
``json.loads`` raises RecursionError. The server's own limit depends on
|
||||||
|
the Python (TESTING.md, "Deep JSON"): about 990 on 3.10 and 3.11, so it
|
||||||
|
agrees past the cap there; about 10,000 on 3.12 and 3.13 and over
|
||||||
|
50,000 on 3.14, so it agrees up to the cap there and reads deeper.
|
||||||
|
"""
|
||||||
|
cases = resolver_cases.depth_cases()
|
||||||
|
observed = _run_oracle(
|
||||||
|
self,
|
||||||
|
[
|
||||||
|
{"name": name, "mode": "resolve", "records": records}
|
||||||
|
for name, _kind, _depth, records in cases
|
||||||
|
],
|
||||||
|
)
|
||||||
|
newer = sys.version_info >= (3, 12)
|
||||||
|
compared = 0
|
||||||
|
for name, kind, depth, records in cases:
|
||||||
|
with self.subTest(case=name):
|
||||||
|
local = observed[name]["evidence"]["resolution"]
|
||||||
|
within = depth <= resolver_cases.JSON_MAX_DEPTH
|
||||||
|
if kind == "prompt":
|
||||||
|
expected = (
|
||||||
|
("resolved", None) if within else ("unresolved", "malformed")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# A workflow that is read finds the overwritten pair (R1).
|
||||||
|
expected = (
|
||||||
|
("unresolved", "malformed") if within else ("resolved", None)
|
||||||
|
)
|
||||||
|
self.assertEqual((local["state"], local["reason_code"]), expected)
|
||||||
|
server = _server_evidence(records)
|
||||||
|
if server["resolution"]["state"] == local["state"]:
|
||||||
|
self.assertEqual(
|
||||||
|
_canonical_bytes(observed[name]["evidence"]),
|
||||||
|
_canonical_bytes(server),
|
||||||
|
)
|
||||||
|
compared += 1
|
||||||
|
else:
|
||||||
|
# The documented divergence only: an older server refuses
|
||||||
|
# a little below the cap, a newer one reads past it.
|
||||||
|
self.assertEqual(within, not newer)
|
||||||
|
self.assertGreaterEqual(compared, 2)
|
||||||
|
|
||||||
|
def test_local_roles_match_fuzzed_record_sets(self):
|
||||||
|
cases = {}
|
||||||
|
for seed in range(4):
|
||||||
|
cases.update(resolver_cases.fuzz_cases(seed, 150))
|
||||||
|
answers = self._compare(cases)
|
||||||
|
states = [answer["resolution"]["state"] for answer in answers.values()]
|
||||||
|
# Coherent graphs reach real results, not only early refusals.
|
||||||
|
self.assertGreater(states.count("resolved"), len(states) // 5)
|
||||||
|
self.assertGreater(states.count("ambiguous"), 0)
|
||||||
|
self.assertGreater(states.count("unresolved"), len(states) // 10)
|
||||||
|
self.assertGreaterEqual(
|
||||||
|
len({answer["resolution"]["reason_code"] for answer in answers.values()}),
|
||||||
|
5,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"""The concept fixture files carry the concept inspector facts (app.js:400-414)."""
|
"""The concept fixture files carry the concept inspector facts (app.js:400-414)."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -15,6 +17,8 @@ except ImportError: # discovered with tests/ as the top-level directory
|
|||||||
from tests.browser.workspace_concept_fixture import (
|
from tests.browser.workspace_concept_fixture import (
|
||||||
CONCEPT_MODEL,
|
CONCEPT_MODEL,
|
||||||
CONCEPT_PROMPTS,
|
CONCEPT_PROMPTS,
|
||||||
|
MAX_EPOCH_SECONDS,
|
||||||
|
_instant,
|
||||||
write_concept_png,
|
write_concept_png,
|
||||||
)
|
)
|
||||||
from utils.prompt_inspector import inspect_prompt_evidence, read_inspection_file
|
from utils.prompt_inspector import inspect_prompt_evidence, read_inspection_file
|
||||||
@@ -80,5 +84,29 @@ class TestWorkspaceConceptFixture(unittest.TestCase):
|
|||||||
self.assertEqual((image["width"], image["height"]), size)
|
self.assertEqual((image["width"], image["height"]), size)
|
||||||
|
|
||||||
|
|
||||||
|
class TestConceptFixtureInstant(unittest.TestCase):
|
||||||
|
"""One "now" for every relative time the fixture writes (PR #284's midnight)."""
|
||||||
|
|
||||||
|
def test_a_chosen_instant_is_kept(self):
|
||||||
|
self.assertEqual(_instant(1_792_713_598), 1_792_713_598.0)
|
||||||
|
self.assertEqual(_instant(1_792_713_598.25), 1_792_713_598.25)
|
||||||
|
|
||||||
|
def test_the_wall_clock_when_omitted(self):
|
||||||
|
before = time.time()
|
||||||
|
now = _instant(None)
|
||||||
|
self.assertLessEqual(before, now)
|
||||||
|
self.assertLessEqual(now, time.time())
|
||||||
|
|
||||||
|
def test_a_non_number_is_refused(self):
|
||||||
|
for value in (True, "1792713598", [1]):
|
||||||
|
with self.subTest(value=value), self.assertRaises(TypeError):
|
||||||
|
_instant(value)
|
||||||
|
|
||||||
|
def test_an_instant_out_of_range_is_refused(self):
|
||||||
|
for value in (0, -1, MAX_EPOCH_SECONDS, math.inf, math.nan):
|
||||||
|
with self.subTest(value=value), self.assertRaises(ValueError):
|
||||||
|
_instant(value)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
+24
-1
@@ -6,7 +6,7 @@ import { once } from 'node:events';
|
|||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
import { setTimeout as delay } from 'node:timers/promises';
|
import { setTimeout as delay } from 'node:timers/promises';
|
||||||
import { startBrowserServer, createServerSupervisor, attachServerStderr } from '../../tools/vendor/start-browser-server.mjs';
|
import { startBrowserServer, createServerSupervisor, attachServerStderr, forwardPrefixedLines, SLOW_RESET_PREFIX } from '../../tools/vendor/start-browser-server.mjs';
|
||||||
import { workflowWorkers } from '../../tools/vendor/test-workers.mjs';
|
import { workflowWorkers } from '../../tools/vendor/test-workers.mjs';
|
||||||
|
|
||||||
const fakeServer = fileURLToPath(new URL('./fake-browser-server.mjs', import.meta.url));
|
const fakeServer = fileURLToPath(new URL('./fake-browser-server.mjs', import.meta.url));
|
||||||
@@ -193,6 +193,29 @@ test('a failed test gets the server stderr written during it attached; a passing
|
|||||||
assert.equal(String(attached[0].body), 'Traceback (most recent call last): boom\n');
|
assert.equal(String(attached[0].body), 'Traceback (most recent call last): boom\n');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('only complete slow-reset report lines are forwarded, however the chunks split them', () => {
|
||||||
|
const written = [];
|
||||||
|
const forward = forwardPrefixedLines(SLOW_RESET_PREFIX, text => written.push(text));
|
||||||
|
forward(`aiohttp access line\n${SLOW_RESET_PREFIX}POST /__test__/reset still`);
|
||||||
|
assert.deepEqual(written, [], 'a report line waits for its newline');
|
||||||
|
forward(` running after 2.000s\nplain stderr\n${SLOW_RESET_PREFIX}Thread 0x1 [asyncio_0]\n${SLOW_RESET_PREFIX.slice(0, 5)}`);
|
||||||
|
forward(`${SLOW_RESET_PREFIX.slice(5)} File "server.py", line 1\nnot a report ${SLOW_RESET_PREFIX}\n`);
|
||||||
|
assert.deepEqual(written, [
|
||||||
|
`${SLOW_RESET_PREFIX}POST /__test__/reset still running after 2.000s\n${SLOW_RESET_PREFIX}Thread 0x1 [asyncio_0]\n`,
|
||||||
|
`${SLOW_RESET_PREFIX} File "server.py", line 1\n`,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a slow-reset report on server stderr reaches the worker stderr; other stderr does not', async t => {
|
||||||
|
const reported = [];
|
||||||
|
const server = await startBrowserServer({ ...fake(), report: text => reported.push(text) });
|
||||||
|
t.after(() => server.stop());
|
||||||
|
await writeStderr(server, 'Traceback: ordinary stderr\n');
|
||||||
|
await writeStderr(server, `${SLOW_RESET_PREFIX}POST /__test__/reset answered 200 after 16.064s\n`);
|
||||||
|
assert.deepEqual(reported, [`${SLOW_RESET_PREFIX}POST /__test__/reset answered 200 after 16.064s\n`]);
|
||||||
|
assert.ok(server.stderrTail().includes('Traceback: ordinary stderr'), 'the stderr tail still keeps everything');
|
||||||
|
});
|
||||||
|
|
||||||
test('a server exits with its worker even when the worker is SIGKILLed', async () => {
|
test('a server exits with its worker even when the worker is SIGKILLed', async () => {
|
||||||
const pid = await serverOfKilledWorker(`{ command: process.execPath, args: port => [${JSON.stringify(fakeServer)}, '--port', String(port), '--exit-on-stdin-eof'] }`);
|
const pid = await serverOfKilledWorker(`{ command: process.execPath, args: port => [${JSON.stringify(fakeServer)}, '--port', String(port), '--exit-on-stdin-eof'] }`);
|
||||||
await waitFor(() => !alive(pid), `server ${pid} to exit after its worker died`);
|
await waitFor(() => !alive(pid), `server ${pid} to exit after its worker died`);
|
||||||
|
|||||||
+38
-2
@@ -181,7 +181,7 @@
|
|||||||
},
|
},
|
||||||
"workspace/file-inspector.mjs": {
|
"workspace/file-inspector.mjs": {
|
||||||
"lines": 96,
|
"lines": 96,
|
||||||
"branches": 92,
|
"branches": 93,
|
||||||
"functions": 92,
|
"functions": 92,
|
||||||
"statements": 96
|
"statements": 96
|
||||||
},
|
},
|
||||||
@@ -296,7 +296,7 @@
|
|||||||
"workspace/inspect.mjs": {
|
"workspace/inspect.mjs": {
|
||||||
"lines": 98,
|
"lines": 98,
|
||||||
"branches": 92,
|
"branches": 92,
|
||||||
"functions": 92,
|
"functions": 93,
|
||||||
"statements": 98
|
"statements": 98
|
||||||
},
|
},
|
||||||
"workspace/inspection-state.mjs": {
|
"workspace/inspection-state.mjs": {
|
||||||
@@ -341,12 +341,42 @@
|
|||||||
"functions": 98,
|
"functions": 98,
|
||||||
"statements": 98
|
"statements": 98
|
||||||
},
|
},
|
||||||
|
"workspace/local-parameters.mjs": {
|
||||||
|
"lines": 98,
|
||||||
|
"branches": 94,
|
||||||
|
"functions": 98,
|
||||||
|
"statements": 98
|
||||||
|
},
|
||||||
"workspace/local-projection.mjs": {
|
"workspace/local-projection.mjs": {
|
||||||
"lines": 97,
|
"lines": 97,
|
||||||
"branches": 96,
|
"branches": 96,
|
||||||
"functions": 98,
|
"functions": 98,
|
||||||
"statements": 97
|
"statements": 97
|
||||||
},
|
},
|
||||||
|
"workspace/local-resolver-stages.mjs": {
|
||||||
|
"lines": 94,
|
||||||
|
"branches": 93,
|
||||||
|
"functions": 98,
|
||||||
|
"statements": 94
|
||||||
|
},
|
||||||
|
"workspace/local-resolver-support.mjs": {
|
||||||
|
"lines": 96,
|
||||||
|
"branches": 80,
|
||||||
|
"functions": 98,
|
||||||
|
"statements": 96
|
||||||
|
},
|
||||||
|
"workspace/local-resolver.mjs": {
|
||||||
|
"lines": 96,
|
||||||
|
"branches": 86,
|
||||||
|
"functions": 98,
|
||||||
|
"statements": 96
|
||||||
|
},
|
||||||
|
"workspace/local-semantics.mjs": {
|
||||||
|
"lines": 98,
|
||||||
|
"branches": 98,
|
||||||
|
"functions": 98,
|
||||||
|
"statements": 98
|
||||||
|
},
|
||||||
"workspace/local-session.mjs": {
|
"workspace/local-session.mjs": {
|
||||||
"lines": 98,
|
"lines": 98,
|
||||||
"branches": 92,
|
"branches": 92,
|
||||||
@@ -389,6 +419,12 @@
|
|||||||
"functions": 98,
|
"functions": 98,
|
||||||
"statements": 98
|
"statements": 98
|
||||||
},
|
},
|
||||||
|
"workspace/py-json.mjs": {
|
||||||
|
"lines": 97,
|
||||||
|
"branches": 92,
|
||||||
|
"functions": 98,
|
||||||
|
"statements": 97
|
||||||
|
},
|
||||||
"workspace/recorded-run.mjs": {
|
"workspace/recorded-run.mjs": {
|
||||||
"lines": 95,
|
"lines": 95,
|
||||||
"branches": 83,
|
"branches": 83,
|
||||||
|
|||||||
+19
@@ -17,6 +17,22 @@ const HOST = '127.0.0.1';
|
|||||||
// tests/browser/server.py prints this once it holds its listening socket. Only a
|
// tests/browser/server.py prints this once it holds its listening socket. Only a
|
||||||
// complete line counts: a chunk may end mid-number.
|
// complete line counts: a chunk may end mid-number.
|
||||||
const PORT_ANNOUNCEMENT = /^PM_TEST_SERVER_PORT=(\d+)\r?\n/m;
|
const PORT_ANNOUNCEMENT = /^PM_TEST_SERVER_PORT=(\d+)\r?\n/m;
|
||||||
|
// tests/browser/slow_reset_watch.py starts every line of a slow-reset report with
|
||||||
|
// this. Those lines go to the worker's stderr, so the job log shows a slow reset
|
||||||
|
// even when its test passes.
|
||||||
|
export const SLOW_RESET_PREFIX = 'pm-slow-reset| ';
|
||||||
|
|
||||||
|
// A stderr chunk handler that passes every complete line starting with `prefix`
|
||||||
|
// to `write`. A line split across chunks is held until its newline arrives.
|
||||||
|
export function forwardPrefixedLines(prefix, write) {
|
||||||
|
let pending = '';
|
||||||
|
return chunk => {
|
||||||
|
const lines = (pending + chunk).split('\n');
|
||||||
|
pending = lines.pop().slice(-STDERR_TAIL_BYTES);
|
||||||
|
const matched = lines.filter(line => line.startsWith(prefix));
|
||||||
|
if (matched.length) write(matched.map(line => `${line}\n`).join(''));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// The server exits when its stdin closes, so a worker that dies without teardown
|
// The server exits when its stdin closes, so a worker that dies without teardown
|
||||||
// (SIGKILL) leaves no server behind; the launcher holds the pipe's other end.
|
// (SIGKILL) leaves no server behind; the launcher holds the pipe's other end.
|
||||||
@@ -40,6 +56,7 @@ export async function startBrowserServer({
|
|||||||
cwd = repositoryRoot,
|
cwd = repositoryRoot,
|
||||||
timeoutMs = STARTUP_TIMEOUT_MS,
|
timeoutMs = STARTUP_TIMEOUT_MS,
|
||||||
stderrLogChars = STDERR_LOG_CHARS,
|
stderrLogChars = STDERR_LOG_CHARS,
|
||||||
|
report = chunk => process.stderr.write(chunk),
|
||||||
} = {}) {
|
} = {}) {
|
||||||
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
||||||
throw new Error(`startBrowserServer needs a port from 0 to 65535, got ${port}`);
|
throw new Error(`startBrowserServer needs a port from 0 to 65535, got ${port}`);
|
||||||
@@ -50,7 +67,9 @@ export async function startBrowserServer({
|
|||||||
let stderr = '';
|
let stderr = '';
|
||||||
let stderrTotal = 0;
|
let stderrTotal = 0;
|
||||||
child.stderr.setEncoding('utf8');
|
child.stderr.setEncoding('utf8');
|
||||||
|
const forwardReport = forwardPrefixedLines(SLOW_RESET_PREFIX, report);
|
||||||
child.stderr.on('data', chunk => {
|
child.stderr.on('data', chunk => {
|
||||||
|
forwardReport(chunk);
|
||||||
stderr = (stderr + chunk).slice(-Math.max(stderrLogChars, STDERR_TAIL_BYTES));
|
stderr = (stderr + chunk).slice(-Math.max(stderrLogChars, STDERR_TAIL_BYTES));
|
||||||
stderrTotal += chunk.length;
|
stderrTotal += chunk.length;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from .prompt_pair_resolver import (
|
|||||||
_FOOTER,
|
_FOOTER,
|
||||||
EMPTY_POSITIVE_REASON,
|
EMPTY_POSITIVE_REASON,
|
||||||
INSPECTION_PROFILE,
|
INSPECTION_PROFILE,
|
||||||
|
MALFORMED_EVIDENCE_REASON,
|
||||||
ResolverProfile,
|
ResolverProfile,
|
||||||
resolve_prompt_pair,
|
resolve_prompt_pair,
|
||||||
)
|
)
|
||||||
@@ -548,7 +549,7 @@ def inspect_prompt_evidence(
|
|||||||
resolution = {
|
resolution = {
|
||||||
"state": "unresolved",
|
"state": "unresolved",
|
||||||
"sampler_id": None,
|
"sampler_id": None,
|
||||||
"reason": "Prompt evidence is malformed",
|
"reason": MALFORMED_EVIDENCE_REASON,
|
||||||
"reason_code": "malformed",
|
"reason_code": "malformed",
|
||||||
"node": None,
|
"node": None,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1237,10 +1237,13 @@ class _GraphResolver:
|
|||||||
|
|
||||||
def non_file_outputs(self) -> set:
|
def non_file_outputs(self) -> set:
|
||||||
names = self.semantics.non_file_outputs
|
names = self.semantics.non_file_outputs
|
||||||
|
# Only a string names a class: a list or object is never looked up (#286).
|
||||||
return {
|
return {
|
||||||
node_id
|
node_id
|
||||||
for node_id, node in self.graph.items()
|
for node_id, node in self.graph.items()
|
||||||
if isinstance(node, dict) and node.get("class_type") in names
|
if isinstance(node, dict)
|
||||||
|
and isinstance(node.get("class_type"), str)
|
||||||
|
and node["class_type"] in names
|
||||||
}
|
}
|
||||||
|
|
||||||
def producers_of(self, starts: list, previews: set) -> set:
|
def producers_of(self, starts: list, previews: set) -> set:
|
||||||
@@ -1832,6 +1835,8 @@ def _shared_pair(candidates: list) -> Optional[Dict[str, Any]]:
|
|||||||
|
|
||||||
# Bounds for walking a UI workflow's subgraph instances (R1 only).
|
# Bounds for walking a UI workflow's subgraph instances (R1 only).
|
||||||
_MAX_SUBGRAPH_DEPTH = 8
|
_MAX_SUBGRAPH_DEPTH = 8
|
||||||
|
# The inspector's own line for evidence it cannot read (``inspect_prompt_evidence``).
|
||||||
|
MALFORMED_EVIDENCE_REASON = "Prompt evidence is malformed"
|
||||||
_MAX_WORKFLOW_NODES = 100_000
|
_MAX_WORKFLOW_NODES = 100_000
|
||||||
|
|
||||||
|
|
||||||
@@ -1862,7 +1867,9 @@ def _prompt_manager_widgets(workflow: Any) -> Dict[str, str]:
|
|||||||
|
|
||||||
Nodes inside a subgraph run as ``"<instance>:<inner>"`` API IDs (nested
|
Nodes inside a subgraph run as ``"<instance>:<inner>"`` API IDs (nested
|
||||||
instances chain their IDs); a definition without an instance runs nothing.
|
instances chain their IDs); a definition without an instance runs nothing.
|
||||||
The walk is bounded in depth and in nodes visited.
|
The walk is bounded in depth and in nodes visited. A node whose ``type`` is
|
||||||
|
a list or an object is skipped: it is neither a subgraph instance nor a
|
||||||
|
PromptManager node (#286).
|
||||||
"""
|
"""
|
||||||
nodes = workflow.get("nodes") if isinstance(workflow, dict) else None
|
nodes = workflow.get("nodes") if isinstance(workflow, dict) else None
|
||||||
if not isinstance(nodes, list):
|
if not isinstance(nodes, list):
|
||||||
@@ -1882,6 +1889,9 @@ def _prompt_manager_widgets(workflow: Any) -> Dict[str, str]:
|
|||||||
if not isinstance(node_id, (str, int)) or isinstance(node_id, bool):
|
if not isinstance(node_id, (str, int)) or isinstance(node_id, bool):
|
||||||
continue
|
continue
|
||||||
api_id = f"{prefix}{node_id}"
|
api_id = f"{prefix}{node_id}"
|
||||||
|
if isinstance(kind, (list, dict)):
|
||||||
|
# JSON's unhashable values are never a node type (#286).
|
||||||
|
continue
|
||||||
if kind in subgraphs and depth < _MAX_SUBGRAPH_DEPTH:
|
if kind in subgraphs and depth < _MAX_SUBGRAPH_DEPTH:
|
||||||
stack.append((subgraphs[kind], api_id + ":", depth + 1))
|
stack.append((subgraphs[kind], api_id + ":", depth + 1))
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { createSendButton } from './prompt-sender.mjs';
|
|||||||
import { createWorkspaceIcon } from './icons.mjs';
|
import { createWorkspaceIcon } from './icons.mjs';
|
||||||
import { navigateTo } from './state.mjs';
|
import { navigateTo } from './state.mjs';
|
||||||
import { showWorkspaceToast } from './toast.mjs';
|
import { showWorkspaceToast } from './toast.mjs';
|
||||||
|
import { openCopyFallback } from './workspace-dialogs.mjs';
|
||||||
|
|
||||||
const RAW_KEYS = ['prompt', 'workflow', 'parameters', 'comfyassets_prompt_pairs'];
|
const RAW_KEYS = ['prompt', 'workflow', 'parameters', 'comfyassets_prompt_pairs'];
|
||||||
const RAW_LIMIT = 'Raw metadata exceeds the response limit. Download the original file to inspect it externally.';
|
const RAW_LIMIT = 'Raw metadata exceeds the response limit. Download the original file to inspect it externally.';
|
||||||
@@ -26,6 +27,26 @@ const REVEAL_BUSY = 'The folder is already opening.';
|
|||||||
const REVEAL_FAILED = 'The folder could not be opened. Try again.';
|
const REVEAL_FAILED = 'The folder could not be opened. Try again.';
|
||||||
const FACTS = [['seed', 'Seed'], ['steps', 'Steps'], ['cfg', 'CFG'], ['sampler', 'Sampler'], ['scheduler', 'Scheduler'], ['model', 'Model']];
|
const FACTS = [['seed', 'Seed'], ['steps', 'Steps'], ['cfg', 'CFG'], ['sampler', 'Sampler'], ['scheduler', 'Scheduler'], ['model', 'Model']];
|
||||||
|
|
||||||
|
// D117 (LI-4): the legacy metadata page's COPY ALL (web/metadata.html copyAllData), its lines and placeholders.
|
||||||
|
const SUMMARY_LINES = [['Checkpoint', 'model'], ['Positive Prompt', 'positive'], ['Negative Prompt', 'negative'], ['Steps', 'steps'],
|
||||||
|
['CFG Scale', 'cfg'], ['Sampler', 'sampler'], ['Seed', 'seed']];
|
||||||
|
const SUMMARY_MISSING = {positive:'No prompt found', negative:'No negative prompt found'};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one-click settings summary (D117): the legacy COPY ALL text for what this inspector shows. A confirmed prompt
|
||||||
|
* and a known fact are copied verbatim; anything else, an explicitly empty prompt included, takes the legacy page's
|
||||||
|
* placeholder, as its parse does (`hasValue` treats '' as absent).
|
||||||
|
*/
|
||||||
|
export function settingsSummary(record) {
|
||||||
|
const roles = promptRoles(record?.prompts);
|
||||||
|
const value = key => {
|
||||||
|
if (Object.hasOwn(SUMMARY_MISSING, key)) return roles[key] ? roles[key] : SUMMARY_MISSING[key];
|
||||||
|
const fact = record?.generation?.[key];
|
||||||
|
return fact?.status === 'known' && typeof fact.value === 'string' && fact.value !== '' ? fact.value : 'Unknown';
|
||||||
|
};
|
||||||
|
return SUMMARY_LINES.map(([label, key]) => `${label}: ${value(key)}`).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
function safeFilename(value) {
|
function safeFilename(value) {
|
||||||
return String(value || 'file').replace(/[\\/\x00-\x1f\x7f<>:"|?*]/g, '_').replace(/^\.+|[. ]+$/g, '') || 'file';
|
return String(value || 'file').replace(/[\\/\x00-\x1f\x7f<>:"|?*]/g, '_').replace(/^\.+|[. ]+$/g, '') || 'file';
|
||||||
}
|
}
|
||||||
@@ -175,7 +196,7 @@ function prefixedChip(prefix, text) {
|
|||||||
* `variant: 'full'` (the viewer's `#viewer-details`, imageDetails(i, true)) leaves out the cover and adds "File &
|
* `variant: 'full'` (the viewer's `#viewer-details`, imageDetails(i, true)) leaves out the cover and adds "File &
|
||||||
* provenance" and "Workflow & raw metadata" with the downloads. `inline: true` means `panel`; neither means `full`.
|
* provenance" and "Workflow & raw metadata" with the downloads. `inline: true` means `panel`; neither means `full`.
|
||||||
*/
|
*/
|
||||||
export function mountFileInspector(container, {api, sender, parentScope, variant, inline = false, promptContext = '', onTagFilter, onRetry, onRefresh, onInteraction = () => {}}) {
|
export function mountFileInspector(container, {api, sender, parentScope, variant, inline = false, promptContext = '', onTagFilter, onRetry, onRefresh, onInteraction = () => {}, copyFallback = openCopyFallback}) {
|
||||||
if (parentScope.signal.aborted) return {setState() {}, dispose() {}};
|
if (parentScope.signal.aborted) return {setState() {}, dispose() {}};
|
||||||
const scope = childScope(parentScope);
|
const scope = childScope(parentScope);
|
||||||
let contentScope;
|
let contentScope;
|
||||||
@@ -184,7 +205,7 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
|
|||||||
scope.listen(container, 'keydown', onInteraction);
|
scope.listen(container, 'keydown', onInteraction);
|
||||||
scope.listen(container, 'click', onInteraction);
|
scope.listen(container, 'click', onInteraction);
|
||||||
scope.listen(container, 'wheel', onInteraction, {passive:true});
|
scope.listen(container, 'wheel', onInteraction, {passive:true});
|
||||||
scope.cleanup(() => {contentScope?.dispose(); container.replaceChildren(); container.classList.remove('file-inspector');});
|
scope.cleanup(() => {contentScope?.dispose(); container.replaceChildren(); container.classList.remove('file-inspector'); container.removeAttribute('aria-busy');});
|
||||||
|
|
||||||
const full = (variant ?? (inline ? 'panel' : 'full')) === 'full';
|
const full = (variant ?? (inline ? 'panel' : 'full')) === 'full';
|
||||||
function statusView(state, parent) {
|
function statusView(state, parent) {
|
||||||
@@ -203,6 +224,8 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
|
|||||||
if (scope.signal.aborted) return;
|
if (scope.signal.aborted) return;
|
||||||
contentScope?.dispose(); contentScope = childScope(scope);
|
contentScope?.dispose(); contentScope = childScope(scope);
|
||||||
container.replaceChildren();
|
container.replaceChildren();
|
||||||
|
// A local read resolves on this thread in bounded steps; the busy state stays until its answer lands.
|
||||||
|
if (state.loading) container.setAttribute('aria-busy', 'true'); else container.removeAttribute('aria-busy');
|
||||||
render(state);
|
render(state);
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
@@ -244,7 +267,7 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
|
|||||||
inner.append(tags);
|
inner.append(tags);
|
||||||
}
|
}
|
||||||
inner.append(...generationFacts(record, 'meta-grid', 'small'));
|
inner.append(...generationFacts(record, 'meta-grid', 'small'));
|
||||||
if (full) inner.append(provenance(record, badge), rawMetadata(record));
|
if (full) inner.append(summaryButton(record), provenance(record, badge), rawMetadata(record));
|
||||||
const footer = full ? container : el('div', undefined, 'detail-footer'), roles = promptRoles(record.prompts);
|
const footer = full ? container : el('div', undefined, 'detail-footer'), roles = promptRoles(record.prompts);
|
||||||
if (Object.hasOwn(roles, 'positive') && Object.hasOwn(roles, 'negative')) {
|
if (Object.hasOwn(roles, 'positive') && Object.hasOwn(roles, 'negative')) {
|
||||||
const pair = createButton('', 'send');
|
const pair = createButton('', 'send');
|
||||||
@@ -264,6 +287,23 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
|
|||||||
}
|
}
|
||||||
if (!full && footer.childNodes.length) container.append(footer);
|
if (!full && footer.childNodes.length) container.append(footer);
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* D117 (LI-4): "Copy settings summary" after the six facts, the legacy COPY ALL. It copies as the concept's prompt
|
||||||
|
* copy does: the shared toast, or the select-to-copy dialog where the clipboard is unavailable or refused.
|
||||||
|
*/
|
||||||
|
function summaryButton(record) {
|
||||||
|
const button = createButton('Copy settings summary', 'settings-summary'), text = settingsSummary(record);
|
||||||
|
contentScope.listen(button, 'click', async () => {
|
||||||
|
try {
|
||||||
|
if (!navigator.clipboard) throw new Error('Clipboard requires a secure context');
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
if (!contentScope.signal.aborted) showWorkspaceToast('Settings summary copied.');
|
||||||
|
} catch {
|
||||||
|
if (!contentScope.signal.aborted) copyFallback(text, {scope:contentScope, trigger:button, title:'Copy settings summary'});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return button;
|
||||||
|
}
|
||||||
/** The concept's plain cover; the card image opens the viewer (D19). Video keeps its own element. */
|
/** The concept's plain cover; the card image opens the viewer (D19). Video keeps its own element. */
|
||||||
function cover(record) {
|
function cover(record) {
|
||||||
const {file} = record, video = file?.media_type === 'video';
|
const {file} = record, video = file?.media_type === 'video';
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { pageHeading } from './heading.mjs';
|
|||||||
import { createWorkspaceIcon } from './icons.mjs';
|
import { createWorkspaceIcon } from './icons.mjs';
|
||||||
import { createLocalSession, LOCAL_SESSION_MESSAGES } from './local-session.mjs';
|
import { createLocalSession, LOCAL_SESSION_MESSAGES } from './local-session.mjs';
|
||||||
import { localFileAdapter } from './local-inspection.mjs';
|
import { localFileAdapter } from './local-inspection.mjs';
|
||||||
|
import { loadInspectionSemantics } from './local-semantics.mjs';
|
||||||
import { createMediaViewer } from './media-viewer.mjs';
|
import { createMediaViewer } from './media-viewer.mjs';
|
||||||
import { showWorkspaceToast, TOAST_MS } from './toast.mjs';
|
import { showWorkspaceToast, TOAST_MS } from './toast.mjs';
|
||||||
|
|
||||||
@@ -39,36 +40,24 @@ function element(tag, className, text) {
|
|||||||
|
|
||||||
// The status shown for the selection; AVI/WMV's note and an empty status keep the toast's "metadata is available".
|
// The status shown for the selection; AVI/WMV's note and an empty status keep the toast's "metadata is available".
|
||||||
const TOASTABLE = new Set(['', LOCAL_SESSION_MESSAGES.containerOnly]);
|
const TOASTABLE = new Set(['', LOCAL_SESSION_MESSAGES.containerOnly]);
|
||||||
// How long a released video still starting may take to settle before its deferred reset is dropped.
|
// How long a replaced video still starting may stay in place before it is released anyway.
|
||||||
const DEFERRED_RESET_MS = 15000;
|
const STARTING_VIDEO_MS = 15000;
|
||||||
|
|
||||||
/**
|
/** A video whose pipeline is still starting: it has a source but neither metadata nor an error yet. */
|
||||||
* Resets a released video once it settles. Removing `src` alone never aborts a load already under way, and WebKit
|
function isStarting(node) {
|
||||||
* (GStreamer) blocked the page when a pipeline still starting was reset, so load() waits for metadata or an error.
|
return node instanceof HTMLMediaElement && node.hasAttribute('src') && node.readyState < HTMLMediaElement.HAVE_METADATA && node.error === null;
|
||||||
* The fallback only drops the waiting listeners (a WebKit load cut off by the revoked URL stalls and never settles),
|
|
||||||
* so the detached node is never pinned.
|
|
||||||
*/
|
|
||||||
function resetWhenSettled(node) {
|
|
||||||
const waiting = new AbortController();
|
|
||||||
const fallback = setTimeout(() => waiting.abort(), DEFERRED_RESET_MS);
|
|
||||||
const reset = () => { waiting.abort(); clearTimeout(fallback); node.load(); };
|
|
||||||
for (const type of ['loadedmetadata', 'error']) node.addEventListener(type, reset, { once: true, signal: waiting.signal });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stops a preview from holding its blob: URL: its own listeners end, media is paused and its src dropped before the
|
* Stops a preview from holding its blob: URL: its own listeners end, media is paused, its src dropped and it is reset
|
||||||
* session revokes the URL, and the node leaves the page. A settled video (metadata read, or failed) is then reset with
|
* with load() (which frees its decoder), and the node leaves the page, all before its URL is revoked.
|
||||||
* load() to free its decoder at once; one still starting is reset as soon as it settles.
|
|
||||||
*/
|
*/
|
||||||
function releaseMedia(preview) {
|
function releaseMedia(preview) {
|
||||||
if (!preview) return;
|
if (!preview) return;
|
||||||
const { node, listeners } = preview;
|
const { node, listeners } = preview;
|
||||||
listeners.abort();
|
listeners.abort();
|
||||||
if (node instanceof HTMLMediaElement) {
|
if (node instanceof HTMLMediaElement) {
|
||||||
const settled = node.readyState >= HTMLMediaElement.HAVE_METADATA || node.error !== null;
|
node.pause(); node.removeAttribute('src'); node.load();
|
||||||
node.pause(); node.removeAttribute('src');
|
|
||||||
if (settled) node.load();
|
|
||||||
else resetWhenSettled(node);
|
|
||||||
} else node.removeAttribute('src');
|
} else node.removeAttribute('src');
|
||||||
node.remove();
|
node.remove();
|
||||||
}
|
}
|
||||||
@@ -84,6 +73,8 @@ export function mountInspect(main, { parentScope, api, sender }) {
|
|||||||
if (parentScope.signal.aborted) { scope.dispose(); return { dispose }; }
|
if (parentScope.signal.aborted) { scope.dispose(); return { dispose }; }
|
||||||
parentScope.signal.addEventListener('abort', dispose, { once: true });
|
parentScope.signal.addEventListener('abort', dispose, { once: true });
|
||||||
scope.cleanup(() => parentScope.signal.removeEventListener('abort', dispose));
|
scope.cleanup(() => parentScope.signal.removeEventListener('abort', dispose));
|
||||||
|
// The node table the local roles resolve with, read before any file is chosen (one request per page).
|
||||||
|
loadInspectionSemantics();
|
||||||
|
|
||||||
// The concept DOM: section.surface > h2, p; div.file-drop.spaced-20 > icon, label, input, span.small; #local-preview.
|
// The concept DOM: section.surface > h2, p; div.file-drop.spaced-20 > icon, label, input, span.small; #local-preview.
|
||||||
const surface = element('section', 'surface');
|
const surface = element('section', 'surface');
|
||||||
@@ -108,7 +99,33 @@ export function mountInspect(main, { parentScope, api, sender }) {
|
|||||||
// The session ends after the viewer has closed and the inline media is gone, so no element holds its URL then.
|
// The session ends after the viewer has closed and the inline media is gone, so no element holds its URL then.
|
||||||
// The page owns page leave for the same reason: the session listens for no `pagehide` of its own.
|
// The page owns page leave for the same reason: the session listens for no `pagehide` of its own.
|
||||||
const sessionScope = createScope();
|
const sessionScope = createScope();
|
||||||
const session = createLocalSession({ parentScope: sessionScope, pageTarget: null });
|
// Replaced videos still starting, by URL. WebKit (GStreamer) deadlocked the page when a video left the page or
|
||||||
|
// the viewport before its pipeline had started, so such a video stays where it is, inert, until it reports
|
||||||
|
// metadata or an error; only then is it released and its URL, which the session asked to revoke, revoked.
|
||||||
|
const retiring = new Map();
|
||||||
|
function finishRetiring(url) {
|
||||||
|
const entry = retiring.get(url);
|
||||||
|
if (!entry) return;
|
||||||
|
retiring.delete(url);
|
||||||
|
entry.waiting.abort(); clearTimeout(entry.timer);
|
||||||
|
releaseMedia(entry.preview);
|
||||||
|
if (entry.revoke) URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
function retire(preview, url) {
|
||||||
|
const waiting = new AbortController();
|
||||||
|
const entry = { preview, waiting, revoke: false, timer: setTimeout(() => finishRetiring(url), STARTING_VIDEO_MS) };
|
||||||
|
retiring.set(url, entry);
|
||||||
|
preview.listeners.abort();
|
||||||
|
preview.node.inert = true; preview.node.setAttribute('aria-hidden', 'true');
|
||||||
|
for (const type of ['loadedmetadata', 'error']) preview.node.addEventListener(type, () => finishRetiring(url), { once: true, signal: waiting.signal });
|
||||||
|
}
|
||||||
|
const finishAllRetiring = () => { for (const url of [...retiring.keys()]) finishRetiring(url); };
|
||||||
|
const revokeObjectURL = url => {
|
||||||
|
const entry = retiring.get(url);
|
||||||
|
if (entry) entry.revoke = true;
|
||||||
|
else URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
const session = createLocalSession({ parentScope: sessionScope, pageTarget: null, revokeObjectURL });
|
||||||
const viewer = createMediaViewer({ api, container: main, parentScope: scope, inspectionController: session.controller, sender,
|
const viewer = createMediaViewer({ api, container: main, parentScope: scope, inspectionController: session.controller, sender,
|
||||||
adapter: localFileAdapter, onRestoreFocus: () => { if (open.isConnected) open.focus({ preventScroll: true }); } });
|
adapter: localFileAdapter, onRestoreFocus: () => { if (open.isConnected) open.focus({ preventScroll: true }); } });
|
||||||
|
|
||||||
@@ -153,7 +170,9 @@ export function mountInspect(main, { parentScope, api, sender }) {
|
|||||||
function showMedia(state) {
|
function showMedia(state) {
|
||||||
const url = state.preview?.url ?? null;
|
const url = state.preview?.url ?? null;
|
||||||
if (url === mediaUrl) return;
|
if (url === mediaUrl) return;
|
||||||
releaseMedia(media); media = null; mediaUrl = url;
|
if (media && isStarting(media.node) && !retiring.has(mediaUrl)) retire(media, mediaUrl);
|
||||||
|
else releaseMedia(media);
|
||||||
|
media = null; mediaUrl = url;
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
const video = state.preview.media === 'video';
|
const video = state.preview.media === 'video';
|
||||||
const node = element(video ? 'video' : 'img', 'local-file-preview');
|
const node = element(video ? 'video' : 'img', 'local-file-preview');
|
||||||
@@ -173,7 +192,10 @@ export function mountInspect(main, { parentScope, api, sender }) {
|
|||||||
}, { once: true, signal: current.listeners.signal });
|
}, { once: true, signal: current.listeners.signal });
|
||||||
node.src = url;
|
node.src = url;
|
||||||
media = current;
|
media = current;
|
||||||
preview.prepend(node);
|
// A video still starting keeps its place, so the new preview goes after it and never moves it.
|
||||||
|
const anchor = [...retiring.values()].map(entry => entry.preview.node).filter(item => item.parentNode === preview).at(-1);
|
||||||
|
if (anchor) anchor.after(node);
|
||||||
|
else preview.prepend(node);
|
||||||
}
|
}
|
||||||
scope.cleanup(session.subscribe(state => {
|
scope.cleanup(session.subscribe(state => {
|
||||||
showMedia(state);
|
showMedia(state);
|
||||||
@@ -190,6 +212,7 @@ export function mountInspect(main, { parentScope, api, sender }) {
|
|||||||
// viewer (on abort), drops the media, then ends the session.
|
// viewer (on abort), drops the media, then ends the session.
|
||||||
scope.cleanup(() => viewer.dispose());
|
scope.cleanup(() => viewer.dispose());
|
||||||
scope.cleanup(() => { releaseMedia(media); media = null; mediaUrl = null; });
|
scope.cleanup(() => { releaseMedia(media); media = null; mediaUrl = null; });
|
||||||
|
scope.cleanup(finishAllRetiring);
|
||||||
scope.cleanup(() => sessionScope.dispose());
|
scope.cleanup(() => sessionScope.dispose());
|
||||||
|
|
||||||
// Page leave: the viewer closes and the inline media goes before the session revokes the URL, and the chooser is
|
// Page leave: the viewer closes and the inline media goes before the session revokes the URL, and the chooser is
|
||||||
@@ -198,6 +221,7 @@ export function mountInspect(main, { parentScope, api, sender }) {
|
|||||||
clearRejection();
|
clearRejection();
|
||||||
viewer.close();
|
viewer.close();
|
||||||
releaseMedia(media); media = null; mediaUrl = null;
|
releaseMedia(media); media = null; mediaUrl = null;
|
||||||
|
finishAllRetiring();
|
||||||
session.clear();
|
session.clear();
|
||||||
chosen = null; input.value = '';
|
chosen = null; input.value = '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,11 @@
|
|||||||
import { readVideoTags, selectVideoCarrier } from './local-video.mjs';
|
import { readVideoTags, selectVideoCarrier } from './local-video.mjs';
|
||||||
import { projectMetadata, fitInspectionPayload, MAX_RESPONSE_BYTES } from './local-projection.mjs';
|
import { projectMetadata, fitInspectionPayload, MAX_RESPONSE_BYTES } from './local-projection.mjs';
|
||||||
import { projectInspectionPayload } from './inspection-state.mjs';
|
import { projectInspectionPayload } from './inspection-state.mjs';
|
||||||
|
import { resolveLocalEvidence, unreadableEvidence } from './local-resolver.mjs';
|
||||||
|
import { loadInspectionSemantics } from './local-semantics.mjs';
|
||||||
|
|
||||||
export const LOCAL_OBSERVATION = 'selected-local-file';
|
export const LOCAL_OBSERVATION = 'selected-local-file';
|
||||||
export const LOCAL_SOURCE_LABEL = 'Selected local file';
|
export const LOCAL_SOURCE_LABEL = 'Selected local file';
|
||||||
// Interim roles until PR L3 ports the inspection resolver: every role is open, and
|
|
||||||
// the legacy fill in projectInspectionPayload shows what the legacy pages show.
|
|
||||||
const LOCAL_RESOLUTION_REASON = 'Resolved in this browser from the workflow.';
|
|
||||||
const LOCAL_LIBRARY = Object.freeze({ status: 'unavailable', scope: 'saved-library', tags: [], category_scope: null, tags_scope: null });
|
const LOCAL_LIBRARY = Object.freeze({ status: 'unavailable', scope: 'saved-library', tags: [], category_scope: null, tags_scope: null });
|
||||||
const LOCAL_SCOPE = 'local';
|
const LOCAL_SCOPE = 'local';
|
||||||
const PREFIX_BYTES = 32;
|
const PREFIX_BYTES = 32;
|
||||||
@@ -19,6 +18,24 @@ const MAX_FILENAME_CHARS = 255;
|
|||||||
const MEDIA_TYPES = new Set(['image', 'video']);
|
const MEDIA_TYPES = new Set(['image', 'video']);
|
||||||
|
|
||||||
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
const abortError = () => new DOMException('The local selection ended', 'AbortError');
|
||||||
|
|
||||||
|
/** `promise`, or an AbortError as soon as `signal` aborts (the work behind `promise` is shared and keeps going). */
|
||||||
|
function untilAborted(promise, signal) {
|
||||||
|
if (!signal) return promise;
|
||||||
|
if (signal.aborted) return Promise.reject(abortError());
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const onAbort = () => reject(abortError());
|
||||||
|
signal.addEventListener('abort', onAbort, { once: true });
|
||||||
|
promise.then(value => { signal.removeEventListener('abort', onAbort); resolve(value); },
|
||||||
|
error => { signal.removeEventListener('abort', onAbort); reject(error); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One turn of the event loop between bounded steps, so the busy state paints and input is handled. */
|
||||||
|
function nextTurn(signal) {
|
||||||
|
return untilAborted(new Promise(resolve => setTimeout(resolve, 0)), signal);
|
||||||
|
}
|
||||||
const pngReader = () => globalThis.PngMetadata || globalThis.window?.PngMetadata;
|
const pngReader = () => globalThis.PngMetadata || globalThis.window?.PngMetadata;
|
||||||
|
|
||||||
/** A display name with no path separators or control characters; never empty. */
|
/** A display name with no path separators or control characters; never empty. */
|
||||||
@@ -88,8 +105,19 @@ export function toInspectionRead(classification, result) {
|
|||||||
|
|
||||||
const dimension = value => Number.isSafeInteger(value) && value > 0 ? value : null;
|
const dimension = value => Number.isSafeInteger(value) && value > 0 ? value : null;
|
||||||
|
|
||||||
/** The `inspection_version: 1` wire for one local read, before the response budget. */
|
/** The roles for one local read (`{prompts, resolution}`): the bounded parse and resolution step of buildLocalPayload. */
|
||||||
export function buildLocalPayload({ read, file, classification }) {
|
export function localEvidence(read, classification, semantics = null) {
|
||||||
|
const inspection = toInspectionRead(classificationOf(classification), read);
|
||||||
|
return inspection.read === null ? unreadableEvidence() : resolveLocalEvidence(inspection.read.records, semantics);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `inspection_version: 1` wire for one local read, before the response budget. Its roles are the server
|
||||||
|
* inspector's for the same records (local-resolver.mjs with `semantics`, the compiled node table; null answers
|
||||||
|
* "unavailable" as the server does), so the legacy fill in projectInspectionPayload applies exactly where it does
|
||||||
|
* for Images.
|
||||||
|
*/
|
||||||
|
export function buildLocalPayload({ read, file, classification, semantics = null, evidence = localEvidence(read, classification, semantics) }) {
|
||||||
const classified = classificationOf(classification);
|
const classified = classificationOf(classification);
|
||||||
const inspection = toInspectionRead(classified, read);
|
const inspection = toInspectionRead(classified, read);
|
||||||
const filename = sanitizeLocalFilename(file?.name);
|
const filename = sanitizeLocalFilename(file?.name);
|
||||||
@@ -101,8 +129,8 @@ export function buildLocalPayload({ read, file, classification }) {
|
|||||||
observation: LOCAL_OBSERVATION, source_label: LOCAL_SOURCE_LABEL, media_type: classified.media, extension: localExtension(filename),
|
observation: LOCAL_OBSERVATION, source_label: LOCAL_SOURCE_LABEL, media_type: classified.media, extension: localExtension(filename),
|
||||||
format: inspection.format, size, width: dimension(inspection.width), height: dimension(inspection.height), modified_time: modified,
|
format: inspection.format, size, width: dimension(inspection.width), height: dimension(inspection.height), modified_time: modified,
|
||||||
},
|
},
|
||||||
prompts: { positive: { status: 'unresolved' }, negative: { status: 'unresolved' } },
|
prompts: evidence.prompts,
|
||||||
resolution: { state: 'unresolved', sampler_id: null, reason: LOCAL_RESOLUTION_REASON, reason_code: null, node: null },
|
resolution: evidence.resolution,
|
||||||
metadata: projectMetadata(inspection.read),
|
metadata: projectMetadata(inspection.read),
|
||||||
library: { ...LOCAL_LIBRARY, tags: [] },
|
library: { ...LOCAL_LIBRARY, tags: [] },
|
||||||
};
|
};
|
||||||
@@ -166,7 +194,12 @@ export const localFileAdapter = Object.freeze({
|
|||||||
scope(handle) { owned(handle); return LOCAL_SCOPE; },
|
scope(handle) { owned(handle); return LOCAL_SCOPE; },
|
||||||
// An inert sentinel: the controller's same-key check works and no URL is ever built.
|
// An inert sentinel: the controller's same-key check works and no URL is ever built.
|
||||||
inspectionPath(handle) { return `local-file:${owned(handle).key}`; },
|
inspectionPath(handle) { return `local-file:${owned(handle).key}`; },
|
||||||
async read(handle, { signal } = {}) {
|
/**
|
||||||
|
* Reads, resolves and projects one selection on the page's thread in bounded steps (each bound by the 2 MiB role
|
||||||
|
* and 4 MiB workflow budgets and the resolver's work limits) with a turn of the event loop between them, so the
|
||||||
|
* inspector's busy state paints. Every wait ends with the selection's `signal`; `yieldControl` is for tests.
|
||||||
|
*/
|
||||||
|
async read(handle, { signal, yieldControl = nextTurn } = {}) {
|
||||||
const secret = owned(handle);
|
const secret = owned(handle);
|
||||||
let result = null;
|
let result = null;
|
||||||
try {
|
try {
|
||||||
@@ -176,7 +209,14 @@ export const localFileAdapter = Object.freeze({
|
|||||||
// MetadataReadError, an unreadable record. Abort and programming errors still reject.
|
// MetadataReadError, an unreadable record. Abort and programming errors still reject.
|
||||||
if (!(error instanceof DOMException) || error.name === 'AbortError' || signal?.aborted) throw error;
|
if (!(error instanceof DOMException) || error.name === 'AbortError' || signal?.aborted) throw error;
|
||||||
}
|
}
|
||||||
return fitLocalPayload(buildLocalPayload({ read: result, file: { name: secret.filename, size: secret.file.size, lastModified: secret.file.lastModified }, classification: secret.classification }), MAX_RESPONSE_BYTES, { signal });
|
const semantics = await untilAborted(loadInspectionSemantics(), signal);
|
||||||
|
await yieldControl(signal);
|
||||||
|
const evidence = localEvidence(result, secret.classification, semantics);
|
||||||
|
await yieldControl(signal);
|
||||||
|
const file = { name: secret.filename, size: secret.file.size, lastModified: secret.file.lastModified };
|
||||||
|
const payload = buildLocalPayload({ read: result, file, classification: secret.classification, semantics, evidence });
|
||||||
|
await yieldControl(signal);
|
||||||
|
return fitLocalPayload(payload, MAX_RESPONSE_BYTES, { signal });
|
||||||
},
|
},
|
||||||
toRecord(result, handle, extractFacts) {
|
toRecord(result, handle, extractFacts) {
|
||||||
const secret = owned(handle);
|
const secret = owned(handle);
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// A1111 `parameters` for local-resolver.mjs (#249 local Inspect, PR L3 Task 9): a port of
|
||||||
|
// utils/prompt_pair_resolver.py `_parameters`, `_footer` and `_trailer_lines` for the inspection profile.
|
||||||
|
// Python's `re` differs from JavaScript's in three ways that matter here, so the patterns spell them out:
|
||||||
|
// multiline `^`/`$` break only at "\n" (not "\r", U+2028 or U+2029), `\s` is the `str.isspace()` set and `\d`
|
||||||
|
// is any Unicode decimal digit (Nd). Lengths count code points. No DOM.
|
||||||
|
import { PY_SPACE, pyLen, pyStrip } from './py-json.mjs';
|
||||||
|
import { EMPTY_POSITIVE_REASON, result } from './local-resolver-support.mjs';
|
||||||
|
|
||||||
|
const START = '(?<=^|\\n)', END = '(?=\\n|$)', SPACE = `[${PY_SPACE}]*`;
|
||||||
|
// `_FOOTER` and `_BARE_FOOTER` (inspection reads a bare `Steps: N` when there is no full footer).
|
||||||
|
const FOOTER = new RegExp(`${START}Steps:${SPACE}\\p{Nd}+${SPACE},[^\\n]*${END}`, 'gu');
|
||||||
|
const BARE_FOOTER = new RegExp(`${START}Steps:${SPACE}\\p{Nd}+${SPACE}${END}`, 'gu');
|
||||||
|
const TRAILERS = ['Template:', 'Negative Template:'];
|
||||||
|
const DELIMITER = '\nNegative prompt:';
|
||||||
|
|
||||||
|
/** `_trailer_lines`: empty, or at most one `Template:` line then at most one `Negative Template:` line. */
|
||||||
|
function trailerLines(text) {
|
||||||
|
const stripped = pyStrip(text);
|
||||||
|
const lines = stripped ? stripped.split(/\r?\n|\r/) : [];
|
||||||
|
const prefixes = lines.map(line => {
|
||||||
|
const index = TRAILERS.findIndex(name => line.startsWith(name));
|
||||||
|
return index === -1 ? null : index;
|
||||||
|
});
|
||||||
|
return !prefixes.includes(null) && prefixes.every((prefix, index) => index === 0 || prefix > prefixes[index - 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `_footer(value, inspection=True)`: the unique settings footer match, or null. */
|
||||||
|
function footer(value) {
|
||||||
|
let footers = [...value.matchAll(FOOTER)];
|
||||||
|
if (!footers.length) footers = [...value.matchAll(BARE_FOOTER)];
|
||||||
|
if (footers.length !== 1) return null;
|
||||||
|
const [match] = footers;
|
||||||
|
return trailerLines(value.slice(match.index + match[0].length)) ? match : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rstripLineBreaks = text => text.replace(/[\r\n]+$/, '');
|
||||||
|
|
||||||
|
/** `_parameters(value, max_text_length, inspection=True)`. */
|
||||||
|
export function parameters(value, maxTextLength) {
|
||||||
|
if (typeof value !== 'string' || !value) return result('absent', { reason: 'No A1111 parameters' });
|
||||||
|
if (pyLen(value) > maxTextLength) return result('unresolved', { reason: 'Parameters exceed the length limit', reasonCode: 'text-limit' });
|
||||||
|
const match = footer(value);
|
||||||
|
if (match === null) return result('unresolved', { reason: 'A1111 settings footer is not unique', reasonCode: 'malformed' });
|
||||||
|
// R4: text that starts with the delimiter has an empty positive (#165).
|
||||||
|
const body = `\n${rstripLineBreaks(value.slice(0, match.index))}`;
|
||||||
|
const parts = body.split(DELIMITER);
|
||||||
|
if (parts.length > 2) return result('ambiguous', { reason: 'Multiple negative prompt delimiters' });
|
||||||
|
const positive = pyStrip(parts[0]);
|
||||||
|
if (!positive) return result('unresolved', { reason: EMPTY_POSITIVE_REASON });
|
||||||
|
const provenance = { format: 'a1111', field: 'parameters', text_source: 'embedded-parameters' };
|
||||||
|
const side = (text, role) => ({ text, storage_text: text, provenance: { ...provenance, role } });
|
||||||
|
if (parts.length === 1) return result('absent', { positive: side(positive, 'positive'), reason: 'Negative prompt delimiter is absent' });
|
||||||
|
const negative = pyStrip(parts[1]);
|
||||||
|
return result('resolved', { positive: side(positive, 'positive'), negative: side(negative, 'negative') });
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
// Candidate selection for local-resolver.mjs (#249 local Inspect, PR L3 Task 9): ports of
|
||||||
|
// utils/prompt_pair_resolver.py `_GraphResolver` `stage`, `upstream_stages`, `final_stages`, `file_stages`,
|
||||||
|
// `non_file_outputs`, `producers_of`, `consumers`, `reaches_output`, plus `_outcome_key`, `_recipe_key` and
|
||||||
|
// `_shared_pair` (#270). Each function takes the resolver whose budgets and caches it spends, so the order of
|
||||||
|
// work, and with it every limit, is the server's. No DOM.
|
||||||
|
import { isDict, pyGet, pyStr } from './py-json.mjs';
|
||||||
|
import { DETAILER, IDENTICAL_BRANCHES_REASON, Unresolved, isLink, result } from './local-resolver-support.mjs';
|
||||||
|
|
||||||
|
const roleText = side => side !== null && typeof side === 'object' ? side.text : null;
|
||||||
|
const storageText = side => side !== null && typeof side === 'object' ? side.storage_text : null;
|
||||||
|
/** `_outcome_key`: what a reader sees of a result, the state and the two role texts. */
|
||||||
|
export const outcomeKey = item => JSON.stringify([item.state, roleText(item.positive), roleText(item.negative)]);
|
||||||
|
/** `_recipe_key`: the outcome plus each side's stored recipe. */
|
||||||
|
const recipeKey = item => JSON.stringify([item.state, roleText(item.positive), roleText(item.negative), storageText(item.positive), storageText(item.negative)]);
|
||||||
|
|
||||||
|
/** `_shared_pair`: the one pair every sampler branch resolved to, or null (#270). */
|
||||||
|
export function sharedPair(candidates) {
|
||||||
|
const first = candidates[0];
|
||||||
|
if (first.state !== 'resolved') return null;
|
||||||
|
if (new Set(candidates.map(recipeKey)).size !== 1) return null;
|
||||||
|
return result('resolved', { positive: first.positive, negative: first.negative, reason: IDENTICAL_BRANCHES_REASON, candidates });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `stage`: one sampler's result; a detailer is compared with its upstream stages. */
|
||||||
|
export function stage(resolver, samplerId, later) {
|
||||||
|
if (later.size >= resolver.maxDepth) return result('unresolved', { samplerId, reason: 'Resolution depth limit reached', reasonCode: 'depth-limit' });
|
||||||
|
const own = resolver.sides(samplerId);
|
||||||
|
if (own.state === 'unresolved') return own;
|
||||||
|
const [name, inputs] = resolver.node(samplerId);
|
||||||
|
const entry = resolver.semantics.samplers.get(name);
|
||||||
|
if (entry === undefined || entry.strategy !== DETAILER) return own;
|
||||||
|
let upstream;
|
||||||
|
try {
|
||||||
|
upstream = upstreamStages(resolver, samplerId, pyGet(inputs, entry.image_stage));
|
||||||
|
if (upstream.some(nodeId => later.has(nodeId))) throw new Unresolved('Cyclic sampler stage ancestry', 'malformed');
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof Unresolved)) throw error;
|
||||||
|
return result('unresolved', { samplerId, reason: error.message, reasonCode: error.code, node: error.node });
|
||||||
|
}
|
||||||
|
if (!upstream.length) return own;
|
||||||
|
const deeper = new Set([...later, samplerId]);
|
||||||
|
const stages = upstream.map(nodeId => stage(resolver, nodeId, deeper));
|
||||||
|
const blocked = stages.find(item => item.state === 'unresolved');
|
||||||
|
// A stage that cannot be read cannot be compared: never pick one.
|
||||||
|
if (blocked !== undefined) return result('unresolved', { samplerId, reason: blocked.reason, reasonCode: blocked.reason_code, node: blocked.node });
|
||||||
|
const flat = [];
|
||||||
|
for (const item of [own, ...stages]) flat.push(...(item.state === 'ambiguous' ? item.candidates : [item]));
|
||||||
|
if (new Set(flat.map(outcomeKey)).size === 1) return own;
|
||||||
|
return result('ambiguous', { samplerId, reason: 'Sampler stages have different prompts', candidates: flat, reasonCode: 'multistage-differs' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `upstream_stages`: samplers reached from a detailer's image input, breadth first; each node draws a visit. */
|
||||||
|
function upstreamStages(resolver, origin, value) {
|
||||||
|
if (!isLink(value)) throw new Unresolved('Expected an image link', 'malformed');
|
||||||
|
const found = [], start = String(value[0]), queue = [[start, 0]], queued = new Set([start]);
|
||||||
|
for (let head = 0; head < queue.length; head++) {
|
||||||
|
const [nodeId, depth] = queue[head];
|
||||||
|
if (nodeId === origin) throw new Unresolved('Cyclic sampler stage ancestry', 'malformed');
|
||||||
|
if (depth >= resolver.maxDepth) throw new Unresolved('Resolution depth limit reached', 'depth-limit');
|
||||||
|
resolver.budget.spend();
|
||||||
|
const [name, inputs] = resolver.node(nodeId);
|
||||||
|
if (resolver.stageClasses.has(name)) { found.push(nodeId); continue; }
|
||||||
|
if (resolver.stageShaped(name, inputs)) throw resolver.unsupported('Unsupported sampler stage ancestry', nodeId);
|
||||||
|
resolver.enqueue(queue, queued, inputs, depth + 1);
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
const strategyOf = (resolver, nodeId) => resolver.semantics.samplers.get(resolver.graph.get(nodeId).get('class_type')).strategy;
|
||||||
|
|
||||||
|
/** `final_stages`: drop samplers that only feed detailers (each is judged in that chain). */
|
||||||
|
export function finalStages(resolver, samplerIds) {
|
||||||
|
samplerIds = fileStages(resolver, samplerIds);
|
||||||
|
const detailers = samplerIds.filter(nodeId => strategyOf(resolver, nodeId) === DETAILER);
|
||||||
|
if (!detailers.length) return samplerIds;
|
||||||
|
let reverse;
|
||||||
|
try { reverse = consumers(resolver); } catch (error) {
|
||||||
|
if (!(error instanceof Unresolved)) throw error;
|
||||||
|
return samplerIds;
|
||||||
|
}
|
||||||
|
const absorbed = new Set();
|
||||||
|
for (const detailer of detailers) {
|
||||||
|
resolver.budget.reset(resolver.perCandidateVisits);
|
||||||
|
try {
|
||||||
|
const [name, inputs] = resolver.node(detailer);
|
||||||
|
const image = pyGet(inputs, resolver.semantics.samplers.get(name).image_stage);
|
||||||
|
for (const nodeId of upstreamStages(resolver, detailer, image)) {
|
||||||
|
if (!reachesOutput(resolver, nodeId, reverse)) absorbed.add(nodeId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof Unresolved)) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const kept = samplerIds.filter(nodeId => !absorbed.has(nodeId));
|
||||||
|
return kept.length ? kept : samplerIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `file_stages`: stages not proven preview-only; all of them when every one is. */
|
||||||
|
function fileStages(resolver, samplerIds) {
|
||||||
|
if (samplerIds.length < 2) return samplerIds;
|
||||||
|
let toFile, toPreview;
|
||||||
|
try {
|
||||||
|
const reverse = consumers(resolver), previews = nonFileOutputs(resolver);
|
||||||
|
const terminals = [...resolver.graph].filter(([nodeId, node]) => isDict(node) && !previews.has(nodeId) && !reverse.get(nodeId)?.length).map(([nodeId]) => nodeId);
|
||||||
|
toFile = producersOf(resolver, terminals, previews);
|
||||||
|
toPreview = producersOf(resolver, [...previews], previews);
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof Unresolved)) throw error;
|
||||||
|
return samplerIds;
|
||||||
|
}
|
||||||
|
const kept = samplerIds.filter(nodeId => toFile.has(nodeId) || !toPreview.has(nodeId));
|
||||||
|
return kept.length ? kept : samplerIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nonFileOutputs(resolver) {
|
||||||
|
const names = resolver.semantics.nonFileOutputs, found = new Set();
|
||||||
|
for (const [nodeId, node] of resolver.graph) {
|
||||||
|
// `node.get("class_type") in names`: only a string can be a listed class.
|
||||||
|
if (isDict(node) && names.has(pyGet(node, 'class_type'))) found.add(nodeId);
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `producers_of`: nodes whose output reaches one of `starts`, never through a preview. */
|
||||||
|
function producersOf(resolver, starts, previews) {
|
||||||
|
const found = new Set(starts), queue = [...starts];
|
||||||
|
for (let head = 0; head < queue.length; head++) {
|
||||||
|
const inputs = pyGet(resolver.graph.get(queue[head]), 'inputs');
|
||||||
|
if (!isDict(inputs)) continue;
|
||||||
|
resolver.scan(inputs.size);
|
||||||
|
for (const value of inputs.values()) {
|
||||||
|
if (!isLink(value)) continue;
|
||||||
|
const producer = String(value[0]);
|
||||||
|
if (found.has(producer) || previews.has(producer) || !isDict(pyGet(resolver.graph, producer))) continue;
|
||||||
|
found.add(producer);
|
||||||
|
queue.push(producer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `consumers`: reverse links, node ID to the IDs of nodes reading one of its outputs. */
|
||||||
|
function consumers(resolver) {
|
||||||
|
const reverse = new Map();
|
||||||
|
for (const [nodeId, node] of resolver.graph) {
|
||||||
|
if (!isDict(node) || !isDict(pyGet(node, 'inputs'))) continue;
|
||||||
|
const inputs = node.get('inputs');
|
||||||
|
resolver.scan(inputs.size);
|
||||||
|
for (const value of inputs.values()) {
|
||||||
|
if (!isLink(value)) continue;
|
||||||
|
const producer = String(value[0]);
|
||||||
|
if (!reverse.has(producer)) reverse.set(producer, []);
|
||||||
|
reverse.get(producer).push(nodeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `reaches_output`: whether a sampler's output reaches a terminal node besides later stages. */
|
||||||
|
function reachesOutput(resolver, nodeId, reverse) {
|
||||||
|
const seen = new Set([nodeId]), queue = [nodeId];
|
||||||
|
for (let head = 0; head < queue.length; head++) {
|
||||||
|
for (const consumer of reverse.get(queue[head]) ?? []) {
|
||||||
|
if (seen.has(consumer)) continue;
|
||||||
|
seen.add(consumer);
|
||||||
|
resolver.budget.spend();
|
||||||
|
const name = pyStr(pyGet(resolver.graph.get(consumer), 'class_type', ''));
|
||||||
|
if (resolver.stageClasses.has(name) || resolver.semantics.nonFileOutputs.has(name)) continue;
|
||||||
|
if (!reverse.get(consumer)?.length) return true;
|
||||||
|
queue.push(consumer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
// Tables, evidence reader, bounds and outcome lines for local-resolver.mjs (#249 local Inspect, PR L3 Tasks 8-9):
|
||||||
|
// the constants of utils/prompt_pair_resolver.py and utils/inspection_outcomes.py, utils/pair_execution.py
|
||||||
|
// `read_pair_execution` and the semantics table check of utils/inspection_semantics.py. No DOM.
|
||||||
|
import { PyFloat, PyValueError, isDict, isList, isInt, pyJsonLoads, pyLen, pySlice, pyStrip, pyTruthy, pyEqualsOne, pyStr, pyGet } from './py-json.mjs';
|
||||||
|
|
||||||
|
/** The semantics table is unusable: the server's `SemanticsError`. */
|
||||||
|
export class SemanticsError extends Error {
|
||||||
|
constructor(message) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'SemanticsError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// INSPECTION_PROFILE (prompt_pair_resolver.py 152-166); its drop_preview_only_stages is always on here.
|
||||||
|
export const PROFILE = Object.freeze({
|
||||||
|
perCandidateVisits: 2000, totalVisits: 20000, maxDepth: 64, maxTextLength: 100000, nodeCeiling: 20000,
|
||||||
|
maxPathEntries: 64 * 20000, maxBuiltChars: 16 * 100000, maxScannedUnits: 8000000,
|
||||||
|
});
|
||||||
|
export const EXECUTION_KEY = 'comfyassets_prompt_pairs';
|
||||||
|
export const ROLE_METADATA_KEYS = new Set(['prompt', 'parameters', EXECUTION_KEY]);
|
||||||
|
// pair_execution.py
|
||||||
|
export const MAX_EXECUTION_RECORDS = 16, MAX_EXECUTION_NODES = 64, MAX_EXECUTION_TEXT = 100000, MAX_EXECUTION_JSON = 500000;
|
||||||
|
export const EXECUTION_FIELDS = ['text', 'negative_text', 'positive_text', 'negative_effective_text', 'prepend_text', 'append_text'];
|
||||||
|
|
||||||
|
export const CANDIDATE_STRATEGIES = new Set(['ksampler', 'custom-advanced', 'detailer-stage']);
|
||||||
|
export const DETAILER = 'detailer-stage';
|
||||||
|
export const GUIDER_ABSENT_REASON = 'Guider has no negative input';
|
||||||
|
export const ZEROED_SOURCE = 'zeroed-conditioning';
|
||||||
|
export const MAX_BRANCH_CANDIDATES = 32;
|
||||||
|
export const RUNTIME_REASON = 'Text is decided at run time';
|
||||||
|
export const EMPTY_POSITIVE_REASON = 'Positive prompt is empty';
|
||||||
|
export const IDENTICAL_BRANCHES_REASON = 'Every sampler branch uses the same prompt pair';
|
||||||
|
export const MULTISTAGE_UNATTRIBUTED_REASON = 'Multistage sampler ancestry is ambiguous';
|
||||||
|
export const MALFORMED_EVIDENCE_REASON = 'Prompt evidence is malformed';
|
||||||
|
export const UNREADABLE_METADATA_REASON = 'Media metadata could not be read';
|
||||||
|
export const SEMANTICS_UNAVAILABLE_REASON = 'Inspection node semantics are unavailable';
|
||||||
|
export const INCOMPATIBLE_SLOT = 'Output slot has an incompatible type';
|
||||||
|
export const OVERWRITTEN_PROMPT_REASON = 'Saved PromptManager text was overwritten by another node';
|
||||||
|
export const UNVERIFIABLE_CODES = new Set(['unsupported-node', 'runtime-value']);
|
||||||
|
export const MAX_JOIN_INPUTS = 1000;
|
||||||
|
export const SAMPLER_LIKE_INPUTS = ['latent_image', 'guider', 'positive', 'negative'];
|
||||||
|
export const NO_SAMPLER_NAMES = 3;
|
||||||
|
export const CLASS_NAME_LENGTH = 64;
|
||||||
|
export const MAX_SUBGRAPH_DEPTH = 8, MAX_WORKFLOW_NODES = 100000;
|
||||||
|
|
||||||
|
// inspection_outcomes.py
|
||||||
|
export const OUTCOME_NAME_LENGTH = 200;
|
||||||
|
export const LIMIT_CODES = ['work-limit', 'depth-limit', 'text-limit', 'graph-ceiling'];
|
||||||
|
export const 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',
|
||||||
|
};
|
||||||
|
export const CODED_LINES = {
|
||||||
|
...Object.fromEntries(LIMIT_CODES.map(code => [code, '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.',
|
||||||
|
};
|
||||||
|
export const 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.',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Output adapters (prompt_pair_resolver.py 44-105): [inputName, kind, {compose, storageInput, comma, strip, default}].
|
||||||
|
export const adapter = (inputName, kind, options = {}) => Object.freeze({ inputName, kind, compose: false, storageInput: false, comma: false, strip: false, default: null, ...options });
|
||||||
|
export const slotKey = (name, slot) => `${name}\u0000${String(slot)}`;
|
||||||
|
export const ADAPTERS = new Map();
|
||||||
|
for (const name of ['PromptManager', 'PromptManagerFocused', 'PromptManagerSplit']) {
|
||||||
|
ADAPTERS.set(slotKey(name, 0), adapter('text', 'conditioning', { compose: true }));
|
||||||
|
ADAPTERS.set(slotKey(name, 1), adapter('text', 'string', { compose: true }));
|
||||||
|
}
|
||||||
|
ADAPTERS.set(slotKey('PromptManagerPair', 0), adapter('text', 'conditioning', { compose: true }));
|
||||||
|
ADAPTERS.set(slotKey('PromptManagerPair', 2), adapter('text', 'string', { compose: true }));
|
||||||
|
ADAPTERS.set(slotKey('PromptManagerPair', 1), adapter('negative_text', 'conditioning', { storageInput: true }));
|
||||||
|
ADAPTERS.set(slotKey('PromptManagerPair', 3), adapter('negative_text', 'string', { storageInput: true }));
|
||||||
|
ADAPTERS.set(slotKey('PromptManagerText', 0), adapter('text', 'string', { compose: true }));
|
||||||
|
ADAPTERS.set(slotKey('CLIPTextEncode', 0), adapter('text', 'conditioning'));
|
||||||
|
for (const name of ['PrimitiveString', 'PrimitiveStringMultiline']) ADAPTERS.set(slotKey(name, 0), adapter('value', 'string'));
|
||||||
|
for (const name of ['Text Multiline', 'String Literal', 'ShowText|pysssss']) ADAPTERS.set(slotKey(name, 0), adapter('text', 'string'));
|
||||||
|
// Historical PromptManager classes (inspection only).
|
||||||
|
export const COMMA = { comma: true }, NEGATIVE = { strip: true, default: '' };
|
||||||
|
const historical = [
|
||||||
|
['PromptManagerV2', 0, 'text', 'conditioning', COMMA], ['PromptManagerV2', 1, 'negative_text', 'conditioning', NEGATIVE],
|
||||||
|
['PromptManagerV2', 2, 'text', 'string', COMMA], ['PromptManagerV2', 3, 'negative_text', 'string', NEGATIVE],
|
||||||
|
['PromptManagerV2Text', 0, 'text', 'string', COMMA], ['PromptManagerV2Text', 1, 'negative_text', 'string', NEGATIVE],
|
||||||
|
['PromptManagerNegative', 0, 'text', 'conditioning', { strip: true }], ['PromptManagerNegative', 1, 'text', 'string', { strip: true }],
|
||||||
|
['PromptManagerPositiveText', 0, 'text', 'string', COMMA], ['PromptManagerNegativeText', 0, 'text', 'string', COMMA],
|
||||||
|
];
|
||||||
|
for (const name of ['PromptManagerPositive', 'PromptManagerDetailer']) historical.push([name, 0, 'text', 'conditioning', COMMA], [name, 1, 'text', 'string', COMMA]);
|
||||||
|
for (const [name, slot, input, kind, options] of historical) ADAPTERS.set(slotKey(name, slot), adapter(input, kind, options));
|
||||||
|
// `known_slots` in execution_record: every adapter slot per class.
|
||||||
|
export const ADAPTER_SLOTS = new Map();
|
||||||
|
for (const key of ADAPTERS.keys()) {
|
||||||
|
const [name, slot] = key.split('\u0000');
|
||||||
|
if (!ADAPTER_SLOTS.has(name)) ADAPTER_SLOTS.set(name, new Set());
|
||||||
|
ADAPTER_SLOTS.get(name).add(slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Unresolved extends Error {
|
||||||
|
constructor(message, code = null, node = null) {
|
||||||
|
super(message);
|
||||||
|
this.code = code;
|
||||||
|
this.node = node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class Diverging extends Error {
|
||||||
|
constructor(key) {
|
||||||
|
super('diverging');
|
||||||
|
this.key = key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `_link`: a two-item list of (str or int) and a non-negative int, never a bool. */
|
||||||
|
export function isLink(value) {
|
||||||
|
if (!isList(value) || value.length !== 2) return false;
|
||||||
|
const [source, slot] = value;
|
||||||
|
return (typeof source === 'string' || isInt(source)) && isInt(slot) && (typeof slot === 'bigint' ? slot >= 0n : slot >= 0);
|
||||||
|
}
|
||||||
|
export const nodeRef = (nodeId, classType) => ({ id: pySlice(nodeId, CLASS_NAME_LENGTH), class_type: typeof classType === 'string' ? pySlice(classType, CLASS_NAME_LENGTH) : null });
|
||||||
|
|
||||||
|
export function result(state, { positive = null, negative = null, samplerId = null, reason = '', candidates = [], reasonCode = null, node = null } = {}) {
|
||||||
|
return { state, positive, negative, sampler_id: samplerId, reason, candidates, reason_code: reasonCode, node };
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Budget {
|
||||||
|
constructor(total) {
|
||||||
|
this.total = total;
|
||||||
|
this.allowance = total;
|
||||||
|
}
|
||||||
|
reset(allowance) { this.allowance = allowance; }
|
||||||
|
spend() {
|
||||||
|
if (this.total <= 0 || this.allowance <= 0) throw new Unresolved('Resolution visit limit reached', 'work-limit');
|
||||||
|
this.total -= 1;
|
||||||
|
this.allowance -= 1;
|
||||||
|
}
|
||||||
|
charge(cost) {
|
||||||
|
if (cost > this.allowance) throw new Unresolved('Resolution visit limit reached', 'work-limit');
|
||||||
|
this.allowance -= cost;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `read_pair_execution`: one distinct record for a traced node, or null; malformed evidence throws `Unresolved`. */
|
||||||
|
export function readPairExecution(metadata, nodeId) {
|
||||||
|
const evidence = message => new Unresolved(message, 'malformed');
|
||||||
|
if (!isDict(metadata) || !metadata.has(EXECUTION_KEY)) return null;
|
||||||
|
let envelope = metadata.get(EXECUTION_KEY);
|
||||||
|
if (typeof envelope === 'string') {
|
||||||
|
if (pyLen(envelope) > MAX_EXECUTION_JSON) throw evidence('Execution evidence exceeds the size limit');
|
||||||
|
try { envelope = pyJsonLoads(envelope); } catch (error) {
|
||||||
|
if (error instanceof PyValueError) throw evidence('Malformed execution evidence JSON');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!isDict(envelope) || !pyEqualsOne(pyGet(envelope, 'version'))) throw evidence('Unsupported execution evidence format');
|
||||||
|
const nodes = pyGet(envelope, 'nodes');
|
||||||
|
if (pyTruthy(pyGet(envelope, 'truncated')) || !isDict(nodes) || nodes.size > MAX_EXECUTION_NODES) throw evidence('Truncated or malformed execution evidence');
|
||||||
|
if (!nodes.has(nodeId)) return null;
|
||||||
|
const node = nodes.get(nodeId);
|
||||||
|
if (!isDict(node) || pyTruthy(pyGet(node, 'truncated'))) throw evidence('Truncated or malformed node execution evidence');
|
||||||
|
const records = pyGet(node, 'records');
|
||||||
|
if (!isList(records) || !records.length || records.length > MAX_EXECUTION_RECORDS) throw evidence('Malformed node execution records');
|
||||||
|
const unique = new Map();
|
||||||
|
for (const record of records) {
|
||||||
|
if (!isDict(record) || EXECUTION_FIELDS.some(key => typeof pyGet(record, key) !== 'string' || pyLen(record.get(key)) > MAX_EXECUTION_TEXT)) {
|
||||||
|
throw evidence('Malformed execution text values');
|
||||||
|
}
|
||||||
|
const clean = Object.fromEntries(EXECUTION_FIELDS.map(key => [key, record.get(key)]));
|
||||||
|
if (clean.negative_effective_text !== clean.negative_text) throw evidence('Negative execution text must be unchanged');
|
||||||
|
unique.set(JSON.stringify(EXECUTION_FIELDS.map(key => clean[key])), clean);
|
||||||
|
}
|
||||||
|
if (unique.size !== 1) throw evidence('Multiple distinct executions have no image anchor');
|
||||||
|
return [...unique.values()][0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The validated semantics table (utils/inspection_semantics.py), from the parsed JSON document. Throws
|
||||||
|
* `SemanticsError` for any shape the server rejects; the caller then answers "semantics unavailable".
|
||||||
|
*/
|
||||||
|
export function compileSemantics(doc) {
|
||||||
|
const fail = message => { throw new SemanticsError(message); };
|
||||||
|
const plain = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
if (!plain(doc) || doc.schema !== 1) fail('unsupported semantics table');
|
||||||
|
const owners = new Map();
|
||||||
|
const claim = (name, category) => {
|
||||||
|
if (owners.has(name) && owners.get(name) !== category) fail(`duplicate class ${name}`);
|
||||||
|
owners.set(name, category);
|
||||||
|
};
|
||||||
|
const byClass = category => {
|
||||||
|
if (!plain(doc[category])) fail(`category ${category} must be an object`);
|
||||||
|
const table = new Map();
|
||||||
|
for (const [name, entry] of Object.entries(doc[category])) {
|
||||||
|
if (!plain(entry) || typeof entry.strategy !== 'string') fail(`${category}[${name}]`);
|
||||||
|
claim(name, category);
|
||||||
|
table.set(name, entry);
|
||||||
|
}
|
||||||
|
return table;
|
||||||
|
};
|
||||||
|
const bySlot = category => {
|
||||||
|
if (!Array.isArray(doc[category])) fail(`category ${category} must be a list`);
|
||||||
|
const table = new Map();
|
||||||
|
for (const row of doc[category]) {
|
||||||
|
if (!plain(row) || typeof row.class !== 'string' || !Number.isInteger(row.slot) || row.slot < 0 || row.slot > 255) fail(`${category} row`);
|
||||||
|
const key = slotKey(row.class, row.slot);
|
||||||
|
if (table.has(key)) fail(`${category}: duplicate output key`);
|
||||||
|
claim(row.class, category);
|
||||||
|
const { class: _name, slot: _slot, ...entry } = row;
|
||||||
|
table.set(key, entry);
|
||||||
|
}
|
||||||
|
return table;
|
||||||
|
};
|
||||||
|
const bySet = category => {
|
||||||
|
if (!Array.isArray(doc[category])) fail(`category ${category} must be a list`);
|
||||||
|
const set = new Set();
|
||||||
|
for (const name of doc[category]) {
|
||||||
|
if (typeof name !== 'string' || set.has(name)) fail(`${category}: duplicate class`);
|
||||||
|
claim(name, category);
|
||||||
|
set.add(name);
|
||||||
|
}
|
||||||
|
return set;
|
||||||
|
};
|
||||||
|
const samplers = byClass('samplers'), guiders = byClass('guiders'), selectors = byClass('selectors');
|
||||||
|
const composites = byClass('composites'), savers = byClass('savers');
|
||||||
|
const conditioning = bySlot('conditioning'), encoders = bySlot('encoders'), strings = bySlot('strings');
|
||||||
|
const runtime = bySet('runtime'), nonFileOutputs = bySet('non_file_outputs');
|
||||||
|
const stageInputs = doc.stage_inputs;
|
||||||
|
if (!plain(stageInputs) || !Array.isArray(stageInputs.shaped) || !Array.isArray(stageInputs.with_model)) fail('stage_inputs');
|
||||||
|
return Object.freeze({
|
||||||
|
samplers, guiders, selectors, composites, savers, conditioning, encoders, strings, runtime, nonFileOutputs,
|
||||||
|
stageInputs: { shaped: [...stageInputs.shaped], withModel: [...stageInputs.with_model] }, allClasses: new Set(owners.keys()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `outcome_message`: one line for an unresolved outcome, or null to keep the status copy. */
|
||||||
|
export function outcomeMessage(resolution) {
|
||||||
|
if (resolution?.state !== 'unresolved') return null;
|
||||||
|
const code = typeof resolution.reason_code === 'string' ? resolution.reason_code : null;
|
||||||
|
if (Object.hasOwn(NAMED_LINES, code)) {
|
||||||
|
const node = resolution.node, raw = node !== null && typeof node === 'object' ? node.class_type : null;
|
||||||
|
const name = typeof raw === 'string' && raw ? pySlice(raw, OUTCOME_NAME_LENGTH) : null;
|
||||||
|
return name ? `${NAMED_LINES[code]} (${name}).` : `${NAMED_LINES[code]}.`;
|
||||||
|
}
|
||||||
|
if (code === null) return Object.hasOwn(UNCODED_LINES, resolution.reason) ? UNCODED_LINES[resolution.reason] : null;
|
||||||
|
return Object.hasOwn(CODED_LINES, code) ? CODED_LINES[code] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sideOf = (status, text = null, source = null) => ({ status, text, source });
|
||||||
|
export function explained(prompts, resolution) {
|
||||||
|
const message = outcomeMessage(resolution);
|
||||||
|
if (message === null) return prompts;
|
||||||
|
return Object.fromEntries(Object.entries(prompts).map(([role, side]) => [role, side.status === 'unresolved' && !Object.hasOwn(side, 'message') ? { ...side, message } : side]));
|
||||||
|
}
|
||||||
|
export const EMPTY_POSITIVE = outcomeMessage({ state: 'unresolved', reason: EMPTY_POSITIVE_REASON });
|
||||||
@@ -0,0 +1,772 @@
|
|||||||
|
// The server's inspection resolver in this browser (#249 local Inspect, PR L3 Task 8, decision LI-1): a port of
|
||||||
|
// utils/prompt_inspector.py `inspect_prompt_evidence` with utils/prompt_pair_resolver.py (INSPECTION_PROFILE) and
|
||||||
|
// utils/pair_execution.py `read_pair_execution`, driven by web/js/inspection-semantics.json. Python is the oracle:
|
||||||
|
// tests/test_local_inspect_parity.py compares every answer with the server's on the same records. Values follow
|
||||||
|
// Python's model (py-json.mjs): dict order, int versus float, code-point lengths, `str.strip()`.
|
||||||
|
//
|
||||||
|
// Task 8 ported execution evidence and graph tracing for one sampler; Task 9 adds candidate selection among several
|
||||||
|
// samplers and detailer stages (local-resolver-stages.mjs) and A1111 `parameters` (local-parameters.mjs). No DOM.
|
||||||
|
import { PyValueError, isDict, isList, isInt, pyJsonLoads, pyLen, pySlice, pyStrip, pyStr, pyGet } from './py-json.mjs';
|
||||||
|
import {
|
||||||
|
ADAPTERS,
|
||||||
|
ADAPTER_SLOTS,
|
||||||
|
Budget,
|
||||||
|
CANDIDATE_STRATEGIES,
|
||||||
|
CLASS_NAME_LENGTH,
|
||||||
|
Diverging,
|
||||||
|
EMPTY_POSITIVE,
|
||||||
|
EMPTY_POSITIVE_REASON,
|
||||||
|
GUIDER_ABSENT_REASON,
|
||||||
|
INCOMPATIBLE_SLOT,
|
||||||
|
MALFORMED_EVIDENCE_REASON,
|
||||||
|
MAX_BRANCH_CANDIDATES,
|
||||||
|
MAX_JOIN_INPUTS,
|
||||||
|
MAX_SUBGRAPH_DEPTH,
|
||||||
|
MAX_WORKFLOW_NODES,
|
||||||
|
MULTISTAGE_UNATTRIBUTED_REASON,
|
||||||
|
NO_SAMPLER_NAMES,
|
||||||
|
OVERWRITTEN_PROMPT_REASON,
|
||||||
|
PROFILE,
|
||||||
|
ROLE_METADATA_KEYS,
|
||||||
|
RUNTIME_REASON,
|
||||||
|
SAMPLER_LIKE_INPUTS,
|
||||||
|
SEMANTICS_UNAVAILABLE_REASON,
|
||||||
|
UNREADABLE_METADATA_REASON,
|
||||||
|
UNVERIFIABLE_CODES,
|
||||||
|
Unresolved,
|
||||||
|
ZEROED_SOURCE,
|
||||||
|
compileSemantics,
|
||||||
|
explained,
|
||||||
|
isLink,
|
||||||
|
nodeRef,
|
||||||
|
readPairExecution,
|
||||||
|
result,
|
||||||
|
sideOf,
|
||||||
|
slotKey,
|
||||||
|
} from './local-resolver-support.mjs';
|
||||||
|
import { finalStages, outcomeKey, sharedPair, stage } from './local-resolver-stages.mjs';
|
||||||
|
import { parameters } from './local-parameters.mjs';
|
||||||
|
|
||||||
|
export { SemanticsError, compileSemantics, readPairExecution, outcomeMessage, isLink } from './local-resolver-support.mjs';
|
||||||
|
|
||||||
|
class GraphResolver {
|
||||||
|
constructor(graph, metadata, semantics, overwritten) {
|
||||||
|
this.graph = graph;
|
||||||
|
this.overwritten = overwritten;
|
||||||
|
this.perCandidateVisits = PROFILE.perCandidateVisits;
|
||||||
|
this.budget = new Budget(PROFILE.totalVisits);
|
||||||
|
this.budget.reset(PROFILE.perCandidateVisits);
|
||||||
|
this.pathEntries = PROFILE.maxPathEntries;
|
||||||
|
this.builtChars = PROFILE.maxBuiltChars;
|
||||||
|
this.scanned = PROFILE.maxScannedUnits;
|
||||||
|
this.checked = new Map();
|
||||||
|
this.costs = new Map();
|
||||||
|
this.heights = new Map();
|
||||||
|
this.deepest = 0;
|
||||||
|
this.maxDepth = PROFILE.maxDepth;
|
||||||
|
this.maxTextLength = PROFILE.maxTextLength;
|
||||||
|
this.memo = new Map();
|
||||||
|
this.active = new Set();
|
||||||
|
this.metadata = metadata ?? new Map();
|
||||||
|
this.textSources = new Map();
|
||||||
|
this.choices = new Map();
|
||||||
|
this.tainted = false;
|
||||||
|
this.latentWalks = new Map();
|
||||||
|
this.latentCharged = new Set();
|
||||||
|
this.semantics = semantics;
|
||||||
|
this.samplerClasses = new Set([...semantics.samplers].filter(([, entry]) => CANDIDATE_STRATEGIES.has(entry.strategy)).map(([name]) => name));
|
||||||
|
this.stageClasses = new Set(semantics.samplers.keys());
|
||||||
|
}
|
||||||
|
|
||||||
|
node(nodeId) {
|
||||||
|
if (!this.checked.has(nodeId)) {
|
||||||
|
try {
|
||||||
|
this.checked.set(nodeId, this.checkedNode(nodeId));
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof Unresolved) || error.code === 'work-limit') throw error;
|
||||||
|
this.checked.set(nodeId, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const checked = this.checked.get(nodeId);
|
||||||
|
if (checked instanceof Unresolved) throw new Unresolved(checked.message, checked.code, checked.node);
|
||||||
|
return checked;
|
||||||
|
}
|
||||||
|
checkedNode(nodeId) {
|
||||||
|
const node = pyGet(this.graph, nodeId);
|
||||||
|
if (!isDict(node) || !isDict(pyGet(node, 'inputs'))) throw new Unresolved('Missing or malformed graph node', 'malformed');
|
||||||
|
const inputs = node.get('inputs');
|
||||||
|
this.scan(inputs.size);
|
||||||
|
return [pyStr(pyGet(node, 'class_type', '')), inputs];
|
||||||
|
}
|
||||||
|
scan(units) {
|
||||||
|
if (units > this.scanned) {
|
||||||
|
this.scanned = -1;
|
||||||
|
throw new Unresolved('Resolution work limit reached', 'work-limit');
|
||||||
|
}
|
||||||
|
this.scanned -= units;
|
||||||
|
}
|
||||||
|
unsupported(message, nodeId) { return this.named(message, 'unsupported-node', nodeId); }
|
||||||
|
named(message, code, nodeId) {
|
||||||
|
const node = pyGet(this.graph, nodeId);
|
||||||
|
const classType = isDict(node) ? pyGet(node, 'class_type') : null;
|
||||||
|
return new Unresolved(message, code, nodeRef(nodeId, classType));
|
||||||
|
}
|
||||||
|
bounded(text) {
|
||||||
|
if (pyLen(text) > this.maxTextLength) throw new Unresolved('Resolved text exceeds the length limit', 'text-limit');
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
built(text) {
|
||||||
|
this.builtChars -= pyLen(text);
|
||||||
|
if (this.builtChars < 0) throw new Unresolved('Resolution work limit reached', 'work-limit');
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `text`: [text, path, storageText] for a string value or a typed link. */
|
||||||
|
text(value, kind, depth = 0) {
|
||||||
|
this.deepest = Math.max(this.deepest, depth);
|
||||||
|
if (depth >= this.maxDepth) throw new Unresolved('Resolution depth limit reached', 'depth-limit');
|
||||||
|
if (typeof value === 'string' && kind === 'string') return [this.bounded(value), [], value];
|
||||||
|
if (!isLink(value)) throw new Unresolved('Expected a typed graph link or text input', 'malformed');
|
||||||
|
const nodeId = String(value[0]), slot = value[1];
|
||||||
|
const key = `${slotKey(nodeId, slot)}\u0000${kind}`;
|
||||||
|
if (this.active.has(key)) throw new Unresolved('Cyclic prompt graph', 'malformed');
|
||||||
|
if (this.memo.has(key)) {
|
||||||
|
const height = this.heights.get(key);
|
||||||
|
if (depth + height >= this.maxDepth) throw new Unresolved('Resolution depth limit reached', 'depth-limit');
|
||||||
|
this.budget.charge(this.costs.get(key));
|
||||||
|
this.deepest = Math.max(this.deepest, depth + height);
|
||||||
|
return this.memo.get(key);
|
||||||
|
}
|
||||||
|
const allowance = this.budget.allowance, outerDeepest = this.deepest, outerTainted = this.tainted;
|
||||||
|
this.tainted = false;
|
||||||
|
this.deepest = depth;
|
||||||
|
try {
|
||||||
|
this.budget.spend();
|
||||||
|
const [name, inputs] = this.node(nodeId);
|
||||||
|
this.scan(inputs.size);
|
||||||
|
if (this.overwritten.has(nodeId)) throw new Unresolved(OVERWRITTEN_PROMPT_REASON, 'malformed');
|
||||||
|
this.active.add(key);
|
||||||
|
let text, path, storageText;
|
||||||
|
const adapted = ADAPTERS.get(slotKey(name, slot));
|
||||||
|
if (name === 'Reroute' && slot === 0) {
|
||||||
|
const keys = ['value', 'input', 'in'].filter(item => inputs.has(item));
|
||||||
|
if (keys.length !== 1) throw new Unresolved('Reroute input is not uniquely identifiable', 'malformed');
|
||||||
|
[text, path, storageText] = this.text(inputs.get(keys[0]), kind, depth + 1);
|
||||||
|
this.inheritSource(nodeId, slot, [path]);
|
||||||
|
} else if (adapted) {
|
||||||
|
if (adapted.kind !== kind) throw new Unresolved(INCOMPATIBLE_SLOT, 'malformed');
|
||||||
|
const evidence = name === 'PromptManagerPair' ? this.executionRecord(nodeId, inputs) : null;
|
||||||
|
if (evidence !== null) {
|
||||||
|
text = evidence[adapted.inputName === 'text' ? 'positive_text' : 'negative_effective_text'];
|
||||||
|
storageText = evidence[adapted.inputName];
|
||||||
|
path = [];
|
||||||
|
this.textSources.set(slotKey(nodeId, slot), 'execution');
|
||||||
|
} else if (adapted.comma || adapted.strip) {
|
||||||
|
[text, path, storageText] = this.historical(adapted, inputs, depth);
|
||||||
|
} else {
|
||||||
|
[text, path, storageText] = this.text(pyGet(inputs, adapted.inputName), 'string', depth + 1);
|
||||||
|
if (adapted.compose || adapted.storageInput) storageText = text;
|
||||||
|
if (adapted.compose) {
|
||||||
|
const [before, beforePath] = this.text(pyGet(inputs, 'prepend_text', ''), 'string', depth + 1);
|
||||||
|
const [after, afterPath] = this.text(pyGet(inputs, 'append_text', ''), 'string', depth + 1);
|
||||||
|
this.scan(pyLen(before) + pyLen(after) + beforePath.length + path.length + afterPath.length);
|
||||||
|
text = this.built(this.bounded([pyStrip(before), text, pyStrip(after)].filter(part => part).join(' ')));
|
||||||
|
path = [...beforePath, ...path, ...afterPath];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if ((name === 'StringConcatenate' || name === 'Text Concatenate') && slot === 0 && kind === 'string') {
|
||||||
|
[text, path] = this.concatenate(name, inputs, depth);
|
||||||
|
storageText = text;
|
||||||
|
} else if (this.follows(name, slot, kind)) {
|
||||||
|
[text, path, storageText] = this.semantic(nodeId, name, slot, kind, inputs, depth);
|
||||||
|
} else {
|
||||||
|
throw this.unsupported('Unsupported node semantics or output slot', nodeId);
|
||||||
|
}
|
||||||
|
this.scan(path.length + 1);
|
||||||
|
path = [{ node_id: nodeId, output_slot: slot }, ...path];
|
||||||
|
const pathLimit = this.maxDepth * Math.max(1, this.perCandidateVisits);
|
||||||
|
this.pathEntries -= path.length;
|
||||||
|
if (path.length > pathLimit || this.pathEntries < 0) throw new Unresolved('Provenance path limit reached', 'work-limit');
|
||||||
|
const traced = [text, path, storageText];
|
||||||
|
if (!this.tainted) {
|
||||||
|
this.memo.set(key, traced);
|
||||||
|
this.costs.set(key, allowance - this.budget.allowance);
|
||||||
|
this.heights.set(key, this.deepest - depth);
|
||||||
|
}
|
||||||
|
return traced;
|
||||||
|
} finally {
|
||||||
|
this.deepest = Math.max(outerDeepest, this.deepest);
|
||||||
|
this.tainted = outerTainted || this.tainted;
|
||||||
|
this.active.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `StringConcatenate` / `Text Concatenate` (the `text` branch). */
|
||||||
|
concatenate(name, inputs, depth) {
|
||||||
|
let keys, delimiter, clean;
|
||||||
|
if (name === 'StringConcatenate') {
|
||||||
|
keys = ['string_a', 'string_b'];
|
||||||
|
delimiter = pyGet(inputs, 'delimiter', '');
|
||||||
|
clean = false;
|
||||||
|
} else {
|
||||||
|
keys = [...inputs.keys()].filter(item => /^text_[a-z]$/.test(item)).sort();
|
||||||
|
delimiter = pyGet(inputs, 'delimiter', ' ');
|
||||||
|
clean = pyStr(pyGet(inputs, 'clean_whitespace', 'true')).toLowerCase() === 'true';
|
||||||
|
}
|
||||||
|
if (!keys.length || typeof delimiter !== 'string') throw new Unresolved('Malformed concatenation', 'malformed');
|
||||||
|
const parts = [], path = [];
|
||||||
|
let size = 0;
|
||||||
|
for (const inputName of keys) {
|
||||||
|
let [part, followed] = this.text(pyGet(inputs, inputName, ''), 'string', depth + 1);
|
||||||
|
this.scan(pyLen(part) + followed.length);
|
||||||
|
if (clean) part = pyStrip(part);
|
||||||
|
path.push(...followed);
|
||||||
|
if (part || name === 'StringConcatenate') {
|
||||||
|
size += pyLen(part) + (parts.length ? pyLen(delimiter) : 0);
|
||||||
|
if (size > this.maxTextLength) throw new Unresolved('Resolved text exceeds the length limit', 'text-limit');
|
||||||
|
parts.push(part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [this.built(parts.join(delimiter)), path];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `prompt_manager_*.py` at 07a5af3: strip, then join with ", ". */
|
||||||
|
historical(adapted, inputs, depth) {
|
||||||
|
let [text, path] = this.text(pyGet(inputs, adapted.inputName, adapted.default), 'string', depth + 1);
|
||||||
|
this.scan(pyLen(text));
|
||||||
|
text = pyStrip(text);
|
||||||
|
const storageText = text;
|
||||||
|
if (adapted.comma) {
|
||||||
|
let [before, beforePath] = this.text(pyGet(inputs, 'prepend_text') ?? '', 'string', depth + 1);
|
||||||
|
let [after, afterPath] = this.text(pyGet(inputs, 'append_text') ?? '', 'string', depth + 1);
|
||||||
|
this.scan(pyLen(before) + pyLen(after) + beforePath.length + afterPath.length);
|
||||||
|
before = pyStrip(before); after = pyStrip(after);
|
||||||
|
if (before) text = `${before}${text && !before.endsWith(',') ? ', ' : ''}${text}`;
|
||||||
|
if (after) text = `${text}${text && !text.endsWith(',') ? ', ' : ''}${after}`;
|
||||||
|
text = this.built(this.bounded(text));
|
||||||
|
path = [...beforePath, ...path, ...afterPath];
|
||||||
|
}
|
||||||
|
return [text, path, storageText];
|
||||||
|
}
|
||||||
|
|
||||||
|
follows(name, slot, kind) {
|
||||||
|
const semantics = this.semantics, key = slotKey(name, slot);
|
||||||
|
if (semantics.runtime.has(name)) return true;
|
||||||
|
if (semantics.encoders.has(key) || semantics.strings.has(key)) return true;
|
||||||
|
if (slot === 0 && semantics.selectors.has(name)) return true;
|
||||||
|
return kind === 'conditioning' && (semantics.conditioning.has(key) || (slot === 0 && semantics.composites.has(name)));
|
||||||
|
}
|
||||||
|
|
||||||
|
semantic(nodeId, name, slot, kind, inputs, depth) {
|
||||||
|
const semantics = this.semantics, key = slotKey(name, slot);
|
||||||
|
if (semantics.runtime.has(name)) throw this.named(RUNTIME_REASON, 'runtime-value', nodeId);
|
||||||
|
for (const [table, expected, build] of [[semantics.encoders, 'conditioning', 'encoded'], [semantics.strings, 'string', 'stringed']]) {
|
||||||
|
const listed = table.get(key);
|
||||||
|
if (listed !== undefined) {
|
||||||
|
if (kind !== expected) throw new Unresolved(INCOMPATIBLE_SLOT, 'malformed');
|
||||||
|
return this[build](nodeId, listed, inputs, depth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const entry = kind === 'conditioning' ? semantics.conditioning.get(key) : undefined;
|
||||||
|
if (entry !== undefined && entry.strategy === 'zeroed') {
|
||||||
|
this.textSources.set(slotKey(nodeId, slot), ZEROED_SOURCE);
|
||||||
|
return ['', [], ''];
|
||||||
|
}
|
||||||
|
if (entry !== undefined) {
|
||||||
|
const traced = this.text(pyGet(inputs, entry.input), kind, depth + 1);
|
||||||
|
this.inheritSource(nodeId, slot, [traced[1]]);
|
||||||
|
return traced;
|
||||||
|
}
|
||||||
|
if (semantics.selectors.has(name)) return this.select(nodeId, semantics.selectors.get(name), inputs, kind, depth);
|
||||||
|
const parts = semantics.composites.get(name).inputs.map(inputName => this.text(pyGet(inputs, inputName), kind, depth + 1));
|
||||||
|
if (new Set(parts.map(part => part[0])).size !== 1) throw this.named('Combined conditioning has different texts', 'composite', nodeId);
|
||||||
|
return this.merged(nodeId, slot, parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
select(nodeId, entry, inputs, kind, depth) {
|
||||||
|
const condition = pyGet(inputs, entry.condition), branches = entry.branches;
|
||||||
|
let choice;
|
||||||
|
if (typeof condition === 'boolean') choice = condition;
|
||||||
|
else if (!isLink(condition)) throw new Unresolved('Malformed selector condition', 'malformed');
|
||||||
|
else if (this.choices.has(slotKey(String(condition[0]), condition[1]))) {
|
||||||
|
choice = this.choices.get(slotKey(String(condition[0]), condition[1]));
|
||||||
|
this.tainted = true;
|
||||||
|
} else choice = null;
|
||||||
|
if (choice !== null) {
|
||||||
|
const traced = this.text(pyGet(inputs, branches[choice ? 'true' : 'false']), kind, depth + 1);
|
||||||
|
this.inheritSource(nodeId, 0, [traced[1]]);
|
||||||
|
return traced;
|
||||||
|
}
|
||||||
|
const parts = ['true', 'false'].map(label => this.text(pyGet(inputs, branches[label]), kind, depth + 1));
|
||||||
|
if (parts[0][0] !== parts[1][0]) throw new Diverging(slotKey(String(condition[0]), condition[1]));
|
||||||
|
return this.merged(nodeId, 0, parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
merged(nodeId, slot, parts) {
|
||||||
|
const traced = this.joinedPaths(parts, parts);
|
||||||
|
this.inheritSource(nodeId, slot, parts.map(part => part[1]));
|
||||||
|
return traced;
|
||||||
|
}
|
||||||
|
|
||||||
|
joinedPaths(chosen, parts) {
|
||||||
|
const text = chosen[0][0];
|
||||||
|
const storages = new Set(chosen.map(part => part[2]));
|
||||||
|
const storageText = storages.size === 1 ? chosen[0][2] : text;
|
||||||
|
const paths = parts.map(part => part[1]);
|
||||||
|
this.scan(paths.reduce((sum, path) => sum + path.length, 0));
|
||||||
|
const merged = [], seen = new Set();
|
||||||
|
for (const path of paths) {
|
||||||
|
for (const step of path) {
|
||||||
|
const identity = slotKey(step.node_id, step.output_slot);
|
||||||
|
if (!seen.has(identity)) { seen.add(identity); merged.push(step); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [text, merged, storageText];
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded(nodeId, entry, inputs, depth) {
|
||||||
|
const names = entry.inputs;
|
||||||
|
if (entry.strategy === 'single') return this.text(pyGet(inputs, names[0]), 'string', depth + 1);
|
||||||
|
if (entry.strategy === 'flag-parts') return this.flagged(nodeId, entry, inputs, depth);
|
||||||
|
if (entry.strategy !== 'parts-equal') throw this.unsupported('Unsupported encoder strategy', nodeId);
|
||||||
|
const parts = names.map(name => this.text(pyGet(inputs, name), 'string', depth + 1));
|
||||||
|
this.scan(parts.reduce((sum, part) => sum + pyLen(part[0]), 0));
|
||||||
|
if (new Set(parts.map(part => part[0])).size === 1) return this.joinedPaths(parts, parts);
|
||||||
|
const filled = parts.filter(part => part[0] !== '');
|
||||||
|
if (filled.length === 1) return this.joinedPaths(filled, parts);
|
||||||
|
throw this.named('Encoder parts have different texts', 'composite', nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
flagged(nodeId, entry, inputs, depth) {
|
||||||
|
const names = entry.inputs;
|
||||||
|
if (!this.setting(nodeId, inputs, entry.params.flag, 'bool')) return this.text(pyGet(inputs, names[0]), 'string', depth + 1);
|
||||||
|
const parts = names.map(name => this.text(pyGet(inputs, name), 'string', depth + 1));
|
||||||
|
this.scan(parts.reduce((sum, part) => sum + pyLen(part[0]), 0));
|
||||||
|
if (new Set(parts.map(part => part[0])).size === 1) return this.joinedPaths(parts, parts);
|
||||||
|
throw this.named(RUNTIME_REASON, 'runtime-value', nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
stringed(nodeId, entry, inputs, depth) {
|
||||||
|
const strategy = entry.strategy, source = entry.inputs[0];
|
||||||
|
if (strategy === 'literal') return this.text(pyGet(inputs, source), 'string', depth + 1);
|
||||||
|
if (strategy === 'strip-newlines') {
|
||||||
|
const flag = this.setting(nodeId, inputs, entry.params.flag, 'bool');
|
||||||
|
const traced = this.text(pyGet(inputs, source), 'string', depth + 1);
|
||||||
|
if (!flag) return traced;
|
||||||
|
this.scan(pyLen(traced[0]));
|
||||||
|
const text = this.built(this.bounded(pyStrip(traced[0].replaceAll('\n', ''))));
|
||||||
|
return [text, traced[1], text];
|
||||||
|
}
|
||||||
|
if (strategy === 'join-multi') return this.joinMulti(nodeId, entry, inputs, depth);
|
||||||
|
throw this.unsupported('Unsupported string strategy', nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A literal node setting of exactly `type(value) is kind`; linked is `unsupported-node`, else malformed. */
|
||||||
|
setting(nodeId, inputs, name, kind) {
|
||||||
|
if (!inputs.has(name)) throw new Unresolved('Missing string node setting', 'malformed');
|
||||||
|
const value = inputs.get(name);
|
||||||
|
if (isLink(value)) throw this.unsupported('Linked string node setting', nodeId);
|
||||||
|
const typed = { bool: typeof value === 'boolean', int: isInt(value), str: typeof value === 'string' }[kind];
|
||||||
|
if (!typed) throw new Unresolved('Malformed string node setting', 'malformed');
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
joinMulti(nodeId, entry, inputs, depth) {
|
||||||
|
const params = entry.params, flag = params.list_flag;
|
||||||
|
if (!inputs.has(flag)) throw new Unresolved('Missing string node setting', 'malformed');
|
||||||
|
if (isLink(inputs.get(flag)) || inputs.get(flag) === true) throw this.named(RUNTIME_REASON, 'runtime-value', nodeId);
|
||||||
|
if (inputs.get(flag) !== false) throw new Unresolved('Malformed string node setting', 'malformed');
|
||||||
|
const count = this.setting(nodeId, inputs, params.count, 'int');
|
||||||
|
const delimiter = this.setting(nodeId, inputs, params.delimiter, 'str');
|
||||||
|
if (!(count >= 1 && count <= MAX_JOIN_INPUTS)) throw new Unresolved('Malformed string node setting', 'malformed');
|
||||||
|
const total = Number(count);
|
||||||
|
this.scan(total);
|
||||||
|
const names = [entry.inputs[0]];
|
||||||
|
for (let index = 2; index <= total; index++) names.push(`${params.input_prefix}${index}`);
|
||||||
|
const parts = [], path = [], seen = new Set();
|
||||||
|
let size = 0;
|
||||||
|
names.forEach((inputName, position) => {
|
||||||
|
if (position && !inputs.has(inputName)) return;
|
||||||
|
const [part, followed] = this.text(pyGet(inputs, inputName), 'string', depth + 1);
|
||||||
|
this.scan(pyLen(part) + followed.length);
|
||||||
|
for (const step of followed) {
|
||||||
|
const identity = slotKey(step.node_id, step.output_slot);
|
||||||
|
if (!seen.has(identity)) { seen.add(identity); path.push(step); }
|
||||||
|
}
|
||||||
|
if (position && !part) return;
|
||||||
|
size += pyLen(part) + (parts.length ? pyLen(delimiter) : 0);
|
||||||
|
if (size > this.maxTextLength) throw new Unresolved('Resolved text exceeds the length limit', 'text-limit');
|
||||||
|
parts.push(part);
|
||||||
|
});
|
||||||
|
const text = this.built(parts.join(delimiter));
|
||||||
|
return [text, path, text];
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceOf(path) {
|
||||||
|
if (!path.length) return 'graph-inferred';
|
||||||
|
return this.textSources.get(slotKey(path[0].node_id, path[0].output_slot)) ?? 'graph-inferred';
|
||||||
|
}
|
||||||
|
inheritSource(nodeId, slot, paths) {
|
||||||
|
const sources = new Set(paths.map(path => this.sourceOf(path)));
|
||||||
|
if (sources.size === 1) this.textSources.set(slotKey(nodeId, slot), [...sources][0]);
|
||||||
|
else this.textSources.delete(slotKey(nodeId, slot));
|
||||||
|
}
|
||||||
|
unverifiable(error) { return UNVERIFIABLE_CODES.has(error.code); }
|
||||||
|
|
||||||
|
executionRecord(nodeId, inputs) {
|
||||||
|
const record = readPairExecution(this.metadata, nodeId);
|
||||||
|
if (record === null) return null;
|
||||||
|
for (const name of ['text', 'negative_text', 'prepend_text', 'append_text']) {
|
||||||
|
let expected;
|
||||||
|
try {
|
||||||
|
expected = this.text(pyGet(inputs, name, ''), 'string')[0];
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Unresolved && this.unverifiable(error)) {
|
||||||
|
const value = pyGet(inputs, name);
|
||||||
|
if (isLink(value)) {
|
||||||
|
const [sourceName] = this.node(String(value[0]));
|
||||||
|
const known = ADAPTER_SLOTS.get(sourceName);
|
||||||
|
if (known && !known.has(String(value[1]))) throw error;
|
||||||
|
if (['StringConcatenate', 'Text Concatenate', 'Reroute'].includes(sourceName) && value[1] !== 0) throw error;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (name === 'prepend_text' || name === 'append_text') expected = pyStrip(expected);
|
||||||
|
if (expected !== record[name]) throw new Unresolved('Execution evidence does not match recorded node inputs', 'malformed');
|
||||||
|
}
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
side(value, samplerId, guiderId = null, depth = 0) {
|
||||||
|
const [text, path, storageText] = this.text(value, 'conditioning', depth);
|
||||||
|
const origin = path[0];
|
||||||
|
const provenance = {
|
||||||
|
format: 'comfyui', sampler_id: samplerId, ...origin, path,
|
||||||
|
text_source: this.textSources.get(slotKey(origin.node_id, origin.output_slot)) ?? 'graph-inferred',
|
||||||
|
};
|
||||||
|
if (guiderId !== null) provenance.guider_id = guiderId;
|
||||||
|
return { text, storage_text: storageText, provenance };
|
||||||
|
}
|
||||||
|
|
||||||
|
guider(value) {
|
||||||
|
const seen = new Set();
|
||||||
|
let depth = 0;
|
||||||
|
for (;;) {
|
||||||
|
if (depth >= this.maxDepth) throw new Unresolved('Resolution depth limit reached', 'depth-limit');
|
||||||
|
if (!isLink(value)) throw new Unresolved('Expected a guider link', 'malformed');
|
||||||
|
const nodeId = String(value[0]), slot = value[1];
|
||||||
|
if (seen.has(nodeId)) throw new Unresolved('Cyclic guider ancestry', 'malformed');
|
||||||
|
seen.add(nodeId);
|
||||||
|
this.budget.spend();
|
||||||
|
const [name, inputs] = this.node(nodeId);
|
||||||
|
this.scan(inputs.size);
|
||||||
|
depth += 1;
|
||||||
|
if (name === 'Reroute' && slot === 0) {
|
||||||
|
const keys = ['value', 'input', 'in'].filter(item => inputs.has(item));
|
||||||
|
if (keys.length !== 1) throw new Unresolved('Reroute input is not uniquely identifiable', 'malformed');
|
||||||
|
value = inputs.get(keys[0]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const entry = this.semantics.guiders.get(name);
|
||||||
|
if (entry === undefined || slot !== 0) throw this.unsupported('Unsupported guider', nodeId);
|
||||||
|
return [nodeId, inputs, { positive: entry.positive, negative: entry.negative ?? null }, entry.absent_reason ?? GUIDER_ABSENT_REASON, depth];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pair(samplerId) {
|
||||||
|
this.budget.reset(this.perCandidateVisits);
|
||||||
|
this.latentCharged = new Set();
|
||||||
|
if (this.budget.total <= 0) return result('unresolved', { samplerId, reason: 'Resolution visit limit reached', reasonCode: 'work-limit' });
|
||||||
|
return this.branches(samplerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
branches(samplerId) {
|
||||||
|
const pending = [new Map()], results = [];
|
||||||
|
try {
|
||||||
|
while (pending.length) {
|
||||||
|
this.choices = pending.shift();
|
||||||
|
try {
|
||||||
|
results.push(stage(this, samplerId, new Set()));
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof Diverging)) throw error;
|
||||||
|
if (results.length + pending.length + 2 > MAX_BRANCH_CANDIDATES) throw new Unresolved('Too many selector branches', 'work-limit');
|
||||||
|
for (const choice of [true, false]) pending.push(new Map([...this.choices, [error.key, choice]]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof Unresolved)) throw error;
|
||||||
|
return result('unresolved', { samplerId, reason: error.message, reasonCode: error.code, node: error.node });
|
||||||
|
} finally {
|
||||||
|
this.choices = new Map();
|
||||||
|
}
|
||||||
|
if (results.every(item => item.state === 'unresolved')) return results[0];
|
||||||
|
if (new Set(results.map(outcomeKey)).size === 1) return results[0];
|
||||||
|
return result('ambiguous', { samplerId, reason: 'Selector branches have different prompts', candidates: results });
|
||||||
|
}
|
||||||
|
|
||||||
|
sides(samplerId) {
|
||||||
|
let positive = null, negative = null;
|
||||||
|
try {
|
||||||
|
let [name, inputs] = this.node(samplerId);
|
||||||
|
const entry = this.semantics.samplers.get(name);
|
||||||
|
this.checkOverrides(samplerId, entry, inputs);
|
||||||
|
const latent = entry.latent;
|
||||||
|
if (latent !== undefined && latent !== null && inputs.has(latent)) this.latentChecked(samplerId, inputs.get(latent));
|
||||||
|
let guiderId = null, depth = 0, conditioning = entry.conditioning, absentReason = 'Negative input is absent';
|
||||||
|
if (entry.strategy === 'custom-advanced') {
|
||||||
|
let guiderReason;
|
||||||
|
[guiderId, inputs, conditioning, guiderReason, depth] = this.guider(pyGet(inputs, conditioning.guider));
|
||||||
|
if (conditioning.negative === null) absentReason = guiderReason;
|
||||||
|
}
|
||||||
|
positive = this.side(pyGet(inputs, conditioning.positive), samplerId, guiderId, depth);
|
||||||
|
if (!pyStrip(positive.text)) throw new Unresolved(EMPTY_POSITIVE_REASON);
|
||||||
|
const negativeName = conditioning.negative;
|
||||||
|
if (negativeName === null || negativeName === undefined || !inputs.has(negativeName)) {
|
||||||
|
return result('absent', { positive, samplerId, reason: absentReason });
|
||||||
|
}
|
||||||
|
negative = this.side(inputs.get(negativeName), samplerId, guiderId, depth);
|
||||||
|
return result('resolved', { positive, negative, samplerId });
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof Unresolved)) throw error;
|
||||||
|
return result('unresolved', { positive, negative, samplerId, reason: error.message, reasonCode: error.code, node: error.node });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkOverrides(samplerId, entry, inputs) {
|
||||||
|
for (const name of entry.prompt_overrides ?? []) {
|
||||||
|
const value = pyGet(inputs, name);
|
||||||
|
if (value === null || value === '') continue;
|
||||||
|
if (isLink(value)) throw this.unsupported('Unsupported prompt override', String(value[0]));
|
||||||
|
throw this.named(RUNTIME_REASON, 'runtime-value', samplerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
latentChecked(samplerId, value) {
|
||||||
|
let error;
|
||||||
|
if (this.latentWalks.has(samplerId)) {
|
||||||
|
const [walked, cost] = this.latentWalks.get(samplerId);
|
||||||
|
error = walked;
|
||||||
|
if (!this.latentCharged.has(samplerId)) {
|
||||||
|
this.budget.charge(cost);
|
||||||
|
this.latentCharged.add(samplerId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const before = this.budget.allowance;
|
||||||
|
try {
|
||||||
|
this.latentStages(samplerId, value);
|
||||||
|
error = null;
|
||||||
|
} catch (raised) {
|
||||||
|
if (!(raised instanceof Unresolved) || raised.code === 'work-limit') throw raised;
|
||||||
|
error = raised;
|
||||||
|
}
|
||||||
|
this.latentWalks.set(samplerId, [error, before - this.budget.allowance]);
|
||||||
|
this.latentCharged.add(samplerId);
|
||||||
|
}
|
||||||
|
if (error !== null) throw new Unresolved(error.message, error.code, error.node === null ? null : { ...error.node });
|
||||||
|
}
|
||||||
|
|
||||||
|
enqueue(queue, queued, inputs, depth) {
|
||||||
|
this.scan(inputs.size);
|
||||||
|
for (const item of inputs.values()) {
|
||||||
|
if (!isLink(item)) continue;
|
||||||
|
const key = String(item[0]);
|
||||||
|
if (!queued.has(key)) { queued.add(key); queue.push([key, depth]); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stageShaped(name, inputs) {
|
||||||
|
if (this.semantics.allClasses.has(name)) return false;
|
||||||
|
const { shaped, withModel } = this.semantics.stageInputs;
|
||||||
|
if (shaped.some(key => isLink(pyGet(inputs, key)))) return true;
|
||||||
|
return isLink(pyGet(inputs, 'model')) && withModel.some(key => isLink(pyGet(inputs, key)));
|
||||||
|
}
|
||||||
|
|
||||||
|
latentStages(origin, value) {
|
||||||
|
if (!isLink(value)) throw new Unresolved('Unknown latent ancestry', 'malformed');
|
||||||
|
const start = String(value[0]), queue = [[start, 0]], queued = new Set([start]);
|
||||||
|
for (let head = 0; head < queue.length; head++) {
|
||||||
|
const [nodeId, depth] = queue[head];
|
||||||
|
if (nodeId === origin) throw new Unresolved('Cyclic latent ancestry', 'malformed');
|
||||||
|
if (depth >= this.maxDepth) throw new Unresolved('Resolution depth limit reached', 'depth-limit');
|
||||||
|
this.budget.spend();
|
||||||
|
const [name, inputs] = this.node(nodeId);
|
||||||
|
if (this.stageClasses.has(name)) throw new Unresolved(MULTISTAGE_UNATTRIBUTED_REASON);
|
||||||
|
if (this.stageShaped(name, inputs)) throw this.unsupported('Unsupported latent ancestry', nodeId);
|
||||||
|
this.enqueue(queue, queued, inputs, depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** `_sampler_like_classes`: distinct classes, in graph order, of nodes shaped like samplers or guiders. */
|
||||||
|
function samplerLikeClasses(graph) {
|
||||||
|
const names = [];
|
||||||
|
for (const node of graph.values()) {
|
||||||
|
if (!isDict(node) || !isDict(pyGet(node, 'inputs'))) continue;
|
||||||
|
const name = pyGet(node, 'class_type');
|
||||||
|
if (typeof name !== 'string' || !name || names.includes(name)) continue;
|
||||||
|
if (SAMPLER_LIKE_INPUTS.some(key => isLink(pyGet(node.get('inputs'), key)))) {
|
||||||
|
names.push(pySlice(name, CLASS_NAME_LENGTH));
|
||||||
|
if (names.length === NO_SAMPLER_NAMES) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
function noSampler(graph) {
|
||||||
|
const seen = samplerLikeClasses(graph);
|
||||||
|
const reason = `No supported sampler pair found${seen.length ? ` (saw ${seen.join(', ')})` : ''}`;
|
||||||
|
return result('unresolved', { reason, reasonCode: 'no-sampler' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `_prompt_manager_widgets`: API node ID to widget text for every PromptManager node that runs. */
|
||||||
|
function promptManagerWidgets(workflow) {
|
||||||
|
const nodes = isDict(workflow) ? pyGet(workflow, 'nodes') : null;
|
||||||
|
if (!isList(nodes)) return new Map();
|
||||||
|
const definitions = pyGet(workflow, 'definitions');
|
||||||
|
const listed = isDict(definitions) ? pyGet(definitions, 'subgraphs') : null;
|
||||||
|
const subgraphs = new Map();
|
||||||
|
for (const subgraph of isList(listed) ? listed : []) {
|
||||||
|
if (isDict(subgraph) && typeof pyGet(subgraph, 'id') === 'string' && isList(pyGet(subgraph, 'nodes'))) subgraphs.set(subgraph.get('id'), subgraph.get('nodes'));
|
||||||
|
}
|
||||||
|
const widgets = new Map(), stack = [[nodes, '', 0]];
|
||||||
|
let visited = 0;
|
||||||
|
while (stack.length) {
|
||||||
|
const [members, prefix, depth] = stack.pop();
|
||||||
|
for (const node of members) {
|
||||||
|
visited += 1;
|
||||||
|
if (visited > MAX_WORKFLOW_NODES) return widgets;
|
||||||
|
if (!isDict(node)) continue;
|
||||||
|
const kind = pyGet(node, 'type'), nodeId = pyGet(node, 'id');
|
||||||
|
if (!(typeof nodeId === 'string' || isInt(nodeId))) continue;
|
||||||
|
const apiId = `${prefix}${String(nodeId)}`;
|
||||||
|
// A list or object `type` is neither a subgraph instance nor a PromptManager node (#286).
|
||||||
|
if (isList(kind) || isDict(kind)) continue;
|
||||||
|
if (typeof kind === 'string' && subgraphs.has(kind) && depth < MAX_SUBGRAPH_DEPTH) {
|
||||||
|
stack.push([subgraphs.get(kind), `${apiId}:`, depth + 1]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const values = pyGet(node, 'widgets_values');
|
||||||
|
const text = isList(values) && values.length && typeof values[0] === 'string' ? values[0] :
|
||||||
|
isDict(values) && typeof pyGet(values, 'text') === 'string' ? values.get('text') : null;
|
||||||
|
if (typeof kind === 'string' && kind !== 'PromptManagerPair' && kind.toLowerCase().includes('promptmanager') && text !== null) widgets.set(apiId, text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return widgets;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `_overwritten_prompt_nodes` (R1). */
|
||||||
|
function overwrittenPromptNodes(workflow, graph) {
|
||||||
|
const widgets = promptManagerWidgets(workflow), byText = new Map();
|
||||||
|
for (const [nodeId] of widgets) {
|
||||||
|
const node = pyGet(graph, nodeId);
|
||||||
|
const inputs = isDict(node) ? pyGet(node, 'inputs') : null;
|
||||||
|
const text = isDict(inputs) ? pyGet(inputs, 'text') : null;
|
||||||
|
if (typeof text === 'string') {
|
||||||
|
if (!byText.has(text)) byText.set(text, []);
|
||||||
|
byText.get(text).push(nodeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const found = new Set();
|
||||||
|
for (const ids of byText.values()) {
|
||||||
|
if (ids.length > 1 && new Set(ids.map(id => widgets.get(id))).size > 1) ids.forEach(id => found.add(id));
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `_resolve` for an unanchored inspection (output_node_id None). */
|
||||||
|
function resolve(metadata, semantics) {
|
||||||
|
if (!isDict(metadata) || !metadata.size) return result('absent', { reason: 'No parsed prompt metadata' });
|
||||||
|
let graph;
|
||||||
|
if (metadata.has('prompt')) graph = metadata.get('prompt');
|
||||||
|
else if (metadata.has('parameters')) return parameters(metadata.get('parameters'), PROFILE.maxTextLength);
|
||||||
|
else graph = metadata;
|
||||||
|
if (!isDict(graph) || !graph.size) return result('unresolved', { reason: 'API graph is missing or malformed', reasonCode: 'malformed' });
|
||||||
|
if (graph.size > PROFILE.nodeCeiling) return result('unresolved', { reason: 'Prompt graph exceeds the inspection node ceiling', reasonCode: 'graph-ceiling' });
|
||||||
|
if (semantics === null) return result('unresolved', { reason: SEMANTICS_UNAVAILABLE_REASON });
|
||||||
|
let overwritten = new Set();
|
||||||
|
if (metadata.has('prompt')) {
|
||||||
|
overwritten = overwrittenPromptNodes(pyGet(metadata, 'workflow'), graph);
|
||||||
|
}
|
||||||
|
const resolver = new GraphResolver(graph, metadata.has('prompt') ? metadata : null, semantics, overwritten);
|
||||||
|
const samplerIds = finalStages(resolver, [...graph].filter(([, node]) => isDict(node) && typeof pyGet(node, 'class_type') === 'string' &&
|
||||||
|
resolver.samplerClasses.has(node.get('class_type'))).map(([id]) => id));
|
||||||
|
if (!samplerIds.length) return noSampler(graph);
|
||||||
|
if (samplerIds.length > 1) {
|
||||||
|
const candidates = samplerIds.map(id => resolver.pair(id));
|
||||||
|
return sharedPair(candidates) ?? result('ambiguous', { reason: 'Multiple sampler branches', candidates });
|
||||||
|
}
|
||||||
|
return resolver.pair(samplerIds[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** `_may_hold_prompt_manager`. */
|
||||||
|
function mayHoldPromptManager(canonical) {
|
||||||
|
const graph = pyGet(canonical, 'prompt');
|
||||||
|
if (!isDict(graph)) return false;
|
||||||
|
for (const node of graph.values()) {
|
||||||
|
if (!isDict(node)) continue;
|
||||||
|
const name = pyGet(node, 'class_type'), inputs = pyGet(node, 'inputs');
|
||||||
|
if (typeof name === 'string' && name.toLowerCase().includes('promptmanager')) return true;
|
||||||
|
if (isDict(inputs) && inputs.has('search_text')) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `_parsed_workflow`: the UI workflow as a dict, or null. */
|
||||||
|
function parsedWorkflow(value) {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
try { value = pyJsonLoads(value); } catch (error) {
|
||||||
|
if (error instanceof PyValueError) return null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return isDict(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `unreadable_evidence`: both roles when the file's metadata could not be read at all. */
|
||||||
|
export function unreadableEvidence() {
|
||||||
|
const resolution = { state: 'unresolved', sampler_id: null, reason: UNREADABLE_METADATA_REASON, reason_code: null, node: null };
|
||||||
|
return { prompts: explained({ positive: sideOf('unresolved'), negative: sideOf('unresolved') }, resolution), resolution };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `inspect_prompt_evidence(records, None, profile=INSPECTION_PROFILE)`: `{prompts, resolution}` for the records a
|
||||||
|
* local read found (`{prompt, workflow, parameters, comfyassets_prompt_pairs}` texts). `semantics` is
|
||||||
|
* `compileSemantics(...)`'s table, or null when it could not be loaded.
|
||||||
|
*/
|
||||||
|
export function resolveLocalEvidence(records, semantics) {
|
||||||
|
const source = records !== null && typeof records === 'object' ? records : {};
|
||||||
|
const metadata = new Map(Object.entries(source).filter(([key]) => ROLE_METADATA_KEYS.has(key)));
|
||||||
|
const malformedResolution = { state: 'unresolved', sampler_id: null, reason: MALFORMED_EVIDENCE_REASON, reason_code: 'malformed', node: null };
|
||||||
|
if (!metadata.size) {
|
||||||
|
return {
|
||||||
|
prompts: { positive: sideOf('absent'), negative: sideOf('absent') },
|
||||||
|
resolution: { state: 'absent', sampler_id: null, reason: 'No parsed prompt metadata', reason_code: null, node: null },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const malformed = { prompts: explained({ positive: sideOf('unresolved'), negative: sideOf('unresolved') }, malformedResolution), resolution: malformedResolution };
|
||||||
|
if (!metadata.has('prompt') && !metadata.has('parameters')) return malformed;
|
||||||
|
if (typeof metadata.get('prompt') === 'string') {
|
||||||
|
try { metadata.set('prompt', pyJsonLoads(metadata.get('prompt'))); } catch (error) {
|
||||||
|
if (error instanceof PyValueError) return malformed;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const workflow = mayHoldPromptManager(metadata) ? parsedWorkflow(source.workflow ?? null) : null;
|
||||||
|
if (workflow !== null && metadata.has('prompt')) metadata.set('workflow', workflow);
|
||||||
|
const resolved = resolve(metadata, semantics);
|
||||||
|
const state = resolved.state, prompts = {};
|
||||||
|
for (const role of ['positive', 'negative']) {
|
||||||
|
const side = resolved[role];
|
||||||
|
const present = side !== null && typeof side === 'object';
|
||||||
|
if (state !== 'ambiguous' && present && (role === 'negative' || pyStrip(side.text))) prompts[role] = sideOf('resolved', side.text, side.provenance.text_source);
|
||||||
|
else if (state === 'resolved' && present) prompts[role] = { ...sideOf('unresolved'), message: EMPTY_POSITIVE };
|
||||||
|
else prompts[role] = sideOf(state !== 'resolved' ? state : 'unresolved');
|
||||||
|
}
|
||||||
|
const resolution = { state, sampler_id: resolved.sampler_id, reason: resolved.reason, reason_code: resolved.reason_code, node: resolved.node };
|
||||||
|
return { prompts: explained(prompts, resolution), resolution };
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// The node table local Inspect resolves with (#249 local Inspect, PR L3 Task 10): web/js/inspection-semantics.json,
|
||||||
|
// the very table the server's inspector reads, fetched once per page through the API client (the proxy-mount rule).
|
||||||
|
// The Inspect page asks for it as it mounts, before any file is chosen; it is the only static file Inspect fetches,
|
||||||
|
// and the request carries nothing about a selection. A table that cannot be fetched or used is null: the roles then
|
||||||
|
// answer as the server does when its table is unusable, and a later selection asks again.
|
||||||
|
import { compileSemantics } from './local-resolver.mjs';
|
||||||
|
|
||||||
|
export const SEMANTICS_PATH = '/prompt_manager/js/inspection-semantics.json';
|
||||||
|
let pending = null;
|
||||||
|
|
||||||
|
async function fetchSemantics() {
|
||||||
|
const client = globalThis.window?.ApiClient;
|
||||||
|
if (typeof client?.resolveUrl !== 'function' || typeof globalThis.fetch !== 'function') return null;
|
||||||
|
const response = await globalThis.fetch(client.resolveUrl(SEMANTICS_PATH), { credentials: 'same-origin' });
|
||||||
|
if (!response.ok) throw new Error(`Inspection node table request failed (${response.status})`);
|
||||||
|
return compileSemantics(await response.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The compiled table (one request per page), or null when it is unavailable. */
|
||||||
|
export function loadInspectionSemantics() {
|
||||||
|
if (pending !== null) return pending;
|
||||||
|
const current = pending = fetchSemantics().catch(() => null).then(table => {
|
||||||
|
if (table === null && pending === current) pending = null;
|
||||||
|
return table;
|
||||||
|
});
|
||||||
|
return current;
|
||||||
|
}
|
||||||
@@ -70,7 +70,7 @@ function chosenFile(input) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createLocalSession({
|
export function createLocalSession({
|
||||||
parentScope, createObjectURL = file => URL.createObjectURL(file), revokeObjectURL = url => URL.revokeObjectURL(url),
|
parentScope, createObjectURL = file => URL.createObjectURL(file), revokeObjectURL = URL.revokeObjectURL.bind(URL),
|
||||||
schedulerYield = defaultSchedulerYield(), setTimer = setTimeout, clearTimer = clearTimeout,
|
schedulerYield = defaultSchedulerYield(), setTimer = setTimeout, clearTimer = clearTimeout,
|
||||||
pageTarget = globalThis.window, extractFacts, pngMetadata,
|
pageTarget = globalThis.window, extractFacts, pngMetadata,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
// server folds them.
|
// server folds them.
|
||||||
|
|
||||||
import { INT_MAX_STR_DIGITS } from "./local-projection.mjs";
|
import { INT_MAX_STR_DIGITS } from "./local-projection.mjs";
|
||||||
|
import { JSON_MAX_DEPTH } from "./py-json.mjs";
|
||||||
|
|
||||||
// Role records (prompt, parameters, the pair execution record) share one budget:
|
// Role records (prompt, parameters, the pair execution record) share one budget:
|
||||||
// past it the read fails. A workflow has its own budget; past it only the workflow
|
// past it the read fails. A workflow has its own budget; past it only the workflow
|
||||||
@@ -40,7 +41,6 @@ const MAX_NAME_BYTES = 64; // the longest carrier name is 24 bytes; longer names
|
|||||||
export const VIDEO_READ_CEILING = MAX_VIDEO_ELEMENTS * (MAX_HEADER_BYTES + MAX_NAME_BYTES) + VIDEO_TAG_READ_BYTES;
|
export const VIDEO_READ_CEILING = MAX_VIDEO_ELEMENTS * (MAX_HEADER_BYTES + MAX_NAME_BYTES) + VIDEO_TAG_READ_BYTES;
|
||||||
const PREFIX_BYTES = 32;
|
const PREFIX_BYTES = 32;
|
||||||
const YIELD_INTERVAL_MS = 8;
|
const YIELD_INTERVAL_MS = 8;
|
||||||
const JSON_MAX_DEPTH = 1000; // Python's default recursion limit
|
|
||||||
|
|
||||||
const ROLE_KEYS = ["prompt", "parameters", "comfyassets_prompt_pairs"];
|
const ROLE_KEYS = ["prompt", "parameters", "comfyassets_prompt_pairs"];
|
||||||
const METADATA_KEYS = ["prompt", "workflow", "parameters", "comfyassets_prompt_pairs"];
|
const METADATA_KEYS = ["prompt", "workflow", "parameters", "comfyassets_prompt_pairs"];
|
||||||
@@ -826,8 +826,9 @@ export function pythonJsonLoads(text) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
function value(depth) {
|
function value(depth) {
|
||||||
if (depth > JSON_MAX_DEPTH) throw new PythonJsonError("Maximum recursion depth exceeded", true);
|
|
||||||
const char = text[pos];
|
const char = text[pos];
|
||||||
|
// The shared cap (py-json.mjs): `depth` containers enclose this value, and it would be one more.
|
||||||
|
if ((char === "{" || char === "[") && depth >= JSON_MAX_DEPTH) throw new PythonJsonError("Maximum recursion depth exceeded", true);
|
||||||
if (char === '"') return string();
|
if (char === '"') return string();
|
||||||
if (char === "{") {
|
if (char === "{") {
|
||||||
pos += 1;
|
pos += 1;
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
// Python's value model for the local inspection resolver (#249 local Inspect, PR L3): the server's resolver
|
||||||
|
// (utils/prompt_pair_resolver.py) runs on `json.loads` output, so the browser port reads the same text the same way.
|
||||||
|
// Objects are `Map`s (insertion order, a repeated key keeps its first position and its last value, as a dict does),
|
||||||
|
// integers are numbers (or BigInt past 2^53 - 1), floats are `PyFloat`, None is null. Strings are measured, sliced and
|
||||||
|
// stripped as Python's `str` is: by code point, with `str.isspace()` whitespace. No DOM.
|
||||||
|
|
||||||
|
/** A Python float: distinct from an int (`1.0` is not a link slot, `type(x) is int` is false). */
|
||||||
|
export class PyFloat {
|
||||||
|
constructor(value) {
|
||||||
|
this.value = value;
|
||||||
|
Object.freeze(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `ValueError` from `json.loads` (malformed text, or an integer past `sys.int_info.str_digits_check_threshold`). */
|
||||||
|
export class PyValueError extends Error {
|
||||||
|
constructor(message) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'PyValueError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CPython's default int max str digits (PEP 651 / gh-95778).
|
||||||
|
const INT_MAX_STR_DIGITS = 4300;
|
||||||
|
/**
|
||||||
|
* The nesting both browser CPython-JSON parsers accept (this one and local-video.mjs's): every array or object counts,
|
||||||
|
* an empty one too, as CPython's scanner enters a recursion level for each. Past it `json.loads` raises RecursionError,
|
||||||
|
* which the server treats as invalid JSON; its real limit depends on the Python (TESTING.md, "Deep JSON").
|
||||||
|
*/
|
||||||
|
export const JSON_MAX_DEPTH = 1000;
|
||||||
|
const MAX_SAFE_DIGITS = '9007199254740991';
|
||||||
|
const NUMBER = /-?(?:0|[1-9][0-9]*)(\.[0-9]+)?([eE][-+]?[0-9]+)?/y;
|
||||||
|
const STRING_STOP = /["\\\u0000-\u001f]/g;
|
||||||
|
const HEX4 = /^[0-9a-fA-F]{4}$/;
|
||||||
|
const ESCAPES = { '"': '"', '\\': '\\', '/': '/', b: '\b', f: '\f', n: '\n', r: '\r', t: '\t' };
|
||||||
|
// The C scanner's constants, in its order of checks.
|
||||||
|
const CONSTANTS = [['null', null], ['true', true], ['false', false], ['NaN', new PyFloat(NaN)],
|
||||||
|
['Infinity', new PyFloat(Infinity)], ['-Infinity', new PyFloat(-Infinity)]];
|
||||||
|
|
||||||
|
export const isDict = value => value instanceof Map;
|
||||||
|
export const isList = value => Array.isArray(value);
|
||||||
|
/** `isinstance(value, int) and not isinstance(value, bool)`. */
|
||||||
|
export const isInt = value => (typeof value === 'number' && Number.isInteger(value)) || typeof value === 'bigint';
|
||||||
|
|
||||||
|
function numberValue(match) {
|
||||||
|
const [literal, fraction, exponent] = match;
|
||||||
|
if (fraction || exponent) return new PyFloat(Number(literal));
|
||||||
|
const digits = literal[0] === '-' ? literal.slice(1) : literal;
|
||||||
|
if (digits.length > INT_MAX_STR_DIGITS) throw new PyValueError('Exceeds the limit for integer string conversion');
|
||||||
|
if (digits.length > MAX_SAFE_DIGITS.length || (digits.length === MAX_SAFE_DIGITS.length && digits > MAX_SAFE_DIGITS)) return BigInt(literal);
|
||||||
|
// `-0` is the int 0.
|
||||||
|
return Number(literal) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `json.loads(text)` with Python's defaults (C scanner): NaN/Infinity accepted, a repeated key's last value wins. */
|
||||||
|
export function pyJsonLoads(text) {
|
||||||
|
if (typeof text !== 'string') throw new TypeError('JSON text must be a string');
|
||||||
|
if (text.startsWith('')) throw new PyValueError('Unexpected UTF-8 BOM (decode using utf-8-sig)');
|
||||||
|
let pos = 0;
|
||||||
|
const fail = message => new PyValueError(`${message}: char ${pos}`);
|
||||||
|
const skip = () => {
|
||||||
|
while (pos < text.length) {
|
||||||
|
const c = text.charCodeAt(pos);
|
||||||
|
if (c !== 0x20 && c !== 0x09 && c !== 0x0a && c !== 0x0d) break;
|
||||||
|
pos++;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
function string() {
|
||||||
|
pos++;
|
||||||
|
let out = '';
|
||||||
|
for (;;) {
|
||||||
|
STRING_STOP.lastIndex = pos;
|
||||||
|
const stop = STRING_STOP.exec(text);
|
||||||
|
if (!stop) throw fail('Unterminated string starting at');
|
||||||
|
out += text.slice(pos, stop.index);
|
||||||
|
pos = stop.index + 1;
|
||||||
|
if (stop[0] === '"') return out;
|
||||||
|
if (stop[0] !== '\\') throw fail('Invalid control character at');
|
||||||
|
const escape = text[pos];
|
||||||
|
if (escape === 'u') {
|
||||||
|
const hex = text.slice(pos + 1, pos + 5);
|
||||||
|
if (!HEX4.test(hex)) throw fail('Invalid \\uXXXX escape');
|
||||||
|
pos += 5;
|
||||||
|
const code = parseInt(hex, 16);
|
||||||
|
if (code >= 0xd800 && code <= 0xdbff && text[pos] === '\\' && text[pos + 1] === 'u') {
|
||||||
|
const low = text.slice(pos + 2, pos + 6);
|
||||||
|
if (!HEX4.test(low)) throw fail('Invalid \\uXXXX escape');
|
||||||
|
const lowCode = parseInt(low, 16);
|
||||||
|
if (lowCode >= 0xdc00 && lowCode <= 0xdfff) {
|
||||||
|
out += String.fromCharCode(code, lowCode);
|
||||||
|
pos += 6;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out += String.fromCharCode(code);
|
||||||
|
} else if (Object.hasOwn(ESCAPES, escape)) {
|
||||||
|
out += ESCAPES[escape];
|
||||||
|
pos++;
|
||||||
|
} else throw fail('Invalid \\escape');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function key() {
|
||||||
|
if (text[pos] !== '"') throw fail('Expecting property name enclosed in double quotes');
|
||||||
|
const name = string();
|
||||||
|
skip();
|
||||||
|
if (text[pos] !== ':') throw fail("Expecting ':' delimiter");
|
||||||
|
pos++;
|
||||||
|
skip();
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
function scalar() {
|
||||||
|
if (text[pos] === '"') return string();
|
||||||
|
for (const [literal, value] of CONSTANTS) {
|
||||||
|
if (text.startsWith(literal, pos)) {
|
||||||
|
pos += literal.length;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NUMBER.lastIndex = pos;
|
||||||
|
const match = NUMBER.exec(text);
|
||||||
|
if (!match) throw fail('Expecting value');
|
||||||
|
pos += match[0].length;
|
||||||
|
return numberValue(match);
|
||||||
|
}
|
||||||
|
skip();
|
||||||
|
const stack = [];
|
||||||
|
const enter = () => {
|
||||||
|
if (stack.length >= JSON_MAX_DEPTH) throw fail('Maximum JSON nesting depth exceeded');
|
||||||
|
pos++; skip();
|
||||||
|
};
|
||||||
|
let result;
|
||||||
|
for (;;) {
|
||||||
|
let value;
|
||||||
|
if (text[pos] === '{') {
|
||||||
|
enter();
|
||||||
|
const frame = { value: new Map(), object: true, key: null };
|
||||||
|
if (text[pos] === '}') pos++;
|
||||||
|
else { frame.key = key(); stack.push(frame); continue; }
|
||||||
|
value = frame.value;
|
||||||
|
} else if (text[pos] === '[') {
|
||||||
|
enter();
|
||||||
|
const frame = { value: [], object: false };
|
||||||
|
if (text[pos] === ']') pos++;
|
||||||
|
else { stack.push(frame); continue; }
|
||||||
|
value = frame.value;
|
||||||
|
} else value = scalar();
|
||||||
|
for (;;) {
|
||||||
|
if (!stack.length) { result = value; break; }
|
||||||
|
const frame = stack[stack.length - 1];
|
||||||
|
if (frame.object) frame.value.set(frame.key, value);
|
||||||
|
else frame.value.push(value);
|
||||||
|
skip();
|
||||||
|
if (text[pos] === ',') {
|
||||||
|
pos++; skip();
|
||||||
|
if (frame.object) frame.key = key();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (text[pos] !== (frame.object ? '}' : ']')) throw fail("Expecting ',' delimiter");
|
||||||
|
pos++;
|
||||||
|
stack.pop();
|
||||||
|
value = frame.value;
|
||||||
|
}
|
||||||
|
if (!stack.length) break;
|
||||||
|
}
|
||||||
|
skip();
|
||||||
|
if (pos !== text.length) throw fail('Extra data');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `len(text)`: code points (a lone surrogate counts once). */
|
||||||
|
export function pyLen(text) {
|
||||||
|
let pairs = 0;
|
||||||
|
for (let index = 0; index < text.length - 1; index++) {
|
||||||
|
const code = text.charCodeAt(index);
|
||||||
|
if (code >= 0xd800 && code <= 0xdbff) {
|
||||||
|
const next = text.charCodeAt(index + 1);
|
||||||
|
if (next >= 0xdc00 && next <= 0xdfff) { pairs++; index++; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return text.length - pairs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `text[:count]` by code points. */
|
||||||
|
export function pySlice(text, count) {
|
||||||
|
if (text.length <= count) return text;
|
||||||
|
let end = 0;
|
||||||
|
for (let taken = 0; taken < count && end < text.length; taken++) {
|
||||||
|
const code = text.charCodeAt(end);
|
||||||
|
const next = text.charCodeAt(end + 1);
|
||||||
|
end += code >= 0xd800 && code <= 0xdbff && next >= 0xdc00 && next <= 0xdfff ? 2 : 1;
|
||||||
|
}
|
||||||
|
return text.slice(0, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
// `str.isspace()`: Unicode Zs plus the bidi classes B, S and WS (not U+FEFF, unlike `trim()`).
|
||||||
|
export const PY_SPACE = '\\t\\n\\x0b\\x0c\\r\\x1c-\\x1f \\x85\\xa0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000';
|
||||||
|
const LEADING = new RegExp(`^[${PY_SPACE}]+`), TRAILING = new RegExp(`[${PY_SPACE}]+$`);
|
||||||
|
/** `text.strip()`. */
|
||||||
|
export const pyStrip = text => text.replace(LEADING, '').replace(TRAILING, '');
|
||||||
|
|
||||||
|
/** `bool(value)`. */
|
||||||
|
export function pyTruthy(value) {
|
||||||
|
if (value === null || value === undefined || value === false) return false;
|
||||||
|
if (value === true) return true;
|
||||||
|
if (typeof value === 'number') return value !== 0;
|
||||||
|
if (typeof value === 'bigint') return value !== 0n;
|
||||||
|
if (value instanceof PyFloat) return value.value !== 0;
|
||||||
|
if (typeof value === 'string' || isList(value)) return value.length > 0;
|
||||||
|
if (isDict(value)) return value.size > 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `value == 1` (True and 1.0 equal it too). */
|
||||||
|
export const pyEqualsOne = value => value === 1 || value === 1n || value === true || (value instanceof PyFloat && value.value === 1);
|
||||||
|
|
||||||
|
/** `repr(float)`: the shortest round-trip digits, fixed for exponents -4..15, else `d.ddde+XX`. */
|
||||||
|
export function pyFloatRepr(value) {
|
||||||
|
if (Number.isNaN(value)) return 'nan';
|
||||||
|
if (!Number.isFinite(value)) return value > 0 ? 'inf' : '-inf';
|
||||||
|
if (value === 0) return Object.is(value, -0) ? '-0.0' : '0.0';
|
||||||
|
const [mantissa, exponentText] = value.toExponential().split('e');
|
||||||
|
const exponent = Number(exponentText);
|
||||||
|
const sign = mantissa.startsWith('-') ? '-' : '';
|
||||||
|
const digits = mantissa.replace(/^-/, '').replace('.', '');
|
||||||
|
if (exponent < -4 || exponent >= 16) {
|
||||||
|
const rest = digits.slice(1);
|
||||||
|
return `${sign}${digits[0]}${rest ? `.${rest}` : ''}e${exponent < 0 ? '-' : '+'}${String(Math.abs(exponent)).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
if (exponent < 0) return `${sign}0.${'0'.repeat(-exponent - 1)}${digits}`;
|
||||||
|
const whole = digits.slice(0, exponent + 1).padEnd(exponent + 1, '0'), fraction = digits.slice(exponent + 1);
|
||||||
|
return `${sign}${whole}.${fraction || '0'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `str(value)` where the resolver compares the result with node class names (`str(node.get("class_type", ""))`).
|
||||||
|
* A container's text never equals a class name, so it is a marker only.
|
||||||
|
*/
|
||||||
|
export function pyStr(value) {
|
||||||
|
if (typeof value === 'string') return value;
|
||||||
|
if (value === null || value === undefined) return 'None';
|
||||||
|
if (value === true) return 'True';
|
||||||
|
if (value === false) return 'False';
|
||||||
|
if (typeof value === 'number' || typeof value === 'bigint') return String(value);
|
||||||
|
if (value instanceof PyFloat) return pyFloatRepr(value.value);
|
||||||
|
return '\u0000container';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `mapping.get(key, fallback)`. */
|
||||||
|
export const pyGet = (mapping, key, fallback = null) => mapping.has(key) ? mapping.get(key) : fallback;
|
||||||
@@ -286,14 +286,17 @@ export function openImportDialog({ scope, onInspect, onRescan, onMetadataScan =
|
|||||||
return { setRescanBusy };
|
return { setRescanBusy };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "Copy prompt": the text in a readonly, pre-selected textarea for a manual copy. */
|
/**
|
||||||
export function openCopyFallback(text, { scope, trigger = null } = {}) {
|
* "Copy prompt": the text in a readonly, pre-selected textarea for a manual copy. `title` names another copy's dialog
|
||||||
|
* (D117 "Copy settings summary").
|
||||||
|
*/
|
||||||
|
export function openCopyFallback(text, { scope, trigger = null, title = 'Copy prompt' } = {}) {
|
||||||
requireScope(scope);
|
requireScope(scope);
|
||||||
if (typeof text !== 'string') throw new TypeError('Copy text must be a string');
|
if (typeof text !== 'string') throw new TypeError('Copy text must be a string');
|
||||||
const area = el('textarea');
|
const area = el('textarea');
|
||||||
area.readOnly = true;
|
area.readOnly = true;
|
||||||
area.value = text;
|
area.value = text;
|
||||||
show('Copy prompt', [
|
show(title, [
|
||||||
field('Select and copy this text', area, 'copy-text'),
|
field('Select and copy this text', area, 'copy-text'),
|
||||||
el('p', 'Automatic clipboard access requires a secure browser context.', 'small'),
|
el('p', 'Automatic clipboard access requires a secure browser context.', 'small'),
|
||||||
], { scope, trigger, focusTarget: area });
|
], { scope, trigger, focusTarget: area });
|
||||||
|
|||||||
@@ -212,6 +212,8 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
|
|||||||
/* #249 D20: the viewer details' pair send and history buttons wrap inside the narrow rail at 200% zoom with wider fonts,
|
/* #249 D20: the viewer details' pair send and history buttons wrap inside the narrow rail at 200% zoom with wider fonts,
|
||||||
where the toolkit's `nowrap` would push them past its edge. */
|
where the toolkit's `nowrap` would push them past its edge. */
|
||||||
.ca-tools #viewer-details > button { white-space: normal; /* D20 */ }
|
.ca-tools #viewer-details > button { white-space: normal; /* D20 */ }
|
||||||
|
/* LI-4: "Copy settings summary" after the facts wraps the same way in the narrow rail. */
|
||||||
|
.ca-tools #viewer-details .settings-summary { white-space: normal; /* D117 */ }
|
||||||
/* The Images page narrows to one column inside a narrow container (the Library-embedded gallery). */
|
/* The Images page narrows to one column inside a narrow container (the Library-embedded gallery). */
|
||||||
.images-main { container: images-page / inline-size; }
|
.images-main { container: images-page / inline-size; }
|
||||||
@container images-page (inline-size < 600px) {
|
@container images-page (inline-size < 600px) {
|
||||||
|
|||||||
Reference in New Issue
Block a user