diff --git a/.github/workflows/viewerjs-check.yml b/.github/workflows/viewerjs-check.yml
index f6f05f3..6f0118d 100644
--- a/.github/workflows/viewerjs-check.yml
+++ b/.github/workflows/viewerjs-check.yml
@@ -13,7 +13,7 @@ permissions:
jobs:
viewerjs:
runs-on: ubuntu-latest
- timeout-minutes: 15
+ timeout-minutes: 25
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
diff --git a/TESTING.md b/TESTING.md
index 67c1ca2..0e356ad 100644
--- a/TESTING.md
+++ b/TESTING.md
@@ -145,6 +145,32 @@ cover cancellation, malformed responses and bounded visibility lookup. These run
automatically in the existing four-browser-project workflow and frontend coverage job. `tests/comfyui/workspace.spec.mjs` verifies the page against the
managed host with both renderer startup configurations.
+The shared inline and collection inspector reads the current rooted file's supported
+embedded records, exact generation facts and recorded or inferred prompt evidence.
+`tests/browser/full-inspector.spec.mjs` covers original/metadata/workflow downloads,
+response limits, source changes, nested sending, matched dark/light and mobile
+geometry, and the loaded-collection viewer. The viewer never fetches another page
+or preloads all originals. Its explicit slideshow waits 3.5 seconds after image
+readiness, pauses at native video without advancing on `ended`, and resumes on a
+manually selected image only while prior intent remains. Pause/Stop, inspector
+reading/copy/download/send, hidden document, media failure, close, query/route
+disposal and pagehide cancel that intent; returning visibility or closing the sender
+does not resume it. Stop leaves native video playback alone. Video uses only the
+existing normalized `gallerySettings` autoplay/mute/loop values, read without writes;
+denied/malformed storage falls back to autoplay off, mute on and loop on. Rejected
+autoplay keeps native controls usable.
+
+The inspector does not establish historical file identity, infer run membership,
+edit image tags, export all EXIF, or load/execute a workflow. Saved vocabulary is
+labelled by its recipe or positive-prompt scope. Limited raw evidence is omitted
+atomically, with JSON exports unavailable and the rooted original still downloadable.
+The inspection API uses one bounded read (2 MiB aggregate metadata, 8 MiB non-PNG
+header, 10,000 PNG chunks, three-second ffprobe, 500 graph nodes/semantic expansions,
+64 link/JSON-container levels and an 8 MiB serialized response); retry does not raise
+these limits. Installing the Python inspection/source-scope changes requires a
+maintainer ComfyUI restart. Automated tests use disposable hosts and never restart
+the running personal ComfyUI.
+
Run the focused page checks with:
```sh
diff --git a/database/operations.py b/database/operations.py
index 3df8f5f..55c2614 100644
--- a/database/operations.py
+++ b/database/operations.py
@@ -2014,12 +2014,18 @@ class PromptDatabase(
return count
def get_image_prompt_info(
- self, image_path: str, allow_filename_fallback: bool = True
+ self,
+ image_path: str,
+ allow_filename_fallback: bool = True,
+ *,
+ report_ambiguity: bool = False,
) -> Optional[Dict[str, Any]]:
"""Look up prompt + image metadata for a given image path.
Returns a dict with prompt_id, text, category, tags, rating, notes,
workflow_data, prompt_metadata, generation_time, parameters — or None.
+ Opting into report_ambiguity returns only an association_status marker
+ for multiple matches; default callers still receive None.
"""
with self.model.get_connection() as conn:
cursor = conn.execute(
@@ -2045,6 +2051,8 @@ class PromptDatabase(
]
matches = exact or rows
if len(matches) != 1:
+ if report_ambiguity and matches:
+ return {"association_status": "ambiguous"}
return None
row = matches[0]
associations = self.get_image_prompt_pairs(row[9])
diff --git a/docs/application-redesign.md b/docs/application-redesign.md
index 0d920b3..a18b637 100644
--- a/docs/application-redesign.md
+++ b/docs/application-redesign.md
@@ -11,7 +11,7 @@ code. This plan proposes implementation order and acceptance gates.
- Shared role-aware sending and the physical-file prompt inspector merged in [#203](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/203) and [#206](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/206).
- The native Prompt Library merged in [#210](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/210) for [#208](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/208): one positive parent, saved negative variants, full selected text and a shared filtered image gallery. It does not invent chronological runs from counters or scan dates.
- [Durable history storage](prompt-history-storage.md) records job attempts and outcomes from [#212](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/212), frozen prompt contexts from [#213](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/213), and observed run-owned output locators from [#214](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/214). Those locators do not verify file bytes or establish gallery-image associations. That association contract and the chronological execution timeline remain pending under [#209](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/209).
-- [#215](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/215) brings the shell, Images and natural-height Prompt Library presentation into the approved geometry and themes while retaining their existing operations. Full inspector/viewer parity remains pending under [#216](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/216); this presentation slice does not establish whole-application parity. Tagging/review, System consolidation and the remaining organization capabilities continue under #196.
+- [#215](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/215) brings the shell, Images and natural-height Prompt Library presentation into the approved geometry and themes while retaining their existing operations. [#216](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/216) adds the shared current-file inspector with exact generation facts, scoped provenance, raw/download evidence and loaded-collection viewer controls, including a slideshow that pauses at native video. This delivery does not establish whole-application parity, change the default entry point or constitute a release. Recorded chronological run/context browsing continues under [#209](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/209); image tagging/review, System consolidation and remaining organization capabilities continue under #196.
## Scope and compatibility
diff --git a/py/api/images.py b/py/api/images.py
index 8f42289..b920ec3 100644
--- a/py/api/images.py
+++ b/py/api/images.py
@@ -11,6 +11,7 @@ import re
import threading
import time as _time
import urllib.parse
+from collections.abc import Mapping
from pathlib import Path
from aiohttp import web
@@ -22,17 +23,19 @@ from .prompts import safe_error_message as _safe_error
try:
from ...utils.parallel import map_parallel
+ from ...utils.inspection_response import encode_inspection_response
from ...utils.prompt_inspector import (
MetadataReadError,
- inspect_prompt_roles,
- read_inspection_metadata,
+ inspect_prompt_evidence,
+ read_inspection_file,
)
except ImportError:
from utils.parallel import map_parallel
+ from utils.inspection_response import encode_inspection_response
from utils.prompt_inspector import (
MetadataReadError,
- inspect_prompt_roles,
- read_inspection_metadata,
+ inspect_prompt_evidence,
+ read_inspection_file,
)
# Only these file types are ever served by the image routes, regardless of
@@ -375,7 +378,9 @@ def _iter_media_files(output_path, max_files):
yield root_path / name
-def _output_image_entry(media_path, output_path, root_index, public_root=None):
+def _output_image_entry(
+ media_path, output_path, root_index, public_root=None, *, root_scope=None
+):
"""Build one gallery entry for *media_path* (blocking stat).
``path`` is relative to its root (what delete/autotag resolve against) and
@@ -396,15 +401,20 @@ def _output_image_entry(media_path, output_path, root_index, public_root=None):
if thumbnail_url is not None:
thumbnail_url += f"&root={root_index}"
encoded_path = urllib.parse.quote(rel_path.as_posix(), safe="/")
+ url = f"/prompt_manager/images/serve/{encoded_path}?root={root_index}"
+ if root_scope is not None:
+ url += f"&root_scope={root_scope}"
+ if thumbnail_url is not None:
+ thumbnail_url += f"&root_scope={root_scope}"
- return {
+ entry = {
"id": hashlib.sha1(str(media_path).encode("utf-8")).hexdigest()[:16],
"filename": media_path.name,
"path": rel_path.as_posix(),
"relative_path": str(rel_path),
"root_dir": public_root,
"root_index": root_index,
- "url": f"/prompt_manager/images/serve/{encoded_path}?root={root_index}",
+ "url": url,
"thumbnail_url": thumbnail_url,
"size": stat.st_size,
"modified_time": stat.st_mtime,
@@ -412,6 +422,9 @@ def _output_image_entry(media_path, output_path, root_index, public_root=None):
"media_type": "video" if is_video else "image",
"is_video": is_video,
}
+ if root_scope is not None:
+ entry["root_scope"] = root_scope
+ return entry
def _in_subfolder(rel_dir, subfolder):
@@ -421,6 +434,48 @@ def _in_subfolder(rel_dir, subfolder):
class ImageRoutesMixin:
"""Mixin providing image and gallery-related API endpoints."""
+ def _gallery_root_scope(self, root: Path, index: int) -> str:
+ payload = json.dumps(
+ ["prompt-manager-gallery-root-v1", index, str(_canonical(root))],
+ separators=(",", ":"),
+ ensure_ascii=False,
+ ).encode("utf-8")
+ return hmac.new(_WORKSPACE_CURSOR_SECRET, payload, hashlib.sha256).hexdigest()
+
+ def _gallery_scope_error(
+ self, query: Mapping, roots: list[Path]
+ ) -> web.Response | None:
+ if "root_scope" not in query:
+ return None
+ scope, root = query.get("root_scope"), query.get("root")
+ if (
+ not isinstance(scope, str)
+ or re.fullmatch(r"[0-9a-f]{64}", scope) is None
+ or not isinstance(root, str)
+ or re.fullmatch(r"[0-9]+", root) is None
+ ):
+ return bad_request("Invalid gallery root scope")
+ try:
+ index = int(root)
+ except ValueError:
+ return bad_request("Invalid gallery root scope")
+ if index >= len(roots) or not hmac.compare_digest(
+ scope, self._gallery_root_scope(roots[index], index)
+ ):
+ return web.json_response(
+ {
+ "success": False,
+ "code": "source-changed",
+ "error": (
+ "The source changed. Refresh the image collection before "
+ "inspecting this file."
+ ),
+ },
+ status=409,
+ headers={"Cache-Control": "no-store"},
+ )
+ return None
+
def _register_image_routes(self, routes):
@routes.get("/prompt_manager/prompts/{prompt_id}/images")
async def get_prompt_images_route(request):
@@ -910,15 +965,17 @@ class ImageRoutesMixin:
async def serve_output_image(self, request):
"""Serve a media file from the ComfyUI output folder(s)."""
try:
+ roots = list(self._get_all_output_dirs())
+ scope_error = self._gallery_scope_error(request.query, roots)
+ if scope_error is not None:
+ return scope_error
filepath = request.match_info["filepath"]
if _has_traversal(filepath):
return _forbidden()
if Path(filepath).suffix.lower() not in SERVABLE_EXTENSIONS:
return _forbidden("Only media files can be served")
- output_dirs = self._select_root(
- list(self._get_all_output_dirs()), request.query.get("root")
- )
+ output_dirs = self._select_root(roots, request.query.get("root"))
if not output_dirs:
return _forbidden("No allowed image directories configured")
@@ -1645,12 +1702,18 @@ class ImageRoutesMixin:
async def inspect_image(self, request):
"""Read prompt evidence for one explicitly rooted physical media file."""
roots = list(self._get_all_output_dirs())
+ scope_error = self._gallery_scope_error(request.query, roots)
+ if scope_error is not None:
+ return scope_error
try:
index = _workspace_root(request.query, roots)
if index is None:
raise ValueError("Gallery root is required")
except ValueError as error:
return bad_request(str(error))
+ root_scope = request.query.get("root_scope") or self._gallery_root_scope(
+ roots[index], index
+ )
relative = request.match_info.get("image_path", "")
if (
_has_traversal(relative)
@@ -1668,32 +1731,89 @@ class ImageRoutesMixin:
)
try:
- def inspect() -> dict:
+ def inspect() -> bytes | None:
+ before = path.stat()
try:
- metadata = read_inspection_metadata(path)
+ inspection = read_inspection_file(path)
except MetadataReadError:
- return {
- role: {"status": "unresolved", "text": None, "source": None}
- for role in ("positive", "negative")
+ inspection = None
+ try:
+ after = path.stat()
+ except FileNotFoundError:
+ return None
+ fields = ("st_dev", "st_ino", "st_size", "st_mtime_ns", "st_ctime_ns")
+ if any(getattr(before, key) != getattr(after, key) for key in fields):
+ return None
+ saved, unavailable = None, False
+ try:
+ saved = self.db.get_image_prompt_info(
+ str(path), False, report_ambiguity=True
+ )
+ except Exception:
+ unavailable = True
+ self.logger.warning("Saved inspection display could not be read")
+ if inspection is None:
+ evidence = {
+ "prompts": {
+ role: {"status": "unresolved", "text": None, "source": None}
+ for role in ("positive", "negative")
+ },
+ "resolution": {
+ "state": "unresolved",
+ "sampler_id": None,
+ "reason": "Media metadata could not be read",
+ },
}
- saved = (
- self.db.get_image_prompt_info(str(path), False)
- if not metadata
- else None
- )
- return inspect_prompt_roles(metadata, saved)
-
- prompts = await self._run_in_executor(inspect)
- return web.json_response(
- {
- "success": True,
- "image": {
+ else:
+ role_saved = (
+ None
+ if saved and saved.get("association_status") == "ambiguous"
+ else saved
+ )
+ evidence = inspect_prompt_evidence(inspection.metadata, role_saved)
+ return encode_inspection_response(
+ image={
"root_index": index,
"path": Path(relative).as_posix(),
"filename": path.name,
},
- "prompts": prompts,
- }
+ root_scope=root_scope,
+ file={
+ "observation": "current-file",
+ "source_label": _public_path(roots[index]),
+ "media_type": (
+ "video"
+ if path.suffix.lower() in VIDEO_EXTENSIONS
+ else "image"
+ ),
+ "extension": path.suffix.lower(),
+ "size": before.st_size,
+ "modified_time": before.st_mtime,
+ "format": None,
+ "width": None,
+ "height": None,
+ },
+ prompts=evidence["prompts"],
+ resolution=evidence["resolution"],
+ inspection=inspection,
+ library=self._inspection_library(saved, unavailable=unavailable),
+ )
+
+ body = await self._run_in_executor(inspect)
+ if body is None:
+ return web.json_response(
+ {
+ "success": False,
+ "error": "The file changed while being inspected. Try again.",
+ },
+ status=409,
+ headers={"Cache-Control": "no-store"},
+ )
+ return web.Response(
+ body=body,
+ content_type="application/json",
+ charset="utf-8",
+ headers={"Cache-Control": "no-store"},
)
except Exception:
self.logger.exception("Prompt inspection failed")
@@ -1701,6 +1821,43 @@ class ImageRoutesMixin:
{"success": False, "error": "Prompt inspection failed"}, status=500
)
+ def _inspection_library(self, saved: dict | None, *, unavailable: bool) -> dict:
+ result = {
+ "status": "unavailable" if unavailable else "none",
+ "scope": "saved-library",
+ "positive_id": None,
+ "pair_id": None,
+ "title": None,
+ "category": None,
+ "tags": [],
+ "metadata_scope": None,
+ }
+ if not isinstance(saved, dict) or not saved:
+ return result
+ if saved.get("association_status") == "ambiguous":
+ result["status"] = "ambiguous"
+ return result
+ pair = saved.get("pair")
+ if isinstance(pair, dict):
+ result.update(
+ status="linked",
+ positive_id=pair.get("positive_prompt_id"),
+ pair_id=pair.get("id"),
+ title=pair.get("title"),
+ category=pair.get("category"),
+ tags=pair.get("tags"),
+ metadata_scope="pair",
+ )
+ else:
+ result.update(
+ status="linked",
+ positive_id=saved.get("prompt_id"),
+ category=saved.get("category"),
+ tags=saved.get("tags"),
+ metadata_scope="positive",
+ )
+ return result
+
async def get_image_prompt(self, request):
"""Get prompt information for a specific image path."""
try:
@@ -1799,7 +1956,7 @@ class ImageRoutesMixin:
]
def _format_workspace_page(self, page: list[tuple]) -> list[dict]:
- images = self._format_output_page(page)
+ images = self._format_output_page(page, scoped=True)
paths = {
(entry[3], entry[0].relative_to(entry[2]).as_posix()): entry[0]
for entry in page
@@ -1818,7 +1975,7 @@ class ImageRoutesMixin:
continue
return images
- def _format_output_page(self, page):
+ def _format_output_page(self, page, *, scoped=False):
entries = []
public_roots = {}
for media_path, _mtime, output_path, root_index in page:
@@ -1827,7 +1984,15 @@ class ImageRoutesMixin:
try:
entries.append(
_output_image_entry(
- media_path, output_path, root_index, public_roots[output_path]
+ media_path,
+ output_path,
+ root_index,
+ public_roots[output_path],
+ root_scope=(
+ self._gallery_root_scope(output_path, root_index)
+ if scoped
+ else None
+ ),
)
)
except Exception as e:
diff --git a/tests/browser/full-inspector.spec.mjs b/tests/browser/full-inspector.spec.mjs
new file mode 100644
index 0000000..a8bd232
--- /dev/null
+++ b/tests/browser/full-inspector.spec.mjs
@@ -0,0 +1,681 @@
+import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
+import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
+
+test.use({locale:'en-US',timezoneId:'UTC',reducedMotion:'reduce'});
+test.beforeEach(async({page,browserName})=>{await startCoverage(page,browserName);page.inspectorErrors=[];page.on('pageerror',error=>page.inspectorErrors.push(error.message));});
+test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.inspectorErrors).toEqual([]);});
+
+test('inspection facts survive actual API to browser transport', async ({ page, request }) => {
+ await expect(await request.post('/__test__/reset', { data: { full_inspector: true } })).toBeOK();
+ await page.goto('/prompt_manager/workspace');
+ const result = await page.evaluate(async () => {
+ if (typeof window.ComfyMetadata?.extractGenerationFacts !== 'function') return { missingFactsExport: true };
+ const { inspectPath, toInspectionRecord } = await import('/prompt_manager/js/workspace/inspection-state.mjs');
+ const { createApiClient } = window.ApiClient;
+ const api = createApiClient();
+ const collection = await api.get('/prompt_manager/images/output?mode=workspace&subfolder=inspector&limit=40');
+ const records = {};
+ for (const name of ['complete', 'ambiguous', 'workflow-only', 'control-heavy']) {
+ const image = collection.images.find(entry => entry.root_index === 0 && entry.path === `inspector/${name}.png`);
+ const envelope = await api.get(inspectPath(image));
+ records[name] = toInspectionRecord(envelope, image, window.ComfyMetadata.extractGenerationFacts);
+ }
+ return records;
+ });
+ expect(result.missingFactsExport).toBeUndefined();
+ const complete = result.complete;
+ expect(Object.values(complete.generation).map(fact => fact.value)).toEqual(['9007199254740993', '28', '6.5', 'dpmpp_2m', 'karras', 'fixture-model.safetensors']);
+ expect(Object.values(complete.generation).every(fact => fact.source === 'api-graph' && fact.evidence === 'configuration')).toBe(true);
+ expect(Object.values(result.ambiguous.generation).every(fact => fact.status === 'ambiguous' && fact.value === null)).toBe(true);
+ expect(result['workflow-only'].generation.seed).toMatchObject({ status: 'known', value: '9007199254740993', source: 'workflow', evidence: 'configuration' });
+ expect(result['workflow-only'].prompts.positive.source).toBe('saved-association');
+ expect(Object.values(result['control-heavy'].generation).every(fact => fact.status === 'unresolved' && fact.value === null && fact.reason === 'projection-limited')).toBe(true);
+ await expect(await request.post('/__test__/reset')).toBeOK();
+});
+
+test('inspection wire preserves exact rooted envelope', async ({ request }) => {
+ for (const data of [
+ { full_inspector: 'true' }, { full_inspector: 1 },
+ { full_inspector: true, workspace_visual: true },
+ { full_inspector: true, prompt_inspector: true },
+ { full_inspector: true, native_library: true },
+ { full_inspector: true, extra_prompts: 1 },
+ { full_inspector: true, extra_variants: 1 },
+ { full_inspector: true, extra_images: true },
+ { full_inspector: true, include_video: 'true' },
+ ]) expect((await request.post('/__test__/reset', { data })).status()).toBe(400);
+ await expect(await request.post('/__test__/reset', { data: { full_inspector: true, include_video: true, extra_images: 2 } })).toBeOK();
+ const collection = await (await request.get('/prompt_manager/images/output?mode=workspace&subfolder=inspector&limit=40')).json();
+ expect(collection.total).toBe(11);
+ const complete = collection.images.find(image => image.root_index === 0 && image.path === 'inspector/complete.png');
+ const collision = collection.images.find(image => image.root_index === 1 && image.path === complete.path);
+ expect(complete.root_scope).toMatch(/^[0-9a-f]{64}$/);
+ expect(collision.root_scope).not.toBe(complete.root_scope);
+ const inspect = async entry => {
+ const response = await request.get(`/prompt_manager/images/inspect/${entry.path.split('/').map(encodeURIComponent).join('/')}?root=${entry.root_index}&root_scope=${entry.root_scope}`);
+ await expect(response).toBeOK();
+ expect(response.headers()['cache-control']).toBe('no-store');
+ expect((await response.body()).length).toBeLessThanOrEqual(8 * 1024 * 1024);
+ return response.json();
+ };
+ const result = await inspect(complete);
+ expect(result.inspection_version).toBe(1);
+ expect(result.image).toEqual({ root_index: 0, path: complete.path, filename: 'complete.png' });
+ expect(result.root_scope).toBe(complete.root_scope);
+ expect(result.prompts.positive.text).toContain('Inspector portrait 猫 😀');
+ expect(result.prompts.negative.text).toBe('blurred edges, watermark');
+ expect(result.metadata.prompt_graph.value['3'].inputs.seed).toBe('9007199254740993');
+ expect(result.metadata.prompt_graph.value['3'].inputs.cfg).toBe('6.5');
+ expect(result.metadata.record_formats).toEqual({ prompt: 'original-text', workflow: 'original-text' });
+ expect(result.file).toMatchObject({ observation: 'current-file', format: 'PNG', width: 720, height: 480 });
+ expect(result.library).toMatchObject({ status: 'linked', metadata_scope: 'pair', title: 'Inspector recipe', category: 'Inspection', tags: ['fixture', 'geometric'] });
+ expect((await inspect(collision)).prompts.positive.text).toBe('Input collision');
+ const gallery = await (await request.get(`/prompt_manager/library/${result.library.positive_id}/images?pair_id=${result.library.pair_id}`)).json();
+ expect(gallery.images[0].root_scope).toBe(complete.root_scope);
+ const library = await (await request.get('/prompt_manager/library')).json();
+ expect(library.items.find(item => item.id === result.library.positive_id).previews.some(entry => entry.root_scope === complete.root_scope)).toBe(true);
+ const workflow = await inspect(collection.images.find(image => image.path === 'inspector/workflow-only.png'));
+ expect(workflow.prompts.positive.source).toBe('saved-association');
+ const limited = await inspect(collection.images.find(image => image.path === 'inspector/control-heavy.png'));
+ expect(limited.metadata).toMatchObject({ status: 'limited', omitted_reason: 'response-size-limit', raw: {}, parameters: null });
+ expect(collection.images.some(image => image.path.endsWith('clip.webm'))).toBe(true);
+ await expect(await request.post('/__test__/reset')).toBeOK();
+});
+
+const inspector=page=>page.locator('.image-details');
+const selectFile=async(page,name,root=0)=>page.locator(`article[data-image-key="${root}:inspector/${name}"] .image-open`).click();
+async function fullFixture(page,request) {
+ await expect(await request.post('/__test__/reset',{data:{full_inspector:true}})).toBeOK();
+ await page.goto('/prompt_manager/workspace#/images');await selectFile(page,'complete.png');
+}
+async function downloadText(page,action) {
+ const [download]=await Promise.all([page.waitForEvent('download'),action.click()]);
+ const fs=await import('node:fs/promises');return {name:download.suggestedFilename(),text:await fs.readFile(await download.path(),'utf8')};
+}
+
+test('full inspector shows six facts and scoped provenance',async({page,request},testInfo)=>{
+ await page.setViewportSize({width:1600,height:1100});await fullFixture(page,request);const panel=inspector(page);
+ await expect(panel.getByRole('heading',{name:'Inspector recipe',exact:true})).toBeVisible();await expect(panel.locator('.file-format')).toHaveText('PNG');await expect(panel.locator('.file-name')).toHaveText('complete.png');
+ await expect(panel.locator('.file-evidence')).toHaveText('Workflow prompt evidence');
+ expect(await panel.evaluate(node=>node.getBoundingClientRect().width)).toBe(310);
+ expect(await panel.locator('.file-inspector-inner').evaluate(node=>getComputedStyle(node).padding)).toBe('17px');
+ if (!await page.evaluate(()=>matchMedia('(pointer: coarse)').matches)) {
+ const copy=await panel.getByRole('button',{name:'Copy positive prompt',exact:true}).boundingBox();expect(copy.width).toBe(30);expect(copy.height).toBe(26);
+ expect((await panel.getByRole('button',{name:'Send prompt pair',exact:true}).boundingBox()).height).toBe(30);
+ }
+ await expect(panel.locator('.generation-facts dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);
+ await expect(panel.locator('.generation-facts dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']);
+ await expect(panel).toContainText('Recipe tags');await expect(panel).toContainText('Recipe category');
+ await expect(panel.locator('.file-vocabulary button')).toHaveCount(0);
+ await panel.getByText('File & provenance',{exact:true}).click();
+ await expect(panel).toContainText('Current file at this source; historical bytes are not verified.');await expect(panel).toContainText('Saved association; not verified against current file');
+ await expect(panel).toContainText('Configuration evidence');await expect(panel.getByRole('link',{name:'Prompt history',exact:true})).toHaveAttribute('href',/^#\/library\/\d+$/);
+ await expect(panel.getByText('Seed: Configuration evidence · Embedded API configuration',{exact:true})).toBeVisible();
+ if (testInfo.project.name === 'chromium') {
+ await panel.getByText('Seed: Configuration evidence · Embedded API configuration',{exact:true}).scrollIntoViewIfNeeded();
+ const path=testInfo.outputPath('configuration-api-source.png');await page.screenshot({path});await testInfo.attach('configuration-api-source',{path,contentType:'image/png'});
+ }
+ await expect(panel).not.toContainText(/Run:|Original file unchanged|Execution recorded/);
+ await selectFile(page,'empty.png');await expect(panel.locator('.file-format')).toHaveText('PNG');await expect(panel.getByRole('heading',{name:'empty.png',exact:true})).toBeVisible();
+ await expect(panel.getByRole('button',{name:'Send negative prompt',exact:true})).toHaveCount(0);await expect(panel).toContainText('Not recorded in this file.');await expect(panel.getByRole('link',{name:'Prompt history',exact:true})).toHaveCount(0);
+ await selectFile(page,'explicit-empty.png');await expect(panel).toContainText('Empty negative prompt');await expect(panel.getByRole('button',{name:'Send prompt pair',exact:true})).toHaveCount(1);
+ await selectFile(page,'workflow-only.png');await panel.getByText('File & provenance',{exact:true}).click();
+ await expect(panel.getByText('Seed: Configuration evidence · Saved workflow configuration',{exact:true})).toBeVisible();
+ if (testInfo.project.name === 'chromium') {
+ await panel.getByText('Seed: Configuration evidence · Saved workflow configuration',{exact:true}).scrollIntoViewIfNeeded();
+ const path=testInfo.outputPath('configuration-workflow-source.png');await page.screenshot({path});await testInfo.attach('configuration-workflow-source',{path,contentType:'image/png'});
+ }
+});
+
+test('full inspector preserves long role text and exact sends',async({page,request,context})=>{
+ const host=await context.newPage();await host.goto('/prompt_manager/workspace#/overview');
+ await host.evaluate(async()=>{const {createBridgeHost}=await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');const target={id:'pair',label:'Exact pair',type:'PromptManagerPair',roles:['positive','negative']};window.sent=[];window.bridge=createBridgeHost({origin:location.origin,namespace:'/',label:'Exact host',adapter:{listTargets:()=>[target],preview:(_id,roles)=>({preview:{target,fields:Object.entries(roles).map(([role,after])=>({role,before:'',after}))},snapshot:roles}),apply:roles=>{window.sent.push(roles);return{targetId:target.id,values:roles};}}});});
+ try {
+ await fullFixture(page,request);const panel=inspector(page);const positive=panel.locator('.prompt-positive .prompt-text');await expect(positive).toContainText('猫 😀');const text=await positive.textContent();expect(text.length).toBeGreaterThan(1000);
+ await panel.getByRole('button',{name:'Send prompt pair',exact:true}).click();const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
+ await expect(sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).locator('option')).toContainText(['Exact host']);
+ await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'Exact host'});await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'Exact pair'});
+ await sender.getByRole('button',{name:'Preview changes',exact:true}).click();await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
+ expect(await host.evaluate(()=>window.sent)).toEqual([{positive:text,negative:'blurred edges, watermark'}]);
+ } finally {await host.evaluate(()=>window.bridge.dispose());await host.close();}
+});
+
+test('full inspector downloads actual bounded evidence',async({page,request})=>{
+ await fullFixture(page,request);const panel=inspector(page);await panel.getByText('Workflow & raw metadata',{exact:true}).click();
+ const list=await(await request.get('/prompt_manager/images/output?mode=workspace&subfolder=inspector')).json();const image=list.images.find(image=>image.root_index===0&&image.path==='inspector/complete.png');
+ const result=await(await request.get(`/prompt_manager/images/inspect/inspector/complete.png?root=0&root_scope=${image.root_scope}`)).json();
+ await expect(panel).toContainText('Original embedded text');const metadata=await downloadText(page,panel.getByRole('button',{name:'Download metadata JSON',exact:true}));expect(metadata.name).toBe('complete.metadata.json');expect(JSON.parse(metadata.text).metadata.raw).toEqual(result.metadata.raw);expect(JSON.parse(metadata.text).identity.root_scope).toBe(image.root_scope);
+ const workflow=await downloadText(page,panel.getByRole('button',{name:'Download workflow JSON',exact:true}));expect(workflow.name).toBe('complete.workflow.json');expect(workflow.text).toBe(result.metadata.raw.workflow);
+ await panel.getByText('File & provenance',{exact:true}).click();await expect(panel.getByRole('link',{name:'Download original',exact:true})).toHaveAttribute('href',image.url);
+ await selectFile(page,'malformed.png');await panel.getByText('Workflow & raw metadata',{exact:true}).click();await expect(panel.locator('pre code').filter({hasText:'{broken'})).toHaveText('{broken');await expect(panel.getByRole('button',{name:'Download workflow JSON',exact:true})).toHaveAttribute('aria-disabled','true');
+ await selectFile(page,'control-heavy.png');await panel.getByText('Workflow & raw metadata',{exact:true}).click();await expect(panel).toContainText('Raw metadata exceeds the response limit. Download the original file to inspect it externally.');
+ let downloads=0;page.on('download',()=>downloads++);for(const name of ['Download metadata JSON','Download workflow JSON']) {const button=panel.getByRole('button',{name,exact:true});await expect(button).toHaveAttribute('aria-disabled','true');await button.focus();await page.keyboard.press('Enter');}expect(downloads).toBe(0);
+ await panel.getByText('File & provenance',{exact:true}).click();await expect(panel.getByRole('link',{name:'Download original',exact:true})).toHaveAttribute('href',/root=0&root_scope=[a-f0-9]{64}/);
+});
+
+test('full inspector rejects stale shared action state',async({page,request})=>{
+ let reads=0,stale=false;await page.route('**/images/inspect/**',route=>{reads++;return stale?route.fulfill({status:409,json:{success:false,code:'source-changed',error:'Source changed'}}):route.continue();});
+ await fullFixture(page,request);const panel=inspector(page);await expect(panel.getByRole('button',{name:'Send prompt pair',exact:true})).toBeVisible();const before=reads;
+ await panel.getByRole('button',{name:'Open full preview',exact:true}).click();const preview=page.getByRole('dialog',{name:'Image preview',exact:true});await expect(preview.locator('.generation-facts dd').first()).toHaveText('9007199254740993');expect(reads).toBe(before);await preview.getByRole('button',{name:'Close preview',exact:true}).click();
+ stale=true;await selectFile(page,'complete.png',1);await expect(panel).toContainText('The source changed. Refresh the image collection before inspecting this file.');
+ await expect(panel.getByRole('button',{name:/Retry/})).toHaveCount(0);await expect(panel.locator('[data-copy-role],[data-send-role],a[download]')).toHaveCount(0);await expect(panel.getByRole('button',{name:/Download/})).toHaveCount(0);
+ stale=false;const refreshed=page.waitForResponse(response=>response.url().includes('/images/output?'));await panel.getByRole('button',{name:'Refresh image collection',exact:true}).click();await refreshed;await selectFile(page,'complete.png',1);await expect(panel).toContainText('Input collision');
+ const urls=await page.evaluate(()=>performance.getEntriesByType('resource').map(item=>item.name).filter(name=>name.includes('/images/inspect/')));expect(urls.every(value=>new URL(value).searchParams.has('root_scope'))).toBe(true);
+});
+
+test('full inspector keeps long literal content reachable on mobile',async({page,request})=>{
+ await page.addInitScript(()=>Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async()=>{throw Error('Denied');}}}));
+ const literal=''+'verylongmodelpath/'.repeat(30);
+ let external=0;await page.route('https://invalid.test/**',route=>{external++;return route.abort();});
+ await page.route('**/images/inspect/**',async route=>{const response=await route.fetch();const data=await response.json();data.metadata.raw.parameters=literal;data.metadata.record_formats.parameters='original-text';data.metadata.parameters=literal;data.metadata.prompt_graph.value['4'].inputs.ckpt_name=literal;await route.fulfill({response,json:data});});
+ await page.setViewportSize({width:390,height:844});await fullFixture(page,request);const panel=inspector(page);await expect(panel.locator('.prompt-positive pre')).toContainText('fine geometry and light.');await panel.getByRole('button',{name:'Copy positive prompt',exact:true}).click();await expect(panel).toContainText('Copy unavailable. Select the prompt text to copy it.');
+ await panel.getByText('Workflow & raw metadata',{exact:true}).click();await expect(panel.locator('pre code').filter({hasText:literal}).last()).toHaveText(literal);await expect(panel.locator('.generation-facts dd').last()).toHaveText(literal);
+ for(const zoom of [1,2]) {await page.evaluate(zoom=>{document.documentElement.style.zoom=String(zoom);},zoom);await expect.poll(()=>page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);const footer=panel.getByRole('button',{name:'Send prompt pair',exact:true});await footer.focus();await expect(footer).toBeFocused();const summary=panel.getByText('File & provenance',{exact:true});await summary.focus();await page.keyboard.press('Enter');await expect(panel).toContainText('historical bytes are not verified');}
+ expect(await page.evaluate(()=>window.executed)).toBeUndefined();expect(external).toBe(0);
+});
+
+test('embedded gallery uses the same full inspector',async({page,request})=>{
+ await expect(await request.post('/__test__/reset',{data:{full_inspector:true}})).toBeOK();const list=await(await request.get('/prompt_manager/library')).json();const id=list.items.find(item=>item.text.startsWith('Inspector portrait')).id;
+ await page.goto(`/prompt_manager/workspace#/library/${id}`);await selectFile(page,'complete.png');const panel=inspector(page);await expect(panel.getByRole('heading',{name:'Inspector recipe',exact:true})).toBeVisible();await expect(panel.locator('.generation-facts dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);await expect(panel).toContainText('Recipe tags');await panel.getByRole('button',{name:'Open full preview',exact:true}).click();await expect(page.locator('.image-preview .file-inspector')).toHaveCount(1);
+});
+
+test('full inspector labels container representations and releases action URLs',async({page,request})=>{
+ await fullFixture(page,request);
+ await page.evaluate(async()=>{
+ const {mountFileInspector}=await import('/prompt_manager/js/workspace/file-inspector.mjs');
+ const {toInspectionRecord,inspectPath}=await import('/prompt_manager/js/workspace/inspection-state.mjs');
+ const {createScope}=await import('/prompt_manager/js/shared/toolkit/core.js');
+ const api=window.ApiClient.createApiClient();const list=await api.get('/prompt_manager/images/output?mode=workspace&subfolder=inspector');const image=list.images.find(item=>item.root_index===0&&item.path==='inspector/complete.png');
+ const record=toInspectionRecord(await api.get(inspectPath(image)),image);record.file.media_type='video';record.metadata.record_formats.workflow='reserialized-json';record.metadata.record_formats.comfyassets_prompt_pairs='invalid';
+ window.renderFixture={record,scope:createScope(),created:[],revoked:[],timers:0};
+ const create=URL.createObjectURL.bind(URL),revoke=URL.revokeObjectURL.bind(URL),timeout=window.setTimeout.bind(window);
+ URL.createObjectURL=blob=>{const url=create(blob);window.renderFixture.created.push(url);return url;};URL.revokeObjectURL=url=>{window.renderFixture.revoked.push(url);revoke(url);};
+ window.setTimeout=(callback,delay,...args)=>{if(delay===1000)window.renderFixture.timers++;return timeout(callback,delay,...args);};
+ const container=document.createElement('section');container.id='render-fixture';document.body.append(container);
+ window.renderFixture.view=mountFileInspector(container,{parentScope:window.renderFixture.scope});window.renderFixture.view.setState({record});
+ });
+ const panel=page.locator('#render-fixture');await panel.getByText('Workflow & raw metadata',{exact:true}).click();
+ await expect(panel).toContainText('Reserialized container JSON; numeric spelling/precision may differ');await expect(panel).toContainText('Invalid container record; text is unavailable');
+ await expect(panel.getByRole('button',{name:'Download workflow JSON',exact:true})).toHaveAttribute('aria-disabled','true');await expect(panel).toContainText('Original workflow JSON text is unavailable for this container record. Download metadata or the original file.');
+ const metadata=await downloadText(page,panel.getByRole('button',{name:'Download metadata JSON',exact:true}));const value=JSON.parse(metadata.text);expect(value.metadata.record_formats.workflow).toBe('reserialized-json');expect(value.metadata.raw.workflow).toContain('9007199254740993');expect(value.metadata.raw.comfyassets_prompt_pairs).toBeUndefined();
+ await page.evaluate(()=>window.renderFixture.view.setState({error:true,refreshRequired:true,message:'The source changed. Refresh the image collection before inspecting this file.'}));
+ await expect(panel).toContainText('Reopen this file from a refreshed image collection.');await expect(panel.locator('button,a[download]')).toHaveCount(0);
+ expect(await page.evaluate(()=>({created:window.renderFixture.created,revoked:window.renderFixture.revoked,timers:window.renderFixture.timers}))).toMatchObject({timers:1});
+ await expect.poll(()=>page.evaluate(()=>window.renderFixture.created.length===window.renderFixture.revoked.length)).toBe(true);
+ await page.evaluate(()=>{window.renderFixture.view.dispose();window.renderFixture.view.dispose();window.renderFixture.scope.dispose();});await expect(panel).toBeEmpty();
+ expect(await page.evaluate(()=>window.renderFixture.revoked)).toEqual(await page.evaluate(()=>window.renderFixture.created));
+});
+
+test('full inspector distinguishes missing, limited and legacy unknown generation facts',async({page,request})=>{
+ await fullFixture(page,request);const panel=inspector(page);
+ await selectFile(page,'empty.png');await expect(panel.locator('.generation-facts dd')).toHaveText(Array(6).fill('Not recorded'));
+ await page.locator('article[data-image-key="0:architecture/study-01.png"] .image-open').click();await expect(panel.locator('.generation-facts dd')).toHaveText(Array(6).fill('Not recorded'));
+ await selectFile(page,'control-heavy.png');
+ const explanation='Configuration exceeds inspection limits; download the original file to inspect it locally.';
+ await expect(panel.getByText(explanation,{exact:true})).toHaveCount(1);await expect(panel.getByText(explanation,{exact:true})).toBeVisible();
+ for(const value of await panel.locator('.generation-facts dd').all())await expect(value).toHaveAccessibleDescription(explanation);
+ await page.evaluate(async()=>{
+ const {mountFileInspector}=await import('/prompt_manager/js/workspace/file-inspector.mjs');
+ const {toInspectionRecord}=await import('/prompt_manager/js/workspace/inspection-state.mjs');
+ const {createScope}=await import('/prompt_manager/js/shared/toolkit/core.js');
+ const container=document.createElement('section');container.id='legacy-facts';document.body.append(container);
+ window.legacyFactsScope=createScope();window.legacyFactsView=mountFileInspector(container,{parentScope:window.legacyFactsScope});
+ window.legacyFactsView.setState({record:toInspectionRecord({prompts:{}},{root_index:0,path:'legacy.png'})});
+ });
+ const legacy=page.locator('#legacy-facts');await expect(legacy.locator('.generation-facts dd')).toHaveText(Array(6).fill('Unresolved'));await expect(legacy).not.toContainText(explanation);await expect(legacy.locator('.generation-facts dd[aria-describedby]')).toHaveCount(0);
+ await page.evaluate(()=>{window.legacyFactsView.dispose();window.legacyFactsScope.dispose();});
+});
+
+test.describe('full inspector coarse pointer',()=>{
+ test.use({hasTouch:true,viewport:{width:390,height:844}});
+ test('full inspector meets coarse target sizes and keeps long content reachable at zoom',async({page,request})=>{
+ await fullFixture(page,request);expect(await page.evaluate(()=>matchMedia('(pointer: coarse)').matches)).toBe(true);const panel=inspector(page);
+ await expect(panel.locator('.prompt-positive pre')).toContainText('Full prompt context, fine geometry and light.');
+ const fullText=await panel.locator('.prompt-positive pre').textContent();expect(fullText.length).toBeGreaterThan(1000);
+ await panel.getByText('File & provenance',{exact:true}).click();await panel.getByText('Workflow & raw metadata',{exact:true}).click();
+ const bounds=await panel.locator('button,a,summary').evaluateAll(nodes=>nodes.map(node=>({name:node.getAttribute('aria-label')||node.textContent,role:node.matches('.prompt-actions button'),width:node.getBoundingClientRect().width,height:node.getBoundingClientRect().height})));
+ for(const target of bounds){expect(target.width,target.name).toBeGreaterThanOrEqual(target.role?36:40);expect(target.height,target.name).toBeGreaterThanOrEqual(target.role?36:40);}
+ for(const zoom of [1,2]) {
+ await page.evaluate(zoom=>{document.documentElement.style.zoom=String(zoom);},zoom);
+ await expect.poll(()=>page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);
+ await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
+ for(const target of [panel.locator('summary').first(),panel.getByRole('button',{name:'Download metadata JSON',exact:true}),panel.getByRole('link',{name:'Prompt history',exact:true})]) {
+ await target.scrollIntoViewIfNeeded();await target.focus();await expect(target).toBeFocused();await expect(target).toBeInViewport();
+ }
+ await expect(panel.locator('.prompt-positive pre')).toHaveText(fullText);
+ }
+ await panel.getByRole('button',{name:'Open full preview',exact:true}).click();const viewer=page.getByRole('dialog',{name:'Image preview',exact:true});
+ await expect(viewer.locator('.viewer-canvas img')).toBeVisible();
+ for(const target of await viewer.locator('.image-preview-controls > :is(button,a),.image-preview-header > button').all()) {
+ const box=await target.evaluate(node=>{const {width,height}=node.getBoundingClientRect();return {width,height,name:node.getAttribute('aria-label')};});
+ expect(box.width,box.name).toBeGreaterThanOrEqual(80);expect(box.height,box.name).toBeGreaterThanOrEqual(80);
+ }
+ const raw=viewer.getByText('Workflow & raw metadata',{exact:true});await raw.scrollIntoViewIfNeeded();await raw.focus();await expect(raw).toBeInViewport();
+ });
+});
+
+const mediaDialog=page=>page.getByRole('dialog',{name:'Image preview',exact:true});
+const mediaStage=page=>mediaDialog(page).getByRole('region',{name:'Media preview',exact:true});
+const position=page=>mediaDialog(page).locator('.image-preview-position');
+async function openCollection(page) {
+ await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();
+ await expect(mediaDialog(page).locator('.viewer-canvas img')).toBeVisible();
+ await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
+}
+
+test('collection viewer follows filtered loaded order beyond mounted cards',async({page,request})=>{
+ await page.addInitScript(()=>{window.IntersectionObserver=undefined;});
+ await expect(await request.post('/__test__/reset',{data:{full_inspector:true,extra_images:200}})).toBeOK();
+ await page.goto('/prompt_manager/workspace#/images');
+ await page.getByRole('combobox',{name:'Source',exact:true}).selectOption('0');
+ await page.getByRole('combobox',{name:'Folder',exact:true}).selectOption('inspector');
+ await page.getByRole('combobox',{name:'Sort',exact:true}).selectOption('name');
+ const more=page.getByRole('button',{name:'Load more images',exact:true});
+ await expect(page.locator('.image-toolbar')).toContainText('40 of');
+ for(const n of [80,120,160]) {await more.click();await expect(page.locator('.image-toolbar')).toContainText(`${n} of`);}
+ const listed=await(await request.get('/prompt_manager/images/output?mode=workspace&root=0&subfolder=inspector&sort=name&limit=200')).json();
+ const loaded=listed.images.slice(0,160);expect(loaded).toHaveLength(160);
+ await page.evaluate(()=>scrollTo(0,0));await expect.poll(()=>page.locator('article[data-image-key]').count()).toBeLessThan(120);
+ let release,started;const held=new Promise(resolve=>{release=resolve;});const hit=new Promise(resolve=>{started=resolve;});
+ await page.route('**/images/output?*',async route=>{started();await held;await route.continue();},{times:1});
+ await more.click();await hit;await page.evaluate(()=>scrollTo(0,0));
+ const opener=page.locator(`article[data-image-key="0:${loaded[0].path}"] .image-card-footer button`);
+ await opener.click();release();await expect(page.locator('.image-toolbar')).toContainText('200 of');
+ const modal=mediaDialog(page);await expect(position(page)).toHaveText(`1 of 160 loaded: ${loaded[0].filename}`);
+ let pages=0;page.on('request',req=>{if(req.url().includes('/images/output?'))pages++;});
+ await modal.getByRole('button',{name:'Previous media',exact:true}).click();
+ await expect(position(page)).toHaveText(`160 of 160 loaded: ${loaded[159].filename}`);
+ await expect(modal.locator('.file-name')).toHaveText(loaded[159].filename);
+ await expect(modal.getByRole('group',{name:'Media controls',exact:true}).getByRole('link',{name:'Download original',exact:true})).toHaveAttribute('href',loaded[159].url);
+ await expect(page.locator(`article[data-image-key="0:${loaded[159].path}"]`)).toHaveCount(0);
+ await modal.getByRole('button',{name:'Next media',exact:true}).click();await expect(position(page)).toHaveText(`1 of 160 loaded: ${loaded[0].filename}`);
+ await mediaStage(page).focus();await page.keyboard.press('ArrowRight');await expect(position(page)).toHaveText(`2 of 160 loaded: ${loaded[1].filename}`);
+ expect(pages).toBe(0);await modal.getByRole('button',{name:'Close preview',exact:true}).click();
+ await expect(opener).toBeFocused();
+ await page.getByRole('combobox',{name:'Source',exact:true}).selectOption('1');
+ await page.getByRole('combobox',{name:'Folder',exact:true}).selectOption('inspector');
+ await selectFile(page,'complete.png',1);await openCollection(page);await expect(position(page)).toHaveText('1 of 1 loaded: complete.png');
+ for(const name of ['Previous media','Next media'])await expect(mediaDialog(page).getByRole('button',{name,exact:true})).toHaveAttribute('aria-disabled','true');
+ const onePlay=mediaDialog(page).getByRole('button',{name:'Play slideshow',exact:true});
+ await expect(onePlay).toHaveAccessibleDescription('Slideshow requires at least two loaded media entries.');await onePlay.dispatchEvent('click');await expect(onePlay).toHaveText('Play');
+ await mediaDialog(page).getByRole('button',{name:'Close preview',exact:true}).click();
+ const library=await(await request.get('/prompt_manager/library')).json();const id=library.items.find(item=>item.text.startsWith('Inspector portrait')).id;
+ await page.goto(`/prompt_manager/workspace#/library/${id}`);await page.getByRole('button',{name:'Select negative variant 1',exact:true}).click();await selectFile(page,'complete.png');await openCollection(page);
+ await expect(position(page)).toContainText('of 1 loaded: complete.png');
+ await mediaDialog(page).getByRole('button',{name:'Close preview',exact:true}).click();
+ await page.getByRole('button',{name:'All images for this positive',exact:true}).click();
+ await selectFile(page,'complete.png');await openCollection(page);await expect(position(page)).toContainText('of 2 loaded:');
+});
+
+test('collection viewer uses real public transforms and resets on navigation',async({page,request})=>{
+ await fullFixture(page,request);
+ await page.evaluate(()=>{
+ window.viewerCalls=[];window.viewerInstances=[];
+ const Native=window.Viewer;
+ for(const method of ['zoom','reset','rotate','scaleX']) {const original=Native.prototype[method];Native.prototype[method]=function(...args){window.viewerCalls.push([method,...args]);return original.apply(this,args);};}
+ window.Viewer=class extends Native {constructor(...args){super(...args);window.viewerInstances.push(this);}};
+ });
+ await openCollection(page);const modal=mediaDialog(page),controls=modal.getByRole('group',{name:'Media controls',exact:true});
+ await expect(modal.locator('.viewer-container')).toHaveCount(1);await expect(modal.locator(':is(.viewer-toolbar,.viewer-navbar,.viewer-title,.viewer-button):visible')).toHaveCount(0);
+ const fitted=await page.evaluate(()=>({...window.viewerInstances.at(-1).imageData}));
+ await controls.getByRole('button',{name:'Zoom in',exact:true}).click();expect(await page.evaluate(()=>window.viewerInstances.at(-1).imageData.width)).toBeGreaterThan(fitted.width);
+ await controls.getByRole('button',{name:'Zoom out',exact:true}).click();
+ await controls.getByRole('button',{name:'Rotate image 90 degrees',exact:true}).click();await controls.getByRole('button',{name:'Flip image horizontally',exact:true}).click();
+ expect(await page.evaluate(()=>({rotate:window.viewerInstances.at(-1).imageData.rotate,scaleX:window.viewerInstances.at(-1).imageData.scaleX}))).toEqual({rotate:90,scaleX:-1});
+ const fit=controls.getByRole('button',{name:'Fit image',exact:true});await fit.click();await expect(fit).toBeFocused();
+ expect(await page.evaluate(()=>({rotate:window.viewerInstances.at(-1).imageData.rotate,scaleX:window.viewerInstances.at(-1).imageData.scaleX}))).toEqual({rotate:0,scaleX:1});
+ expect(await page.evaluate(()=>window.viewerCalls)).toEqual(expect.arrayContaining([['zoom',0.2],['zoom',-0.2],['reset'],['rotate',90],['scaleX',-1]]));
+ await page.evaluate(()=>{window.currentMedia=document.querySelector('.viewer-canvas img');window.currentFit=document.querySelector('[aria-label="Fit image"]');});
+ await modal.getByText('File & provenance',{exact:true}).click();await modal.getByText('Workflow & raw metadata',{exact:true}).click();
+ expect(await page.evaluate(()=>window.currentMedia===document.querySelector('.viewer-canvas img')&&window.currentFit===document.querySelector('[aria-label="Fit image"]'))).toBe(true);
+ await controls.getByRole('button',{name:'Rotate image 90 degrees',exact:true}).click();await controls.getByRole('button',{name:'Next media',exact:true}).click();
+ await expect(controls.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');await expect(modal.locator('.viewer-container')).toHaveCount(1);
+ expect(await page.evaluate(()=>window.currentMedia!==document.querySelector('.viewer-canvas img')&&window.currentFit===document.querySelector('[aria-label="Fit image"]'))).toBe(true);
+ expect(await page.evaluate(()=>({rotate:window.viewerInstances.at(-1).imageData.rotate,scaleX:window.viewerInstances.at(-1).imageData.scaleX}))).toEqual({rotate:0,scaleX:1});
+ await expect(controls.getByRole('button',{name:'Play slideshow',exact:true})).toBeVisible();
+});
+
+async function slideshowFixture(page,request,preferences={videoAutoplay:false,videoMute:true,videoLoop:false}) {
+ await expect(await request.post('/__test__/reset',{data:{full_inspector:true,include_video:true}})).toBeOK();
+ await page.goto('/prompt_manager/workspace#/images');
+ await page.evaluate(async preferences=>{
+ if(preferences) localStorage.setItem('gallerySettings',JSON.stringify(preferences));
+ const [{createMediaViewer},{createInspectionController},{createScope},{createPromptSender}]=await Promise.all([
+ import('/prompt_manager/js/workspace/media-viewer.mjs'),import('/prompt_manager/js/workspace/inspection-state.mjs'),
+ import('/prompt_manager/js/shared/toolkit/core.js'),import('/prompt_manager/js/workspace/prompt-sender.mjs')]);
+ const api=window.ApiClient.createApiClient(),collection=await api.get('/prompt_manager/images/output?mode=workspace&subfolder=inspector&limit=40');
+ const items=['complete.png','clip.webm','explicit-empty.png'].map(filename=>collection.images.find(image=>image.root_index===0&&image.filename===filename));
+ window.slideshowTimers=new Set();window.slideshowCallbacks=[];window.slideshowReady=[];
+ const schedule=window.setTimeout.bind(window),cancel=window.clearTimeout.bind(window);
+ window.setTimeout=(callback,delay,...args)=>{let id;id=schedule(()=>{window.slideshowTimers.delete(id);callback(...args);},delay);if(delay===3500){window.slideshowTimers.add(id);window.slideshowCallbacks.push(callback);}return id;};
+ window.clearTimeout=id=>{window.slideshowTimers.delete(id);cancel(id);};
+ const Native=window.Viewer;
+ window.Viewer=class extends Native {constructor(node,options){window.slideshowReady.push(options.viewed);super(node,options);}};
+ const scope=createScope(),controller=createInspectionController({api}),sender=createPromptSender({scope});scope.cleanup(()=>controller.dispose());
+ const view=createMediaViewer({container:document.getElementById('workspace-main'),parentScope:scope,inspectionController:controller,sender,onSelect:entry=>controller.select(entry)});
+ window.slideshowFixture={scope,controller,view,items,open:()=>view.open({items,selectedKey:'0:inspector/complete.png'})};window.slideshowFixture.open();
+ },preferences);
+ await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
+ await expect(mediaDialog(page).locator('.file-name')).toHaveText('complete.png');
+ await page.clock.install();await page.clock.pauseAt(new Date(Date.now()+1000));
+}
+const slideshowButton=page=>mediaDialog(page).getByRole('group',{name:'Media controls',exact:true}).getByRole('button',{name:/^(Play|Pause|Stop) slideshow$/});
+const timerCount=page=>page.evaluate(()=>window.slideshowTimers.size);
+const mediaStep=async(page,direction)=>mediaDialog(page).getByRole('button',{name:`${direction} media`,exact:true}).click();
+async function readyClockImage(page) {
+ await expect.poll(async()=>{await page.clock.runFor(50);return mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true}).getAttribute('aria-disabled');}).toBe('false');
+}
+
+test('slideshow exposes exact state labels and respects video playback',async({page,request})=>{
+ const originals=[];page.on('request',request=>{if(request.url().includes('_pm_viewer='))originals.push(request.url());});
+ await slideshowFixture(page,request);const button=slideshowButton(page),modal=mediaDialog(page);
+ await expect(button).toHaveText('Play');await expect(button).not.toHaveAttribute('aria-pressed');
+ expect(originals).toHaveLength(1);
+ await button.click();await expect(button).toHaveText('Pause');await expect(button).toBeFocused();await expect(position(page)).toContainText('Slideshow playing.');expect(await timerCount(page)).toBe(1);
+ await page.clock.runFor(3499);await expect(position(page)).toContainText('1 of 3 loaded: complete.png');
+ await page.clock.runFor(1);await expect(position(page)).toContainText('2 of 3 loaded: clip.webm');
+ await expect(button).toHaveAccessibleName('Stop slideshow');await expect(button).toHaveText('Stop');await expect(button).toBeFocused();
+ await expect(position(page)).toContainText('Slideshow paused for video. Next or Previous continues it on an image; Stop ends slideshow.');expect(await timerCount(page)).toBe(0);
+ const video=modal.locator('video');await expect.poll(()=>video.evaluate(node=>node.readyState)).toBeGreaterThanOrEqual(1);
+ expect(await video.evaluate(node=>({controls:node.controls,inline:node.playsInline,muted:node.muted,loop:node.loop}))).toEqual({controls:true,inline:true,muted:true,loop:false});
+ await expect(modal.getByRole('button',{name:'Zoom in',exact:true})).toHaveAccessibleDescription('Image transforms are unavailable for video.');
+ await video.evaluate(node=>node.play());await expect.poll(()=>video.evaluate(node=>node.ended)).toBe(true);
+ await page.clock.runFor(10500);await expect(position(page)).toContainText('2 of 3 loaded');await expect(button).toHaveText('Stop');
+ await video.evaluate(node=>{node.loop=true;node.currentTime=0;return node.play();});
+ await button.click();await expect(button).toHaveText('Play');expect(await video.evaluate(node=>node.paused)).toBe(false);
+ await expect(button).toHaveAttribute('aria-disabled','true');await mediaStep(page,'Next');
+ await readyClockImage(page);await expect(button).toHaveText('Play');expect(await timerCount(page)).toBe(0);
+ await button.click();await mediaStep(page,'Previous');await expect(button).toHaveText('Stop');
+ await mediaStep(page,'Previous');await expect(button).toHaveText('Pause');await expect(position(page)).toContainText('Slideshow waiting for image.');await readyClockImage(page);await expect.poll(()=>timerCount(page)).toBe(1);
+ expect(new Set(originals.map(url=>new URL(url).pathname)).size).toBe(3);
+ await button.click();await modal.locator('.image-preview-details').evaluate(node=>{node.scrollTop=node.scrollHeight;});await page.clock.runFor(50);
+ await button.click();await mediaStep(page,'Previous');await readyClockImage(page);await expect(button).toHaveText('Pause');expect(await timerCount(page)).toBe(1);
+});
+
+test('slideshow stop fences old timer and readiness callbacks',async({page,request})=>{
+ await slideshowFixture(page,request);const button=slideshowButton(page);
+ await button.click();await page.clock.runFor(3499);await button.click();
+ await page.evaluate(()=>{for(const callback of window.slideshowCallbacks)callback();for(const callback of window.slideshowReady)callback();});
+ await page.clock.runFor(7000);await expect(position(page)).toContainText('1 of 3 loaded');await expect(button).toHaveText('Play');expect(await timerCount(page)).toBe(0);
+ await button.click();await mediaStep(page,'Previous');await expect(position(page)).toContainText('Slideshow waiting for image.');await button.click();
+ await page.evaluate(()=>{for(const callback of window.slideshowCallbacks)callback();for(const callback of window.slideshowReady)callback();});
+ await expect(button).toHaveText('Play');expect(await timerCount(page)).toBe(0);await mediaStep(page,'Next');await readyClockImage(page);
+ await page.route('**/images/serve/inspector/explicit-empty.png?*',route=>route.abort());
+ await button.click();await mediaStep(page,'Previous');await expect(position(page)).toContainText('Media could not be loaded.');
+ await expect(button).toHaveText('Play');await expect(button).toHaveAttribute('aria-disabled','true');expect(await timerCount(page)).toBe(0);
+ await mediaStep(page,'Next');await expect(button).toHaveText('Play');
+ await page.evaluate(()=>window.slideshowFixture.scope.dispose());await expect(mediaDialog(page)).toHaveCount(0);
+ await page.evaluate(()=>{for(const callback of window.slideshowCallbacks)callback();for(const callback of window.slideshowReady)callback();window.slideshowFixture.view.stopSlideshow();});
+ expect(await timerCount(page)).toBe(0);
+});
+
+test('slideshow production query and route disposal cancel the active deadline',async({page,request})=>{
+ await fullFixture(page,request);await page.clock.install();
+ await page.evaluate(()=>{
+ window.slideshowTimers=new Set();const schedule=window.setTimeout.bind(window),cancel=window.clearTimeout.bind(window);
+ window.setTimeout=(callback,delay,...args)=>{let id;id=schedule(()=>{window.slideshowTimers.delete(id);callback(...args);},delay);if(delay===3500)window.slideshowTimers.add(id);return id;};
+ window.clearTimeout=id=>{window.slideshowTimers.delete(id);cancel(id);};
+ });
+ await openCollection(page);await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
+ await page.clock.pauseAt(new Date(Date.now()+1000));await slideshowButton(page).click();expect(await timerCount(page)).toBe(1);
+ await page.clock.runFor(3499);
+ const pendingAtReset=await page.evaluate(()=>{const folder=document.getElementById('image-folder');folder.value='inspector';folder.dispatchEvent(new Event('change'));return window.slideshowTimers.size;});
+ expect(pendingAtReset).toBe(0);await page.clock.runFor(1);
+ await expect(mediaDialog(page)).toHaveCount(0);expect(await timerCount(page)).toBe(0);
+ await selectFile(page,'complete.png');await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();await readyClockImage(page);await expect(slideshowButton(page)).toHaveText('Play');
+ await slideshowButton(page).click();await page.evaluate(()=>{location.hash='/overview';});await expect(page.locator('h1')).toHaveText('Overview');await expect(mediaDialog(page)).toHaveCount(0);expect(await timerCount(page)).toBe(0);
+ await page.clock.runFor(7000);await expect(page.locator('.viewer-container')).toHaveCount(0);
+});
+
+test('slideshow interaction visibility sender and disposal clear intent',async({page,request})=>{
+ await slideshowFixture(page,request);const button=slideshowButton(page),modal=mediaDialog(page);
+ for(const action of [
+ ()=>modal.locator('.prompt-positive pre').click(),
+ ()=>modal.locator('.image-preview-details').dispatchEvent('wheel',{deltaY:100}),
+ ()=>modal.getByRole('button',{name:'Copy positive prompt',exact:true}).click(),
+ async()=>{await modal.getByText('Workflow & raw metadata',{exact:true}).click();await downloadText(page,modal.getByRole('button',{name:'Download metadata JSON',exact:true}));},
+ async()=>{await page.evaluate(()=>{Object.defineProperty(document,'hidden',{configurable:true,value:true});document.dispatchEvent(new Event('visibilitychange'));Object.defineProperty(document,'hidden',{configurable:true,value:false});document.dispatchEvent(new Event('visibilitychange'));});},
+ async()=>{const trigger=modal.getByRole('button',{name:'Send prompt pair',exact:true});await trigger.click();await expect(page.getByRole('dialog',{name:'Send to ComfyUI',exact:true})).toBeVisible();await page.keyboard.press('Escape');await expect(trigger).toBeFocused();},
+ ()=>modal.getByRole('group',{name:'Media controls',exact:true}).getByRole('link',{name:'Download original',exact:true}).click(),
+ ()=>page.evaluate(()=>window.slideshowFixture.view.stopSlideshow()),
+ ]) {
+ await button.click();expect(await timerCount(page)).toBe(1);await action();await expect(button).toHaveText('Play');expect(await timerCount(page)).toBe(0);await page.clock.runFor(3500);await expect(position(page)).toContainText('1 of 3 loaded');
+ }
+ for(let mount=0;mount<3;mount++) {
+ await button.click();await modal.getByRole('button',{name:'Close preview',exact:true}).click();await expect(modal).toHaveCount(0);expect(await timerCount(page)).toBe(0);
+ await page.evaluate(()=>window.slideshowFixture.open());await expect(button).toHaveText('Play');
+ await readyClockImage(page);
+ }
+ await button.click();await page.evaluate(()=>window.dispatchEvent(new PageTransitionEvent('pagehide',{persisted:true})));await expect(modal).toHaveCount(0);expect(await timerCount(page)).toBe(0);
+ await page.evaluate(()=>window.dispatchEvent(new PageTransitionEvent('pageshow',{persisted:true})));await expect(modal).toHaveCount(0);
+});
+
+test('slideshow preference failures leave native playback usable',async({page,request})=>{
+ await slideshowFixture(page,request,null);const modal=mediaDialog(page);
+ for(const failure of ['denied','malformed','rejected']) {
+ await page.evaluate(failure=>{
+ if(failure==='denied') {window.normalStorage=Object.getOwnPropertyDescriptor(window,'localStorage');Object.defineProperty(window,'localStorage',{configurable:true,get(){throw new DOMException('Denied','SecurityError');}});}
+ else {if(window.normalStorage)Object.defineProperty(window,'localStorage',window.normalStorage);localStorage.setItem('gallerySettings',failure==='malformed'?'{broken':JSON.stringify({videoAutoplay:true,videoMute:true,videoLoop:false}));}
+ if(failure==='rejected'){window.nativePlay=HTMLMediaElement.prototype.play;window.playRejections=0;HTMLMediaElement.prototype.play=function(){window.playRejections++;return Promise.reject(new DOMException('Blocked','NotAllowedError'));};}
+ },failure);
+ await mediaStep(page,'Next');const video=modal.locator('video');await expect.poll(()=>video.evaluate(node=>node.readyState)).toBeGreaterThanOrEqual(1);
+ expect(await video.evaluate(node=>({controls:node.controls,inline:node.playsInline,muted:node.muted,loop:node.loop}))).toEqual({controls:true,inline:true,muted:true,loop:failure!=='rejected'});
+ if(failure==='rejected') {expect(await page.evaluate(()=>window.playRejections)).toBe(1);await page.evaluate(()=>{HTMLMediaElement.prototype.play=window.nativePlay;});}
+ await video.evaluate(node=>node.play());expect(await video.evaluate(node=>node.paused)).toBe(false);
+ await mediaStep(page,'Previous');await expect(slideshowButton(page)).toHaveText('Play');
+ }
+});
+
+test('full inspector attaches matched theme and mobile evidence with reachable content',async({page,request},testInfo)=>{
+ await expect(await request.post('/__test__/reset',{data:{full_inspector:true,include_video:true}})).toBeOK();
+ await page.goto('/prompt_manager/workspace#/images');
+ const measurements=[];
+ async function capture(name,viewer=false) {
+ await page.evaluate(()=>document.fonts.ready);
+ if(viewer) {
+ const modal=mediaDialog(page),rail=modal.locator('.image-preview-details');
+ expect(await modal.evaluate(node=>node.scrollWidth<=node.clientWidth+1)).toBe(true);
+ measurements.push({name,...await modal.evaluate(node=>{
+ const box=element=>{const bounds=element.getBoundingClientRect();return{x:bounds.x,y:bounds.y,width:bounds.width,height:bounds.height};};
+ return{frame:box(node),header:box(node.querySelector('.image-preview-header')),stage:box(node.querySelector('.image-preview-stage')),
+ toolbar:box(node.querySelector('.image-preview-toolbar')),rail:box(node.querySelector('.image-preview-details')),
+ footer:node.querySelector('.file-inspector-footer')?box(node.querySelector('.file-inspector-footer')):null};
+ })});
+ if(page.viewportSize().width===1600)expect(await rail.evaluate(node=>node.getBoundingClientRect().width)).toBe(340);
+ else await expect(rail).toHaveCSS('overflow-y','visible');
+ }
+ const file=testInfo.outputPath(`${name}.png`);await page.screenshot({path:file});await testInfo.attach(name,{path:file,contentType:'image/png'});
+ }
+ for(const [width,height,theme]of [[1600,1100,'dark'],[1600,1100,'light'],[390,844,'dark'],[390,844,'light']]) {
+ const label=`${theme}-${width}`;await page.setViewportSize({width,height});
+ await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption(theme);
+ await selectFile(page,'complete.png');await expect(inspector(page).locator('.generation-facts dd').first()).toHaveText('9007199254740993');
+ if(width===1600)await page.evaluate(()=>scrollTo(0,0));else await inspector(page).locator('.file-cover').evaluate(node=>node.scrollIntoView({block:'start'}));
+ await capture(`inline-${label}`);
+ await openCollection(page);const modal=mediaDialog(page),rail=modal.locator('.image-preview-details');
+ await expect(modal.locator('.viewer-canvas img')).toBeVisible();await expect.poll(()=>modal.locator('.viewer-canvas img').evaluate(node=>node.complete&&node.naturalWidth>0)).toBe(true);
+ await modal.evaluate(node=>{node.scrollTop=0;});await capture(`viewer-${label}`,true);
+ await expect(rail.locator('.prompt-negative')).toContainText('blurred edges, watermark');await rail.locator('.generation-facts').scrollIntoViewIfNeeded();await capture(`facts-${label}`,true);
+ await rail.getByText('File & provenance',{exact:true}).click();await rail.locator('.file-facts').scrollIntoViewIfNeeded();await capture(`provenance-${label}`,true);
+ await rail.getByText('Workflow & raw metadata',{exact:true}).click();const raw=rail.locator('pre code').first();await raw.scrollIntoViewIfNeeded();await expect(raw).toBeInViewport();await capture(`raw-${label}`,true);
+ const send=rail.getByRole('button',{name:'Send prompt pair',exact:true});await send.focus();await expect(send).toBeInViewport();await send.click();await expect(page.getByRole('dialog',{name:'Send to ComfyUI',exact:true})).toBeVisible();await capture(`sender-${label}`);await page.keyboard.press('Escape');await expect(send).toBeFocused();
+ await modal.getByRole('button',{name:'Close preview',exact:true}).click();await selectFile(page,'clip.webm');await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();
+ await expect.poll(()=>modal.locator('video').evaluate(node=>node.readyState)).toBeGreaterThanOrEqual(1);await capture(`video-${label}`,true);await modal.getByRole('button',{name:'Close preview',exact:true}).click();
+ await selectFile(page,'control-heavy.png');await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();
+ await expect(modal).toContainText('Unavailable within inspection limits');await modal.getByText('Workflow & raw metadata',{exact:true}).click();
+ const limitedDownload=modal.getByRole('button',{name:'Download metadata JSON',exact:true});await limitedDownload.scrollIntoViewIfNeeded();await expect(limitedDownload).toHaveAttribute('aria-disabled','true');await capture(`limited-${label}`,true);
+ await modal.getByRole('button',{name:'Close preview',exact:true}).click();
+ }
+ const fs=await import('node:fs/promises'),geometryPath=testInfo.outputPath('inspector-geometry.json');
+ await fs.writeFile(geometryPath,JSON.stringify(measurements,null,2));await testInfo.attach('inspector-geometry',{path:geometryPath,contentType:'application/json'});
+});
+
+test('collection viewer preserves original download and rooted retries',async({page,request})=>{
+ await fullFixture(page,request);let deny=true;const originals=[];
+ await page.route('**/images/serve/inspector/complete.png?*',route=>{originals.push(route.request().url());return deny?route.fulfill({status:404,body:'Missing'}):route.continue();});
+ await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();const modal=mediaDialog(page);
+ await expect(modal).toContainText('Media could not be loaded. Retry this file.');await expect(modal.locator('.generation-facts dd').first()).toHaveText('9007199254740993');
+ await expect(modal.locator('.image-preview-position')).toContainText('Media could not be loaded.');
+ const download=modal.getByRole('group',{name:'Media controls',exact:true}).getByRole('link',{name:'Download original',exact:true});const original=await download.getAttribute('href');
+ await expect(modal.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','true');
+ const denied=page.waitForResponse(response=>response.url().includes('/images/serve/inspector/complete.png?')&&response.status()===404);
+ await modal.getByRole('button',{name:'Retry media',exact:true}).click();await denied;
+ await expect.poll(()=>mediaStage(page).locator('.image-preview-media > img').evaluate(node=>node.complete&&node.naturalWidth===0)).toBe(true);
+ await expect(modal.getByRole('button',{name:'Retry media',exact:true})).toBeVisible();
+ deny=false;await modal.getByRole('button',{name:'Retry media',exact:true}).click();await expect(modal.locator('.viewer-canvas img')).toBeVisible();
+ await expect(download).toHaveAttribute('href',original);
+ const stages=originals.map(value=>new URL(value)).filter(url=>url.searchParams.has('_pm_viewer'));
+ expect(new Set(stages.map(url=>url.searchParams.get('_pm_viewer'))).size).toBe(3);
+ for(const value of originals) {const actual=new URL(value),expected=new URL(original,'http://127.0.0.1');expect(actual.pathname).toBe(expected.pathname);for(const key of ['root','root_scope','v'])expect(actual.searchParams.get(key)).toBe(expected.searchParams.get(key));expect(actual.searchParams.getAll('_pm_viewer').length).toBeLessThanOrEqual(1);expect([...actual.searchParams.keys()].filter(key=>!expected.searchParams.has(key))).toEqual(actual.searchParams.has('_pm_viewer')?['_pm_viewer']:[]);}
+ const [saved]=await Promise.all([page.waitForEvent('download'),download.click()]);expect(saved.suggestedFilename()).toBe('complete.png');
+ await modal.getByRole('button',{name:'Close preview',exact:true}).click();await page.evaluate(()=>{window.availableViewer=window.Viewer;window.Viewer=undefined;});
+ await page.route('**/images/inspect/inspector/explicit-empty.png?*',route=>route.fulfill({status:503,json:{success:false,error:'Unavailable'}}),{times:1});
+ await selectFile(page,'explicit-empty.png');await expect(inspector(page)).toContainText('Could not read prompts from this file.');
+ await page.locator('article[data-image-key="0:inspector/explicit-empty.png"] .image-card-footer button').click();
+ await expect(mediaStage(page).locator('img')).toBeVisible();await expect(mediaStage(page).locator('img')).toHaveJSProperty('naturalWidth',720);
+ await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAccessibleDescription('Image transforms are unavailable because Viewer.js could not load.');
+ await page.evaluate(()=>{window.Viewer=window.availableViewer;});await mediaDialog(page).getByRole('button',{name:'Retry prompt details',exact:true}).click();
+ await expect(mediaDialog(page).locator('.file-name')).toHaveText('explicit-empty.png');await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','true');
+});
+
+test('collection viewer keeps nested dialog focus and route cleanup',async({page,request})=>{
+ await fullFixture(page,request);await openCollection(page);const modal=mediaDialog(page);
+ const send=modal.getByRole('button',{name:'Send prompt pair',exact:true});await send.click();const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
+ await expect(sender).toBeVisible();const before=await position(page).textContent();await page.keyboard.press('ArrowRight');await expect(position(page)).toHaveText(before);
+ await page.keyboard.press('Escape');await expect(sender).toBeHidden();await expect(modal).toBeVisible();await expect(send).toBeFocused();
+ const text=modal.locator('.prompt-positive pre');await text.focus();await page.keyboard.press('ArrowRight');await expect(position(page)).toHaveText(before);
+ await modal.getByText('Workflow & raw metadata',{exact:true}).focus();await page.keyboard.press('ArrowLeft');await expect(position(page)).toHaveText(before);
+ await modal.getByRole('button',{name:'Next media',exact:true}).click();const key=(await position(page).textContent()).split('loaded: ')[1];
+ await page.keyboard.press('Escape');await expect(modal).toHaveCount(0);await expect(page.locator('.image-open[aria-pressed="true"]')).toBeFocused();await expect(inspector(page).locator('.file-name')).toHaveText(key);
+ await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();await page.evaluate(()=>{location.hash='/overview';});
+ await expect(page.locator('h1')).toHaveText('Overview');await expect(page.locator('.viewer-container,.image-preview')).toHaveCount(0);
+});
+
+test('collection viewer matches approved responsive frame',async({page,request},testInfo)=>{
+ await page.setViewportSize({width:1600,height:1100});await fullFixture(page,request);await openCollection(page);
+ const modal=mediaDialog(page),body=modal.locator('.image-preview-body'),rail=modal.locator('.image-preview-details');
+ // Absolute gallery cards relayout through ResizeObserver and rAF after resize or CSS zoom.
+ const settleViewport=width=>expect.poll(()=>page.evaluate(()=>({inner:innerWidth,client:document.documentElement.clientWidth,scroll:document.documentElement.scrollWidth})),{message:`Images gallery relayout settles the ${width}px viewport`}).toEqual({inner:width,client:width,scroll:width});
+ await settleViewport(1600);
+ for(const theme of ['dark','light']) {
+ await page.evaluate(theme=>{const preference=document.getElementById('workspace-appearance');preference.value=theme;preference.dispatchEvent(new Event('change'));},theme);
+ const geometry=await modal.evaluate(node=>{const style=getComputedStyle(node),box=node.getBoundingClientRect(),rail=node.querySelector('.image-preview-details').getBoundingClientRect();return{width:box.width,height:box.height,padding:style.padding,radius:style.borderRadius,rail:rail.width,gap:getComputedStyle(node.querySelector('.image-preview-body')).columnGap};});
+ expect(geometry).toEqual({width:1500,height:940,padding:'18px',radius:'9px',rail:340,gap:'20px'});
+ await page.screenshot({path:testInfo.outputPath(`viewer-${theme}-1600.png`)});
+ }
+ for(const [width,expected] of [[1200,300],[900,270]]) {await page.setViewportSize({width,height:1000});await settleViewport(width);await expect(rail).toHaveCSS('width',`${expected}px`);}
+ await page.setViewportSize({width:390,height:844});await settleViewport(390);expect(await modal.evaluate(node=>node.getBoundingClientRect().width)).toBeCloseTo(356,1);
+ for(const [width,expected]of [[840,806],[850,816],[860,825.6]]) {
+ await page.setViewportSize({width,height:1000});await settleViewport(width);expect(await modal.evaluate(node=>node.getBoundingClientRect().width)).toBeCloseTo(expected,1);
+ }
+ await page.setViewportSize({width:390,height:844});await settleViewport(390);
+ for(const zoom of [1,2]) {
+ await page.evaluate(zoom=>{document.documentElement.style.zoom=String(zoom);},zoom);
+ await settleViewport(390);
+ expect(await modal.evaluate(node=>node.getBoundingClientRect().width)).toBeCloseTo(zoom===1?356:322,1);
+ expect(await modal.evaluate(node=>{const box=node.getBoundingClientRect();return box.top>=0&&box.bottom<=innerHeight+1;})).toBe(true);
+ await expect.poll(()=>modal.evaluate(node=>node.scrollWidth<=node.clientWidth+1)).toBe(true);
+ await expect(rail).toHaveCSS('overflow-y','visible');expect(await body.evaluate(node=>getComputedStyle(node).gridTemplateColumns.split(' ').length)).toBe(1);
+ await rail.getByText('Workflow & raw metadata',{exact:true}).click();const raw=rail.locator('pre code').first();await expect(raw).toBeVisible();
+ await rail.getByRole('button',{name:'Send prompt pair',exact:true}).focus();await expect(rail.getByRole('button',{name:'Send prompt pair',exact:true})).toBeInViewport();
+ await page.screenshot({path:testInfo.outputPath(`viewer-mobile-${zoom}x.png`)});await rail.getByText('Workflow & raw metadata',{exact:true}).click();
+ }
+ await rail.evaluate(node=>{node.style.fontFamily='"DejaVu Sans", sans-serif';});
+ await rail.getByText('Workflow & raw metadata',{exact:true}).click();
+ const sendPair=rail.getByRole('button',{name:'Send prompt pair',exact:true});await sendPair.focus();await expect(sendPair).toBeInViewport();
+ await rail.getByText('Workflow & raw metadata',{exact:true}).click();await sendPair.focus();await expect(sendPair).toBeFocused();await expect(sendPair).toBeInViewport();
+ const footer=await sendPair.evaluate(node=>{const box=node.getBoundingClientRect(),parent=node.closest('.file-inspector-footer').getBoundingClientRect();return{width:box.width,right:box.right,available:parent.width,parentRight:parent.right,height:box.height,minimum:matchMedia('(pointer: coarse)').matches?80:60};});
+ expect(footer.width).toBeLessThanOrEqual(footer.available);expect(footer.right).toBeLessThanOrEqual(footer.parentRight);expect(footer.height).toBeGreaterThanOrEqual(footer.minimum);await expect(sendPair.locator('svg')).toHaveCount(1);
+ expect(await modal.evaluate(node=>node.getBoundingClientRect().width)).toBeCloseTo(322,1);await expect.poll(()=>modal.evaluate(node=>node.scrollWidth<=node.clientWidth+1)).toBe(true);
+ await testInfo.attach('wider-font-footer-geometry',{body:JSON.stringify({footer,modal:await modal.evaluate(node=>({width:node.getBoundingClientRect().width,scrollWidth:node.scrollWidth,clientWidth:node.clientWidth}))}),contentType:'application/json'});
+ await page.screenshot({path:testInfo.outputPath('viewer-mobile-wider-font-2x.png')});
+ await modal.getByRole('button',{name:'Close preview',exact:true}).click();await page.evaluate(()=>{document.documentElement.style.zoom='1';});await settleViewport(390);
+ const longFiles=await(await request.get('/prompt_manager/images/output?mode=workspace&q=a-very-long&limit=40')).json();const long=longFiles.images[0];
+ await page.locator(`article[data-image-key="${long.root_index}:${long.path}"] .image-open`).click();await openCollection(page);
+ await page.evaluate(()=>{document.documentElement.style.zoom='2';});await settleViewport(390);await modal.evaluate(node=>{node.scrollTop=0;});
+ await expect(modal.getByRole('button',{name:'Close preview',exact:true})).toBeInViewport();
+});
+
+async function currentStageFit(page) {
+ return page.evaluate(()=>{
+ const instance=window.fitInstances.at(-1),stage=document.querySelector('.image-preview-stage');
+ const bounds=stage.getBoundingClientRect(),image=instance.image.getBoundingClientRect();
+ return {stage:{width:stage.offsetWidth,height:stage.offsetHeight},viewer:{...instance.viewerData},
+ image:{width:image.width,height:image.height},
+ center:{x:image.x+image.width/2-bounds.x-bounds.width/2,y:image.y+image.height/2-bounds.y-bounds.height/2},
+ contained:image.left>=bounds.left-1&&image.top>=bounds.top-1&&image.right<=bounds.right+1&&image.bottom<=bounds.bottom+1};
+ });
+}
+async function openStageFitFixture(page,request) {
+ await expect(await request.post('/__test__/reset',{data:{full_inspector:true}})).toBeOK();
+ await page.setViewportSize({width:1600,height:1100});await page.goto('/prompt_manager/workspace#/images');
+ let release;const pending=new Promise(resolve=>{release=resolve;});
+ await page.route('**/images/inspect/architecture/study-02.png?*',async route=>{
+ const response=await route.fetch(),data=await response.json();data.library.title='W'.repeat(220);await pending;
+ await route.fulfill({response,json:data}).catch(()=>{});
+ });
+ await page.evaluate(async()=>{
+ const [{createMediaViewer},{createInspectionController},{createScope},{createPromptSender}]=await Promise.all([
+ import('/prompt_manager/js/workspace/media-viewer.mjs'),import('/prompt_manager/js/workspace/inspection-state.mjs'),
+ import('/prompt_manager/js/shared/toolkit/core.js'),import('/prompt_manager/js/workspace/prompt-sender.mjs')]);
+ const api=window.ApiClient.createApiClient(),list=await api.get('/prompt_manager/images/output?mode=workspace&limit=40');
+ const tall=list.images.find(image=>image.root_index===1&&image.path==='architecture/study-02.png');
+ const next=list.images.find(image=>image.root_index===0&&image.path==='inspector/explicit-empty.png');
+ window.fitInstances=[];window.fitResizeCalls=[];window.fitObservers=[];
+ const Native=window.Viewer,resize=Native.prototype.resize;
+ Native.prototype.resize=function(...args){window.fitResizeCalls.push({destroyed:!!this.destroyed,connected:this.element.isConnected});return resize.apply(this,args);};
+ window.Viewer=class extends Native {constructor(...args){super(...args);window.fitInstances.push(this);}};
+ const NativeObserver=window.ResizeObserver;
+ window.ResizeObserver=class extends NativeObserver {
+ constructor(callback){super(callback);this.probe={callback,observer:this,disconnected:false};window.fitObservers.push(this.probe);}
+ observe(target,...args){this.probe.target=target;return super.observe(target,...args);}
+ disconnect(){this.probe.disconnected=true;super.disconnect();}
+ };
+ const scope=createScope(),controller=createInspectionController({api}),sender=createPromptSender({scope});scope.cleanup(()=>controller.dispose());
+ const view=createMediaViewer({container:document.getElementById('workspace-main'),parentScope:scope,inspectionController:controller,sender,onSelect:entry=>controller.select(entry)});
+ window.stageFitFixture={scope,controller,view};view.open({items:[tall,next],selectedKey:`${tall.root_index}:${tall.path}`,trigger:document.activeElement});
+ });
+ await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
+ await expect(mediaDialog(page).locator('.image-preview-reason')).toBeHidden();
+ return release;
+}
+async function attachStageFit(page,testInfo,name) {
+ const data=await currentStageFit(page);await testInfo.attach(name,{body:JSON.stringify(data,null,2),contentType:'application/json'});return data;
+}
+
+test('collection viewer fits current stage after readiness reason removal',async({page,request},testInfo)=>{
+ const release=await openStageFitFixture(page,request);
+ try {
+ await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
+ await attachStageFit(page,testInfo,'ready-stage-fit');
+ await page.screenshot({path:testInfo.outputPath('ready-stage-fit.png')});
+ await expect.poll(async()=>{const fit=await currentStageFit(page);return Math.abs(fit.viewer.height-fit.stage.height);}).toBeLessThanOrEqual(1);
+ const fit=await currentStageFit(page);expect(Math.abs(fit.center.x)).toBeLessThanOrEqual(1);expect(Math.abs(fit.center.y)).toBeLessThanOrEqual(1);expect(fit.contained).toBe(true);
+ } finally {release();await page.evaluate(()=>window.stageFitFixture.scope.dispose());}
+});
+
+test('collection viewer refits a late wrapped title without replacing current media',async({page,request},testInfo)=>{
+ const release=await openStageFitFixture(page,request);
+ try {
+ const modal=mediaDialog(page),fit=modal.getByRole('button',{name:'Fit image',exact:true});
+ await page.evaluate(()=>{window.fitImage=document.querySelector('.viewer-canvas img');window.fitControl=document.querySelector('[aria-label="Fit image"]');});
+ await fit.focus();const initial=await attachStageFit(page,testInfo,'before-late-title');release();
+ await expect(modal.locator('.image-preview-header h2')).toHaveText(`${'W'.repeat(220)} · study-02.png`);
+ await expect(fit).toBeFocused();await fit.click();await attachStageFit(page,testInfo,'late-title-fit');
+ await expect.poll(async()=>{const data=await currentStageFit(page);return Math.abs(data.viewer.height-data.stage.height);}).toBeLessThanOrEqual(1);
+ const current=await currentStageFit(page);expect(current.stage.height).toBeLessThan(initial.stage.height);expect(Math.abs(current.center.y)).toBeLessThanOrEqual(1);expect(current.contained).toBe(true);
+ await page.screenshot({path:testInfo.outputPath('late-title-fit.png')});
+ expect(await page.evaluate(()=>window.fitImage===document.querySelector('.viewer-canvas img')&&window.fitControl===document.querySelector('[aria-label="Fit image"]')&&window.fitInstances.length===1)).toBe(true);
+ await modal.getByRole('button',{name:'Zoom in',exact:true}).click();await modal.getByRole('button',{name:'Rotate image 90 degrees',exact:true}).click();await modal.getByRole('button',{name:'Flip image horizontally',exact:true}).click();
+ const transformed=await page.evaluate(()=>({...window.fitInstances[0].imageData}));await page.evaluate(()=>window.stageFitFixture.controller.retry());
+ await expect(modal.locator('.file-name')).toHaveText('study-02.png');await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
+ expect(await page.evaluate(()=>window.fitInstances[0].imageData)).toEqual(transformed);
+ await fit.click();const fitted=await currentStageFit(page);expect(Math.abs(fitted.center.y)).toBeLessThanOrEqual(1);expect(fitted.contained).toBe(true);
+ await modal.getByRole('button',{name:'Next media',exact:true}).click();await expect(modal.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
+ await page.evaluate(()=>{for(const probe of window.fitObservers.filter(probe=>probe.disconnected))probe.callback([],probe.observer);});
+ await modal.getByRole('button',{name:'Close preview',exact:true}).click();await expect(modal).toHaveCount(0);
+ await page.evaluate(async()=>{for(const probe of window.fitObservers)probe.callback([],probe.observer);await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));});
+ expect(await page.evaluate(()=>window.fitObservers.every(probe=>probe.disconnected))).toBe(true);
+ expect(await page.evaluate(()=>window.fitResizeCalls.filter(call=>call.destroyed||!call.connected))).toEqual([]);
+ } finally {release();await page.evaluate(()=>window.stageFitFixture.scope.dispose());}
+});
diff --git a/tests/browser/prompt-inspector.spec.mjs b/tests/browser/prompt-inspector.spec.mjs
index 49cb015..e509be9 100644
--- a/tests/browser/prompt-inspector.spec.mjs
+++ b/tests/browser/prompt-inspector.spec.mjs
@@ -36,6 +36,34 @@ async function chooseDestination(page) {
return sender;
}
+test('collection viewer rejects stale source and sends only refreshed API evidence',async({page,request,context})=>{
+ const host=await fakeHost(context,page);
+ await page.addInitScript(()=>{window.copied=[];Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>window.copied.push(text)}});});
+ try {
+ const loaded=await(await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();const stale=loaded.images.find(image=>image.root_index===0&&image.path==='shared/same.png');
+ await page.goto(workspace);await select(page,'1:shared/same.png');await expect(details(page)).toContainText('Dog landscape');
+ await expect(await request.post('/prompt_manager/settings',{data:{gallery_root_paths:['input','output']}})).toBeOK();
+ const response=page.waitForResponse(result=>result.url().includes('/images/inspect/shared/same.png?root=0'));
+ await select(page,'0:shared/same.png');const changed=await response;expect(changed.status()).toBe(409);expect((await changed.json()).code).toBe('source-changed');
+ await page.locator('article[data-image-key="0:shared/same.png"] .image-card-footer button').click();const preview=page.getByRole('dialog',{name:'Image preview',exact:true});
+ const message='The source changed. Refresh the image collection before inspecting this file.';
+ await expect(preview).toContainText(message);await expect(preview.locator('[data-copy-role],[data-send-role],.file-inspector a[download]')).toHaveCount(0);
+ const original=preview.getByRole('group',{name:'Media controls',exact:true}).getByLabel('Download original',{exact:true});
+ await expect(original).toHaveAttribute('aria-disabled','true');await expect(original).not.toHaveAttribute('href',/./);
+ let downloads=0;page.on('download',()=>downloads++);await original.focus();await page.keyboard.press('Enter');expect(downloads).toBe(0);
+ await expect(preview.getByRole('button',{name:'Retry media',exact:true})).toBeHidden();
+ await expect(preview.getByRole('button',{name:'Retry prompt details',exact:true})).toHaveCount(0);await expect(preview).not.toContainText('Dog landscape');await expect(preview).not.toContainText('Cat portrait');
+ expect(await page.evaluate(()=>window.copied)).toEqual([]);expect(await host.evaluate(()=>window.applied)).toEqual([]);
+ const refreshed=page.waitForResponse(result=>result.url().includes('/images/output?'));
+ await preview.getByRole('button',{name:'Refresh image collection',exact:true}).click();await refreshed;await expect(preview).toHaveCount(0);
+ const collection=await(await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();const fresh=collection.images.find(image=>image.root_index===0&&image.path===stale.path);expect(fresh.root_scope).not.toBe(stale.root_scope);
+ const evidence=await(await request.get(`/prompt_manager/images/inspect/shared/same.png?root=0&root_scope=${fresh.root_scope}`)).json();
+ await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:'Open full preview',exact:true}).click();await expect(preview).toContainText(evidence.prompts.positive.text);
+ await preview.getByRole('button',{name:'Send prompt pair',exact:true}).click();const sender=await chooseDestination(page);await sender.getByRole('button',{name:'Preview changes',exact:true}).click();await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
+ expect(await host.evaluate(()=>window.applied)).toEqual([{positive:evidence.prompts.positive.text,negative:evidence.prompts.negative.text}]);expect(await page.evaluate(()=>window.copied)).toEqual([]);
+ } finally {await expect(await request.post('/prompt_manager/settings',{data:{gallery_root_paths:['output','input']}})).toBeOK();await host.evaluate(()=>window.fixtureHost.dispose());await host.close();}
+});
+
test('physical image inspector resolves positive and negative metadata separately for duplicate filenames',async({page})=>{
await page.goto(workspace);await select(page,'0:shared/same.png');
await expect(role(details(page),'Positive')).toContainText('Cat portrait');await expect(role(details(page),'Negative')).toContainText('blurry, watermark');
@@ -141,7 +169,7 @@ test('no open ComfyUI window is actionable and rediscovery finds a newly opened
test('mobile inspector exposes full long prompts, truthful uncertainty and copy failure feedback',async({page})=>{
const text=`A long detailed prompt ${'with readable full composition, '.repeat(150)}END OF FULL PROMPT`;
await page.addInitScript(()=>Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async()=>{throw new Error('Permission denied');}}}));
- await page.route('**/images/inspect/**',route=>route.fulfill({json:{success:true,image:{root_index:0,path:'shared/same.png',filename:'same.png'},prompts:{positive:{status:'resolved',text,source:'execution'},negative:{status:'ambiguous',text:null,source:null}}}}));
+ await page.route('**/images/inspect/**',route=>route.fulfill({json:{success:true,root_scope:new URL(route.request().url()).searchParams.get('root_scope'),image:{root_index:0,path:'shared/same.png',filename:'same.png'},prompts:{positive:{status:'resolved',text,source:'execution'},negative:{status:'ambiguous',text:null,source:null}}}}));
await page.setViewportSize({width:390,height:844});await page.goto(workspace);await select(page,'0:shared/same.png');
const positive=role(details(page),'Positive');await expect(positive.locator('pre')).toHaveText(text);
await expect(role(details(page),'Negative')).toContainText('Multiple possible prompts. Source could not be determined.');
@@ -195,7 +223,7 @@ test('responsive gallery defers observed size writes and still fits the new view
});
test('unverified saved associations explain uncertainty and cannot be sent',async({page})=>{
- await page.route('**/images/inspect/uncertain.png?root=0',route=>route.fulfill({json:{success:true,prompts:{
+ await page.route('**/images/inspect/uncertain.png?root=0*',route=>route.fulfill({json:{success:true,root_scope:new URL(route.request().url()).searchParams.get('root_scope'),image:{root_index:0,path:'uncertain.png',filename:'uncertain.png'},prompts:{
positive:{status:'unresolved',text:null,source:'saved-association',message:'A saved library association exists, but it cannot be verified against this file.'},
negative:{status:'absent',text:null,source:null},
}}}));
diff --git a/tests/browser/server.py b/tests/browser/server.py
index b2575af..419fa3a 100644
--- a/tests/browser/server.py
+++ b/tests/browser/server.py
@@ -15,6 +15,7 @@ from PIL import Image
from PIL.PngImagePlugin import PngInfo
from tests.browser.workspace_visual_fixture import seed_workspace_visual
+from tests.browser.workspace_inspector_fixture import seed_workspace_inspector
class TestModel:
@@ -226,6 +227,7 @@ def create_app(root: Path) -> web.Application:
async def reset(request: web.Request) -> web.Response:
data = await request.json() if request.can_read_body else {}
workspace_visual = data.get("workspace_visual", False)
+ full_inspector = data.get("full_inspector", False)
extra = data.get("extra_images", 0)
include_video = data.get("include_video", False)
prompt_inspector = data.get("prompt_inspector", False)
@@ -234,6 +236,17 @@ def create_app(root: Path) -> web.Application:
extra_variants = data.get("extra_variants", 0)
if (
not isinstance(workspace_visual, bool)
+ or not isinstance(full_inspector, bool)
+ or (
+ full_inspector
+ and (
+ workspace_visual
+ or prompt_inspector
+ or native_library
+ or extra_prompts != 0
+ or extra_variants != 0
+ )
+ )
or (
workspace_visual
and any(
@@ -245,6 +258,7 @@ def create_app(root: Path) -> web.Application:
"native_library",
"extra_prompts",
"extra_variants",
+ "full_inspector",
)
)
)
@@ -265,6 +279,13 @@ def create_app(root: Path) -> web.Application:
if workspace_visual:
await api._run_in_executor(seed_workspace_visual, api, root)
return web.json_response({"success": True})
+ if full_inspector:
+ await api._run_in_executor(
+ lambda: seed_workspace_inspector(
+ api, root, extra_images=extra, include_video=include_video
+ )
+ )
+ return web.json_response({"success": True})
await api._run_in_executor(
seed,
extra,
diff --git a/tests/browser/workspace-images.spec.mjs b/tests/browser/workspace-images.spec.mjs
index 219170e..520c438 100644
--- a/tests/browser/workspace-images.spec.mjs
+++ b/tests/browser/workspace-images.spec.mjs
@@ -97,6 +97,66 @@ test('a short visible batch automatically continues until the sentinel is filled
await expect(page.getByRole('button', { name: 'Load more images', exact: true })).toBeHidden();
});
+for (const embedded of [false, true]) {
+ for (const close of ['Close', 'Escape', 'parent abort']) {
+ test(`viewer closure resumes native continuation after a short pending page: ${embedded ? 'embedded' : 'Images'} ${close}`, async ({ page, request }, testInfo) => {
+ await expect(await request.post('/__test__/reset', { data: { extra_images: 80 } })).toBeOK();
+ const data = await (await request.get('/prompt_manager/images/output?limit=200')).json();
+ await page.setViewportSize({ width: 1600, height: 1100 });
+ await page.route('**/prompt_manager/workspace', async route => {
+ const response = await route.fetch();
+ const html = (await response.text()).replace('', '');
+ await route.fulfill({ response, body: html });
+ });
+ await page.goto('/prompt_manager/workspace');
+ await page.evaluate(async ({ images, embedded }) => {
+ const { mountImages } = await import('/prompt_manager/js/workspace/images.mjs');
+ const { createScope } = await import('/prompt_manager/js/shared/toolkit/core.js');
+ window.ApiClient = { resolveUrl: value => value };
+ const fixture = window.continuationFixture = { reads: [], scope: createScope() };
+ const api = { get: async (url, options) => (await fetch(url, options)).json() };
+ const fetchPage = ({ parameters }) => {
+ fixture.reads.push(parameters.get('cursor'));
+ if (fixture.reads.length === 1) return { images: images.slice(0, 40), total: 80, has_more: true, next_cursor: 'next-0' };
+ if (fixture.reads.length === 2) return new Promise(resolve => {
+ fixture.finishPage = () => resolve({ images: [], total: 80, has_more: true, next_cursor: 'next-1' });
+ });
+ return { images: images.slice(40, 80), total: 80, has_more: false, next_cursor: null };
+ };
+ mountImages(document.querySelector('#workspace-main'), api, fixture.scope, { embedded, fetchPage });
+ }, { images: data.images, embedded });
+ await expect(cards(page)).toHaveCount(40);
+ expect(await page.evaluate(() => window.continuationFixture.reads)).toEqual([null]);
+ await page.locator('.image-continuation').scrollIntoViewIfNeeded();
+ await expect.poll(() => page.evaluate(() => window.continuationFixture.reads)).toEqual([null, 'next-0']);
+ const key = await cards(page).last().getAttribute('data-image-key');
+ const preview = page.locator(`article[data-image-key="${key}"]`).getByRole('button', { name: /^Preview / });
+ await preview.click();
+ const viewer = page.getByRole('dialog', { name: 'Image preview', exact: true });
+ await expect(viewer.locator('.viewer-canvas img')).toBeVisible();
+ await page.evaluate(() => window.continuationFixture.finishPage());
+ await expect(page.getByRole('button', { name: 'Load more images', exact: true })).toHaveAttribute('aria-disabled', 'false');
+ await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
+ expect(await page.evaluate(() => ({ reads: window.continuationFixture.reads, nativeObserver: typeof IntersectionObserver, visible: document.querySelector('.image-continuation').getBoundingClientRect().top <= innerHeight })))
+ .toEqual({ reads: [null, 'next-0'], nativeObserver: 'function', visible: true });
+ if (close === 'parent abort') await page.evaluate(() => window.continuationFixture.scope.dispose());
+ else if (close === 'Close') await viewer.getByRole('button', { name: 'Close preview', exact: true }).click();
+ else await page.keyboard.press('Escape');
+ await expect(viewer).toHaveCount(0);
+ if (close !== 'parent abort') {
+ await expect(preview).toBeFocused();
+ await expect.poll(() => page.evaluate(() => window.continuationFixture.reads)).toEqual([null, 'next-0', 'next-1']);
+ await expect(page.locator('.image-toolbar')).toContainText(embedded ? '80 loaded' : '80 of 80');
+ }
+ await page.evaluate(() => window.continuationFixture.scope.dispose());
+ await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
+ const reads = await page.evaluate(() => window.continuationFixture.reads);
+ expect(reads).toEqual(close === 'parent abort' ? [null, 'next-0'] : [null, 'next-0', 'next-1']);
+ await testInfo.attach('native-continuation-reads', { body: JSON.stringify({ embedded, close, reads }), contentType: 'application/json' });
+ });
+ }
+}
+
test('source, folder and media filters operate on real backend records', async ({ page, request }) => {
await expect(await request.post('/__test__/reset', { data: { extra_images: 3 } })).toBeOK();
await page.goto(workspace);
@@ -336,6 +396,44 @@ test('real video media has a usable card and native playback cleans up on close
expect(await page.evaluate(() => window.__previewVideo.paused && !window.__previewVideo.isConnected)).toBe(true);
});
+test('collection viewer ignores delayed media callbacks and keeps inspection retries independent',async({page})=>{
+ await page.goto(workspace);await expect(cards(page)).toHaveCount(2);
+ await page.evaluate(()=>{const Native=window.Viewer;window.liveViewers=[];window.Viewer=class extends Native {constructor(...args){super(...args);window.liveViewers.push(this);}};});
+ let release,held;const waiting=new Promise(resolve=>{held=resolve;});const pending=new Promise(resolve=>{release=resolve;});
+ await page.route('**/images/serve/shared/same.png?*',async route=>{
+ const url=new URL(route.request().url());
+ if(url.searchParams.get('root')==='0'&&url.searchParams.has('_pm_viewer')) {held();await pending;}
+ await route.continue().catch(()=>{});
+ });
+ await cards(page).first().getByRole('button',{name:'Preview same.png',exact:true}).click();await waiting;
+ const preview=page.getByRole('dialog',{name:'Image preview',exact:true});
+ await expect(preview.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','true');
+ await page.route('**/images/inspect/shared/same.png?*',route=>route.fulfill({status:503,json:{success:false,error:'Unavailable'}}),{times:1});
+ await preview.getByRole('button',{name:'Next media',exact:true}).click();
+ await expect(preview).toContainText('Could not read prompts from this file.');await expect(preview.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
+ await expect(preview.getByRole('group',{name:'Media controls',exact:true}).getByRole('link',{name:'Download original',exact:true})).toHaveAttribute('href',/root=1&root_scope=/);
+ await preview.getByRole('button',{name:'Rotate image 90 degrees',exact:true}).click();
+ await page.evaluate(()=>{window.latestCanvas=document.querySelector('.viewer-canvas img');window.liveViewers[0].options.viewed();});release();
+ expect(await page.evaluate(()=>window.liveViewers.at(-1).imageData.rotate)).toBe(90);
+ await preview.getByRole('button',{name:'Retry prompt details',exact:true}).click();
+ await expect(preview).toContainText('same.png');await expect(preview.getByRole('button',{name:'Retry prompt details',exact:true})).toHaveCount(0);
+ expect(await page.evaluate(()=>window.latestCanvas===document.querySelector('.viewer-canvas img'))).toBe(true);
+ await page.evaluate(()=>{location.hash='/overview';});await expect(page.locator('.viewer-container,.image-preview')).toHaveCount(0);
+});
+
+test('collection viewer describes video transforms and releases native playback on navigation',async({page,request})=>{
+ await expect(await request.post('/__test__/reset',{data:{include_video:true}})).toBeOK();await page.goto(workspace);
+ await page.locator('article[data-image-key="0:clip.webm"] .image-card-footer button').click();
+ const preview=page.getByRole('dialog',{name:'Image preview',exact:true}),video=preview.locator('video');
+ await expect(video).toHaveJSProperty('controls',true);await expect(video).toHaveJSProperty('playsInline',true);
+ const zoom=preview.getByRole('button',{name:'Zoom in',exact:true});await expect(zoom).toHaveAttribute('aria-disabled','true');await expect(zoom).toHaveAccessibleDescription('Image transforms are unavailable for video.');
+ await video.focus();const position=await preview.locator('.image-preview-position').textContent();await page.keyboard.press('ArrowRight');await expect(preview.locator('.image-preview-position')).toHaveText(position);
+ await video.evaluate(async node=>{window.previousClip=node;node.muted=true;await node.play();});
+ await preview.getByRole('button',{name:'Next media',exact:true}).click();await expect(preview.locator('video')).toHaveCount(0);await expect(zoom).toHaveAttribute('aria-disabled','false');
+ expect(await page.evaluate(()=>window.previousClip.paused&&!window.previousClip.isConnected&&!window.previousClip.hasAttribute('src'))).toBe(true);
+ await preview.getByRole('button',{name:'Close preview',exact:true}).click();await expect(page.locator('.image-preview')).toHaveCount(0);
+});
+
test('failed image requests offer retry while stale results cannot replace a newer route', async ({ page }) => {
await page.route('**/prompt_manager/images/output?*', route => route.fulfill({
status: 503, json: { success: false, error: 'Images unavailable' },
@@ -380,6 +478,92 @@ test('source and folder discovery failure stays visible and Refresh restores usa
await expect(cards(page)).toHaveCount(1);
});
+async function staleSourceRecovery(page, request, roots) {
+ await expect(await request.post('/__test__/reset', { data: { extra_images: 80 } })).toBeOK();
+ await page.setViewportSize({ width: 1600, height: 1100 });
+ await page.goto(workspace);
+ const source = page.getByRole('combobox', { name: 'Source', exact: true });
+ const folder = page.getByRole('combobox', { name: 'Folder', exact: true });
+ await source.selectOption('0');await expect(folder.locator('option[value="bulk"]')).toHaveCount(1);await folder.selectOption('bulk');
+ const configured = page.waitForResponse(response => {
+ const url = new URL(response.url());return url.pathname.endsWith('/images/output') && url.searchParams.get('root') === '0' && url.searchParams.get('subfolder') === 'bulk' && url.searchParams.get('q') === 'png' && url.searchParams.get('media') === 'image' && url.searchParams.get('sort') === 'name';
+ });
+ await page.getByRole('searchbox', { name: 'Search filenames', exact: true }).fill('png');
+ await page.getByRole('combobox', { name: 'Media', exact: true }).selectOption('image');await page.getByRole('combobox', { name: 'Sort', exact: true }).selectOption('name');
+ const initial = await (await configured).json();await expect(page.locator('.image-toolbar')).toContainText('40 of 80');
+ let oldPage;
+ await page.route('**/prompt_manager/images/output?*', async route => {
+ if (new URL(route.request().url()).searchParams.has('cursor') && !oldPage) {
+ const response = await route.fetch();oldPage = { route, data: await response.json() };
+ } else await route.continue();
+ });
+ await page.getByRole('button', { name: 'Load more images', exact: true }).scrollIntoViewIfNeeded();await expect.poll(() => !!oldPage).toBe(true);
+ await expect(await request.post('/prompt_manager/settings', { data: { gallery_root_paths: roots } })).toBeOK();
+ const selected = initial.images.at(-1), key = `${selected.root_index}:${selected.path}`;
+ const stale = page.waitForResponse(response => response.url().includes('/images/inspect/') && response.status() === 409);
+ await page.locator(`article[data-image-key="${key}"] .image-open`).click();expect((await stale).status()).toBe(409);
+ await expect(details(page)).toContainText('The source changed. Refresh the image collection before inspecting this file.');
+ await page.locator(`article[data-image-key="${key}"] .image-card-footer button`).click();
+ const viewer = page.getByRole('dialog', { name: 'Image preview', exact: true });await expect(viewer).toContainText('The source changed.');
+ const pages = [], inspections = [], options = [], mutations = [];
+ page.on('request', request => {
+ const url = new URL(request.url());
+ if (url.pathname.endsWith('/images/output')) pages.push(url);
+ if (url.pathname.includes('/images/inspect/')) inspections.push(url);
+ if (request.method() !== 'GET') mutations.push(url.pathname);
+ });
+ await page.route('**/prompt_manager/gallery/subfolders?*', route => { options.push(route); });
+ await viewer.getByRole('button', { name: 'Refresh image collection', exact: true }).click();
+ await expect(viewer).toHaveCount(0);await expect.poll(() => options.length).toBe(1);
+ await oldPage.route.fulfill({ json: oldPage.data });
+ await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
+ return { source, folder, pages, inspections, options, mutations };
+}
+
+for (const [name, roots] of [['reordered', ['input', 'output']], ['removed', ['input']]]) {
+ test(`source-change recovery clears a ${name} root before discovery and safe retry`, async ({ page, request }, testInfo) => {
+ const recovery = await staleSourceRecovery(page, request, roots);
+ try {
+ expect(recovery.pages.map(url => url.search)).toEqual([]);expect(recovery.inspections).toEqual([]);
+ await expect(recovery.source).toHaveValue('all');await expect(recovery.source.locator('option')).toHaveText(['All sources']);await expect(recovery.folder).toHaveValue('');
+ for (const name of ['Source', 'Folder', 'Media', 'Sort']) await expect(page.getByRole('combobox', { name, exact: true })).toBeDisabled();
+ await expect(page.getByRole('searchbox', { name: 'Search filenames', exact: true })).toBeDisabled();
+ await expect(page.getByRole('button', { name: 'Load more images', exact: true })).toBeDisabled();
+ await page.getByRole('button', { name: 'Load more images', exact: true }).evaluate(node => node.click());await recovery.source.dispatchEvent('change');
+ await recovery.options[0].fulfill({ status: 503, json: { success: false, error: 'Discovery unavailable' } });
+ await expect(page.getByRole('alert').filter({ hasText: 'Sources and folders could not be loaded.' })).toBeVisible();
+ expect(recovery.pages).toEqual([]);expect(recovery.inspections).toEqual([]);await expect(cards(page)).toHaveCount(0);
+ const retry = details(page).getByRole('button', { name: 'Refresh image collection', exact: true });await expect(retry).toBeEnabled();
+ if (testInfo.project.name === 'chromium' && name === 'reordered') await page.screenshot({ path: testInfo.outputPath('safe-source-discovery-failure.png') });
+ await retry.click();await expect.poll(() => recovery.options.length).toBe(2);
+ expect(new URL(recovery.options[1].request().url()).searchParams.has('root')).toBe(false);
+ const response = await recovery.options[1].fetch();await recovery.options[1].fulfill({ response });
+ await expect.poll(() => recovery.pages.length).toBe(1);
+ const query = recovery.pages[0].searchParams;expect(query.has('root')).toBe(false);expect(query.has('subfolder')).toBe(false);
+ expect(query.get('q')).toBe('png');expect(query.get('media')).toBe('image');expect(query.get('sort')).toBe('name');
+ await expect(recovery.source).toBeEnabled();await expect(recovery.source).toHaveValue('all');
+ await expect(recovery.source.locator('option')).toHaveText(['All sources', ...roots]);
+ await expect.poll(() => recovery.inspections.length).toBe(1);expect(recovery.inspections[0].searchParams.get('root')).toBe(name === 'reordered' ? '1' : '0');
+ expect(recovery.mutations).toEqual([]);
+ if (testInfo.project.name === 'chromium' && name === 'reordered') await page.screenshot({ path: testInfo.outputPath('safe-source-recovery-success.png') });
+ } finally { await expect(await request.post('/prompt_manager/settings', { data: { gallery_root_paths: ['output', 'input'] } })).toBeOK(); }
+ });
+}
+
+test('source-change recovery ignores superseded discovery and route disposal', async ({ page, request }) => {
+ const recovery = await staleSourceRecovery(page, request, ['input', 'output']);
+ try {
+ expect(recovery.pages).toEqual([]);await details(page).getByRole('button', { name: 'Refresh image collection', exact: true }).click();await expect.poll(() => recovery.options.length).toBe(2);
+ await recovery.options[0].fulfill({ json: { roots: [{ index: 0, label: 'Obsolete source label' }], subfolders: ['obsolete'] } });
+ await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
+ expect(recovery.pages).toEqual([]);await expect(recovery.source.locator('option')).toHaveText(['All sources']);
+ await page.getByRole('navigation', { name: 'Workspace', exact: true }).getByRole('link', { name: 'Overview', exact: true }).click();
+ await recovery.options[1].fulfill({ json: { roots: [{ index: 0, label: 'Late source label' }], subfolders: ['late'] } });
+ await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
+ await expect(page.locator('#workspace-main h1')).toHaveText('Overview');expect(recovery.pages).toEqual([]);expect(recovery.inspections).toEqual([]);expect(recovery.mutations).toEqual([]);
+ } finally { await expect(await request.post('/prompt_manager/settings', { data: { gallery_root_paths: ['output', 'input'] } })).toBeOK(); }
+});
+
test('mobile Images keeps filters, layouts and selected details within the viewport', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(workspace);
@@ -518,9 +702,10 @@ test('Images uses approved geometry while keeping real controls', async ({page,r
expect(geometry[0].mediaWidth/geometry[0].mediaHeight).toBeCloseTo(4/3,2);
expect((await page.locator('.image-toolbar').boundingBox()).y).toBeLessThan(geometry[0].y);
await expect(cards(page).first()).toHaveCSS('outline-style','solid');
- const cover=details(page).locator(':scope > img');
+ const cover=details(page).getByRole('button',{name:'Open full preview',exact:true});
await expect(cover).toHaveCSS('height','150px');
- expect(await details(page).evaluate(node=>node.firstElementChild.tagName)).toBe('IMG');
+ await expect(cover.locator('img')).toBeVisible();await expect(cover.locator('img')).toHaveCSS('height','150px');
+ expect(await cover.evaluate(node=>node===node.parentElement.firstElementChild&&node.tagName==='BUTTON')).toBe(true);
await page.setViewportSize({width:390,height:844});
await expect.poll(()=>cards(page).nth(1).evaluate(node=>node.offsetTop)).toBe(0);
await expect.poll(()=>cards(page).nth(2).evaluate(node=>node.offsetTop)).toBeGreaterThan(0);
diff --git a/tests/browser/workspace-library.spec.mjs b/tests/browser/workspace-library.spec.mjs
index a6a530d..2b29e30 100644
--- a/tests/browser/workspace-library.spec.mjs
+++ b/tests/browser/workspace-library.spec.mjs
@@ -195,6 +195,8 @@ test('history shares real image metadata, vendored Viewer and the acknowledged p
await sender.getByRole('button',{name:'Close',exact:true}).click();
await media(page).first().getByRole('button',{name:'Open same.png',exact:true}).click();
const inspector=page.getByRole('region',{name:'Image details',exact:true});await expect(inspector.getByRole('region',{name:'Positive prompt',exact:true})).toContainText('Cat portrait');
+ await expect(inspector.locator('.generation-facts dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);
+ await expect(inspector.locator('summary')).toHaveText(['File & provenance','Workflow & raw metadata']);
await inspector.getByRole('button',{name:'Open full preview',exact:true}).click();await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible();
await page.keyboard.press('Escape');await expect(page.locator('dialog[open]')).toHaveCount(0);
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Overview',exact:true}).click();await expect(page.locator('.viewer-container')).toHaveCount(0);
diff --git a/tests/browser/workspace_inspector_fixture.py b/tests/browser/workspace_inspector_fixture.py
new file mode 100644
index 0000000..cfd8776
--- /dev/null
+++ b/tests/browser/workspace_inspector_fixture.py
@@ -0,0 +1,138 @@
+"""Owned, deterministic physical-file evidence for the full inspector workflows."""
+
+from __future__ import annotations
+
+import json
+import os
+from pathlib import Path
+import shutil
+from typing import TYPE_CHECKING
+
+from PIL import Image
+from PIL.PngImagePlugin import PngInfo
+
+from tests.browser.workspace_visual_fixture import seed_workspace_visual
+
+if TYPE_CHECKING:
+ from py.api import PromptManagerAPI
+
+
+INSPECTOR_POSITIVE = (
+ "Inspector portrait 猫 😀\n" + "Full prompt context, fine geometry and light. " * 40
+)
+INSPECTOR_NEGATIVE = "blurred edges, watermark"
+
+
+def _graph(positive: str, negative: str) -> dict:
+ return {
+ "1": {"class_type": "CLIPTextEncode", "inputs": {"text": positive}},
+ "2": {"class_type": "CLIPTextEncode", "inputs": {"text": negative}},
+ "3": {
+ "class_type": "KSampler",
+ "inputs": {
+ "positive": ["1", 0],
+ "negative": ["2", 0],
+ "model": ["4", 0],
+ "seed": 9007199254740993,
+ "steps": 28,
+ "cfg": 6.5,
+ "sampler_name": "dpmpp_2m",
+ "scheduler": "karras",
+ },
+ },
+ "4": {
+ "class_type": "CheckpointLoaderSimple",
+ "inputs": {"ckpt_name": "fixture-model.safetensors"},
+ },
+ }
+
+
+def _image(path: Path, index: int, metadata: dict) -> None:
+ info = PngInfo()
+ for key, value in metadata.items():
+ text = (
+ value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
+ )
+ info.add_itxt(key, text, zip=True)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ with Image.new("RGB", (720, 480), (30 + index * 9, 74, 98)) as image:
+ image.save(path, pnginfo=info)
+ stamp = 1_780_000_000 + index
+ os.utime(path, (stamp, stamp))
+
+
+def seed_workspace_inspector(
+ api: PromptManagerAPI,
+ root: Path,
+ *,
+ extra_images: int = 0,
+ include_video: bool = False,
+) -> None:
+ """Seed only disposable harness media and exact saved associations."""
+ seed_workspace_visual(api, root)
+ complete = _graph(INSPECTOR_POSITIVE, INSPECTOR_NEGATIVE)
+ workflow = {
+ "nodes": [
+ {
+ "id": 3,
+ "type": "KSampler",
+ "widgets_values": [
+ 9007199254740993,
+ "fixed",
+ 28,
+ 6.5,
+ "dpmpp_2m",
+ "karras",
+ 1,
+ ],
+ }
+ ],
+ "links": [],
+ }
+ ambiguous = {**complete, "5": complete["3"]}
+ examples = (
+ ("complete.png", {"prompt": complete, "workflow": workflow}),
+ ("empty.png", {}),
+ ("explicit-empty.png", {"prompt": _graph("Explicit empty negative", "")}),
+ ("ambiguous.png", {"prompt": ambiguous}),
+ ("workflow-only.png", {"workflow": workflow}),
+ ("malformed.png", {"prompt": "not-json", "workflow": "{broken"}),
+ ("control-heavy.png", {"parameters": "\x01" * 1_450_000}),
+ )
+ for index, (name, metadata) in enumerate(examples):
+ _image(root / "output" / "inspector" / name, index, metadata)
+ _image(
+ root / "input" / "inspector" / "complete.png",
+ 8,
+ {"prompt": _graph("Input collision", "")},
+ )
+ pair = api.db.save_prompt_pair(
+ INSPECTOR_POSITIVE,
+ INSPECTOR_NEGATIVE,
+ title="Inspector recipe",
+ category="Inspection",
+ tags=["fixture", "geometric"],
+ )
+ positive = api.db.get_prompt_pair(pair)["positive_prompt_id"]
+ image = api.db.link_image_to_prompt(
+ positive, str(root / "output" / "inspector" / "complete.png")
+ )
+ api.db.link_image_to_pair(pair, image, {"source": "fixture"})
+ api.db.link_image_to_prompt(
+ positive, str(root / "output" / "inspector" / "workflow-only.png")
+ )
+ for number in range(extra_images):
+ _image(
+ root / "output" / "inspector" / f"extra-{number:03}.png",
+ 9 + number % 12,
+ {"prompt": complete},
+ )
+ if include_video:
+ shutil.copyfile(
+ Path(__file__).resolve().parents[1] / "fixtures" / "tiny.webm",
+ root / "output" / "inspector" / "clip.webm",
+ )
+ os.utime(
+ root / "output" / "inspector" / "clip.webm", (1_780_000_100, 1_780_000_100)
+ )
+ api.invalidate_gallery_cache()
diff --git a/tests/comfyui/prompt-sender.spec.mjs b/tests/comfyui/prompt-sender.spec.mjs
index a8beeeb..b015a5a 100644
--- a/tests/comfyui/prompt-sender.spec.mjs
+++ b/tests/comfyui/prompt-sender.spec.mjs
@@ -7,11 +7,13 @@ installHostHooks(test,['PromptManagerPair']);
test('native image inspector previews and sends a pair into real visible widgets without loading or queueing',async({page,context,request},testInfo)=>{
const positive=`Inspector host positive ${testInfo.project.name}`;const negative='host negative, watermark';
const image=execFileSync(process.env.PM_TEST_PYTHON||'python',['-c',[
- 'import io,sys',
+ 'import io,json,sys',
'from PIL import Image',
'from PIL.PngImagePlugin import PngInfo',
'info=PngInfo()',
- 'info.add_text("parameters",sys.argv[1]+"\\nNegative prompt: "+sys.argv[2]+"\\nSteps: 20, Sampler: Euler")',
+ 'graph={"1":{"class_type":"CLIPTextEncode","inputs":{"text":sys.argv[1]}},"2":{"class_type":"CLIPTextEncode","inputs":{"text":sys.argv[2]}},"3":{"class_type":"KSampler","inputs":{"positive":["1",0],"negative":["2",0],"model":["4",0],"seed":9007199254740993,"steps":28,"cfg":6.5,"sampler_name":"dpmpp_2m","scheduler":"karras"}},"4":{"class_type":"CheckpointLoaderSimple","inputs":{"ckpt_name":"host-fixture.safetensors"}}}',
+ 'info.add_text("prompt",json.dumps(graph))',
+ 'info.add_text("workflow",json.dumps({"nodes":[],"links":[]}))',
'buffer=io.BytesIO()',
'Image.new("RGB",(64,64),(30,80,140)).save(buffer,format="PNG",pnginfo=info)',
'sys.stdout.buffer.write(buffer.getvalue())',
@@ -44,7 +46,23 @@ test('native image inspector previews and sends a pair into real visible widgets
await card.getByRole('button',{name:`Open ${filename}`,exact:true}).click();
const inspector=workspace.getByRole('region',{name:'Image details',exact:true});
await expect(inspector.getByRole('region',{name:'Positive prompt',exact:true})).toContainText(positive);
- await inspector.getByRole('button',{name:'Send prompt pair',exact:true}).click();
+ await inspector.getByRole('button',{name:'Open full preview',exact:true}).click();
+ const viewer=workspace.getByRole('dialog',{name:'Image preview',exact:true});
+ await expect(viewer.locator('.viewer-canvas img')).toBeVisible();
+ await expect(viewer.locator('.generation-facts dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','host-fixture.safetensors']);
+ await viewer.getByText('Workflow & raw metadata',{exact:true}).click();
+ await expect(viewer.locator('pre code').first()).toContainText('9007199254740993');
+ await expect(viewer.getByRole('button',{name:'Download workflow JSON',exact:true})).not.toHaveAttribute('aria-disabled','true');
+ expect(await page.evaluate(()=>window.senderEffects)).toEqual({queue:0,load:0});
+ for(const renderer of [!testInfo.project.metadata.initialRenderer,testInfo.project.metadata.initialRenderer]) {
+ await page.evaluate(async renderer=>{const {app}=await import('/scripts/app.js');await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled',renderer);await app.loadGraphData(app.graph.serialize());app.canvas.centerOnNode(app.graph._nodes[0]);},renderer);
+ await expect.poll(()=>page.evaluate(()=>LiteGraph.vueNodesMode)).toBe(renderer);
+ await expect(viewer).toBeVisible();await expect(viewer.locator('.generation-facts dd').first()).toHaveText('9007199254740993');
+ await expect(nodeRoot.getByRole('textbox',{name:'Positive prompt',exact:true})).toHaveValue('Before positive');
+ }
+ expect(await page.evaluate(()=>window.senderEffects.queue)).toBe(0);
+ await page.evaluate(()=>{window.senderSendBaseline={...window.senderEffects};});
+ await viewer.getByRole('button',{name:'Send prompt pair',exact:true}).click();
const sender=workspace.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
const hosts=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});
await expect(hosts.locator('option')).toHaveCount(2);
@@ -60,9 +78,11 @@ test('native image inspector previews and sends a pair into real visible widgets
const state=await page.evaluate(async id=>{
const {app}=await import('/scripts/app.js');const node=app.graph.getNodeById(id);const serialized=node.serialize();
return{inputs:(await app.graphToPrompt()).output[String(id)].inputs,
- serialized:Object.fromEntries(node.widgets.map((widget,index)=>[widget.name,serialized.widgets_values[index]])),effects:window.senderEffects};
+ serialized:Object.fromEntries(node.widgets.map((widget,index)=>[widget.name,serialized.widgets_values[index]])),
+ effects:{queue:window.senderEffects.queue-window.senderSendBaseline.queue,load:window.senderEffects.load-window.senderSendBaseline.load}};
},id);
for(const name of ['inputs','serialized'])expect(state[name]).toMatchObject({text:positive,negative_text:negative,prepend_text:'LoRA trigger',append_text:'fine detail'});
expect(state.effects).toEqual({queue:0,load:0});expect(errors).toEqual([]);
+ await workspace.screenshot({path:testInfo.outputPath('full-inspector-host-send.png')});
} finally {await workspace.close();}
});
diff --git a/tests/js/api-client.test.js b/tests/js/api-client.test.js
index 9a6a362..5595111 100644
--- a/tests/js/api-client.test.js
+++ b/tests/js/api-client.test.js
@@ -113,6 +113,31 @@ test("a 2xx body with success:false throws ApiError", async () => {
await assert.rejects(pending, (err) => err instanceof ApiError && err.status === 200 && err.message === "Nope");
});
+test("ApiError preserves only a bounded machine code without changing status or message", async () => {
+ assert.equal(new ApiError(409, "unchanged").code, null);
+ for (const [code, expected] of [
+ ["source-changed", "source-changed"], ["a".repeat(64), "a".repeat(64)],
+ [undefined, null], [null, null], [4, null], [{ code: "x" }, null],
+ ["", null], ["Source-changed", null], ["source_changed", null],
+ ["source-changed\n", null], ["a".repeat(65), null],
+ ]) {
+ for (const status of [409, 200]) {
+ const api = createApiClient(async () => jsonResponse(status, {
+ success: false, error: "unchanged", code, private: "never expose",
+ }));
+ await assert.rejects(api.get("/x"), error => {
+ assert.ok(error instanceof ApiError);
+ assert.equal(error.status, status);
+ assert.equal(error.message, "unchanged");
+ assert.equal(error.code, expected);
+ assert.equal(error.private, undefined);
+ assert.equal(error.body, undefined);
+ return true;
+ });
+ }
+ }
+});
+
test("a 2xx body that is not JSON throws ApiError", async () => {
const fetch = fakeFetch();
const api = createApiClient(fetch);
diff --git a/tests/js/comfy-metadata.test.js b/tests/js/comfy-metadata.test.js
index 12f23c0..451d6b2 100644
--- a/tests/js/comfy-metadata.test.js
+++ b/tests/js/comfy-metadata.test.js
@@ -1,7 +1,391 @@
// Run with: node --test tests/js/
const test = require("node:test");
const assert = require("node:assert/strict");
-const { extractGenerationParams } = require("../../web/js/comfy-metadata.js");
+const { extractGenerationParams, extractGenerationFacts } = require("../../web/js/comfy-metadata.js");
+
+const parsedFactGraph = value => ({ status: "parsed", value });
+const absentFactGraph = () => ({ status: "absent", value: null });
+const factFields = ["seed", "steps", "cfg", "sampler", "scheduler", "model"];
+function factsFor(graph, extra = {}) {
+ assert.equal(typeof extractGenerationFacts, "function", "the conservative facts contract is exported");
+ return extractGenerationFacts({ prompt_graph: parsedFactGraph(graph), workflow_graph: absentFactGraph(), metadataStatus: "available", ...extra });
+}
+function factGraph(inputs = {}) {
+ return {
+ s: { class_type: "KSampler", inputs: { seed: "9007199254740993", steps: 28, cfg: "6.5000000000000000001", sampler_name: "dpmpp_2m", scheduler: "karras", model: ["model", 0], ...inputs } },
+ model: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "model.safetensors" } },
+ };
+}
+
+test("facts keep unsafe seed and decimal strings exact", () => {
+ const facts = factsFor(factGraph());
+ assert.deepEqual(Object.keys(facts), factFields);
+ assert.deepEqual(factFields.map(field => facts[field].value), ["9007199254740993", "28", "6.5000000000000000001", "dpmpp_2m", "karras", "model.safetensors"]);
+ for (const fact of Object.values(facts)) assert.deepEqual(fact, {
+ status: "known", value: fact.value, source: "api-graph", evidence: "configuration", reason: null, sampler_id: "s",
+ });
+ const zero = factsFor(factGraph({ seed: 0, cfg: "0", steps: "1" }));
+ assert.equal(zero.seed.value, "0"); assert.equal(zero.cfg.value, "0");
+ for (const inputs of [{ seed: 9007199254740992 }, { seed: "1.5" }, { steps: "0" }, { cfg: "NaN" }, { cfg: {} }, { cfg: true }]) {
+ const key = Object.keys(inputs)[0];
+ assert.equal(factsFor(factGraph(inputs))[key].status, "unresolved");
+ }
+});
+
+test("facts do not choose equal sampler branches or unrelated model", () => {
+ const graph = factGraph(); graph.other = structuredClone(graph.s);
+ const ambiguous = factsFor(graph);
+ assert.ok(Object.values(ambiguous).every(fact => fact.status === "ambiguous" && fact.value === null));
+ const selected = factsFor(graph, { resolution: { state: "resolved", sampler_id: "other" } });
+ assert.equal(selected.seed.sampler_id, "other");
+ const unrelated = factsFor(factGraph({ model: ["missing", 0] }));
+ assert.equal(unrelated.model.status, "unresolved");
+ assert.equal(unrelated.model.value, null);
+ assert.equal(factsFor({ only: graph.model }).seed.status, "unresolved");
+});
+
+test("facts respect named scalar outputs and custom scheduler paths", () => {
+ const graph = factGraph({ seed: ["primitive", 0], steps: ["steps", 0], cfg: ["cfg", 0], sampler_name: ["sampler", 0], scheduler: ["scheduler", 0], model: ["lora", 0] });
+ graph.primitive = { class_type: "PrimitiveInt", inputs: { value: "0" } };
+ graph.steps = { class_type: "PrimitiveInt", inputs: { value: "50" } };
+ graph.cfg = { class_type: "PrimitiveFloat", inputs: { value: "0" } };
+ graph.sampler = { class_type: "PrimitiveString", inputs: { value: "euler" } };
+ graph.scheduler = { class_type: "PrimitiveStringMultiline", inputs: { value: "normal" } };
+ graph.lora = { class_type: "LoraLoader", inputs: { lora_name: "style", model: ["model", 0] } };
+ assert.deepEqual(factFields.map(field => factsFor(graph)[field].value), ["0", "50", "0", "euler", "normal", "model.safetensors"]);
+ graph.settings = { class_type: "UnknownSettings", inputs: { first: 20, second: 30 } };
+ graph.s.inputs.steps = ["settings", 0];
+ assert.equal(factsFor(graph).steps.status, "unresolved");
+ const custom = {
+ s: { class_type: "SamplerCustomAdvanced", inputs: { guider: ["g", 0], noise: ["n", 0], sampler: ["k", 0], sigmas: ["sigmas", 0] } },
+ g: { class_type: "CFGGuider", inputs: { model: ["m", 0], cfg: "4.0000000000000001" } },
+ n: { class_type: "RandomNoise", inputs: { noise_seed: "9007199254740993" } },
+ k: { class_type: "KSamplerSelect", inputs: { sampler_name: "euler" } },
+ sigmas: { class_type: "BasicScheduler", inputs: { steps: 22, scheduler: "karras" } },
+ m: { class_type: "UNETLoader", inputs: { unet_name: "flux.safetensors" } },
+ };
+ assert.deepEqual(factFields.map(field => factsFor(custom)[field].value), ["9007199254740993", "22", "4.0000000000000001", "euler", "karras", "flux.safetensors"]);
+ custom.s = { class_type: "SamplerCustom", inputs: { model: ["m", 0], cfg: 0, noise_seed: 0, sampler: ["k", 0], sigmas: ["sigmas", 0] } };
+ assert.deepEqual([factsFor(custom).seed.value, factsFor(custom).steps.value, factsFor(custom).cfg.value], ["0", "22", "0"]);
+ custom.sigmas.inputs = { steps: 22 };
+ assert.equal(factsFor(custom).scheduler.status, "not-recorded");
+ assert.equal(factsFor(custom).sampler.value, "euler");
+});
+
+test("facts require supported scalar producer types and output slots", () => {
+ const cases = [
+ ["PrimitiveInt", "seed", "42", true], ["PrimitiveFloat", "cfg", "6.5000000000000000001", true],
+ ["PrimitiveString", "sampler_name", "euler", true], ["PrimitiveStringMultiline", "scheduler", "normal", true],
+ ["PrimitiveString", "seed", "42", false], ["PrimitiveStringMultiline", "steps", "42", false],
+ ["PrimitiveInt", "sampler_name", "euler", false], ["PrimitiveFloat", "scheduler", "normal", false],
+ ["PrimitiveBoolean", "seed", "42", false], ["PrimitiveNode", "seed", "42", false],
+ ["UnknownSettings", "seed", "42", false], ["SamplerCombo", "steps", "42", false],
+ ];
+ for (const [class_type, input, value, supported] of cases) {
+ for (const slot of [0, 1, 999, -1, 0.5]) {
+ const graph = factGraph({ [input]: ["settings", slot] });
+ graph.settings = { class_type, inputs: { value, [input]: value } };
+ const fact = factsFor(graph)[input === "sampler_name" ? "sampler" : input];
+ assert.equal(fact.status, supported && slot === 0 ? "known" : "unresolved", `${class_type} output${slot} as ${input}`);
+ assert.equal(fact.value, supported && slot === 0 ? value : null);
+ }
+ }
+ const graph = factGraph({ seed: ["primitive", 0] });
+ graph.primitive = { class_type: "PrimitiveInt", inputs: { seed: "987" } };
+ assert.equal(factsFor(graph).seed.status, "unresolved", "a matching input is not the output's value");
+ graph.primitive.inputs = { value: "42", seed: "987" };
+ assert.equal(factsFor(graph).seed.value, "42");
+ for (const [input, class_type, inputs] of [
+ ["seed", "RandomNoise", { noise_seed: "42", seed: "42" }],
+ ["sampler_name", "KSamplerSelect", { sampler_name: "euler" }],
+ ["steps", "BasicScheduler", { steps: "42" }],
+ ["cfg", "CFGGuider", { cfg: "7" }],
+ ]) {
+ const graph = factGraph({ [input]: ["provider", 0] }); graph.provider = { class_type, inputs };
+ assert.equal(factsFor(graph)[input === "sampler_name" ? "sampler" : input].status, "unresolved", `${class_type} is not a generic scalar output`);
+ }
+});
+
+function customFactGraph() {
+ return {
+ s: { class_type: "SamplerCustomAdvanced", inputs: { guider: ["g", 0], noise: ["n", 0], sampler: ["k", 0], sigmas: ["sigmas", 0] } },
+ g: { class_type: "CFGGuider", inputs: { model: ["m", 0], cfg: ["cfg", 0] } },
+ n: { class_type: "RandomNoise", inputs: { noise_seed: ["seed", 0] } },
+ k: { class_type: "KSamplerSelect", inputs: { sampler_name: ["sampler", 0] } },
+ sigmas: { class_type: "BasicScheduler", inputs: { steps: ["steps", 0], scheduler: ["scheduler", 0] } },
+ seed: { class_type: "PrimitiveInt", inputs: { value: "9007199254740993" } },
+ cfg: { class_type: "PrimitiveFloat", inputs: { value: "4.0000000000000001" } },
+ steps: { class_type: "PrimitiveInt", inputs: { value: 22 } },
+ sampler: { class_type: "PrimitiveString", inputs: { value: "euler" } },
+ scheduler: { class_type: "PrimitiveString", inputs: { value: "karras" } },
+ m: { class_type: "UNETLoader", inputs: { unet_name: "flux.safetensors" } },
+ };
+}
+
+test("facts require supported custom providers and guider outputs", () => {
+ assert.deepEqual(factFields.map(field => factsFor(customFactGraph())[field].value), ["9007199254740993", "22", "4.0000000000000001", "euler", "karras", "flux.safetensors"]);
+ for (const [input, nodeId, fields] of [["noise", "n", ["seed"]], ["sampler", "k", ["sampler"]], ["sigmas", "sigmas", ["steps", "scheduler"]], ["guider", "g", ["cfg", "model"]]]) {
+ for (const slot of [1, 999, -1, 0.5]) {
+ const graph = customFactGraph(); graph.s.inputs[input] = [nodeId, slot];
+ for (const field of fields) assert.equal(factsFor(graph)[field].status, "unresolved", `${input} output${slot} ${field}`);
+ }
+ for (const class_type of ["UnknownProvider", "PrimitiveInt", "PrimitiveString", "BasicGuider"]) {
+ if (input === "guider" && class_type === "BasicGuider") continue;
+ const graph = customFactGraph(); graph[nodeId].class_type = class_type;
+ for (const field of fields) assert.equal(factsFor(graph)[field].status, "unresolved", `${class_type} cannot provide ${input}`);
+ }
+ }
+ const basic = customFactGraph(); basic.g = { class_type: "BasicGuider", inputs: { model: ["m", 0] } };
+ assert.equal(factsFor(basic).model.value, "flux.safetensors");
+ assert.equal(factsFor(basic).cfg.status, "not-recorded");
+});
+
+test("facts require model output ancestry with supported loaders and passthroughs", () => {
+ for (const [class_type, key] of [["CheckpointLoader", "ckpt_name"], ["CheckpointLoaderSimple", "ckpt_name"], ["unCLIPCheckpointLoader", "ckpt_name"], ["UNETLoader", "unet_name"]]) {
+ for (const slot of [0, 1, 2, 999, -1, 0.5]) {
+ const graph = factGraph({ model: ["loader", slot] });
+ graph.loader = { class_type, inputs: { [key]: "selected.safetensors" } };
+ assert.equal(factsFor(graph).model.value, slot === 0 ? "selected.safetensors" : null, `${class_type} output${slot}`);
+ assert.equal(factsFor(graph).model.status, slot === 0 ? "known" : "unresolved");
+ }
+ }
+ for (const class_type of ["LoraLoader", "LoraLoaderModelOnly", "UnknownModelPassThrough"]) {
+ for (const slot of [0, 1, 2, 999]) {
+ const graph = factGraph({ model: ["lora", slot] }); graph.lora = { class_type, inputs: { model: ["model", 0] } };
+ assert.equal(factsFor(graph).model.status, class_type !== "UnknownModelPassThrough" && slot === 0 ? "known" : "unresolved", `${class_type} output${slot}`);
+ }
+ }
+ for (const [class_type, inputs] of [["UnknownLoader", { ckpt_name: "fake" }], ["PrimitiveString", { model_name: "fake", value: "fake" }], ["__proto__", { "[object Object]": "fake" }]]) {
+ const graph = factGraph({ model: ["unknown", 0] }); graph.unknown = { class_type, inputs };
+ assert.equal(factsFor(graph).model.status, "unresolved");
+ }
+ for (const [input, field] of [["sampler_name", "sampler"], ["scheduler", "scheduler"]]) assert.equal(factsFor(factGraph({ [input]: 42 }))[field].status, "unresolved");
+ const numericModel = factGraph(); numericModel.model.inputs.ckpt_name = 42;
+ assert.equal(factsFor(numericModel).model.status, "unresolved", "model names require string evidence");
+});
+
+test("wide workflow lookups retain selected core sampler facts", () => {
+ const nodes = [{ id: "text", type: "PrimitiveString", widgets_values_named: { value: "unrelated text" } }], links = [];
+ let linkId = 1;
+ for (let index = 0; index < 20; index++) {
+ nodes.push({ id: `r${index}`, type: "Reroute", inputs: [{ name: "value", link: linkId }] });
+ links.push([linkId++, index ? `r${index - 1}` : "text", 0, `r${index}`, 0, "STRING"]);
+ }
+ for (let index = 0; index < 40; index++) {
+ nodes.push({ id: `encoder${index}`, type: "CLIPTextEncode", inputs: [{ name: "text", link: linkId }] });
+ links.push([linkId++, "r19", 0, `encoder${index}`, 0, "STRING"]);
+ }
+ nodes.push({ id: "model", type: "CheckpointLoaderSimple", widgets_values_named: { ckpt_name: "selected.safetensors" } },
+ { id: "steps", type: "PrimitiveInt", widgets_values_named: { value: 28 } },
+ { id: "s", type: "KSampler", widgets_values: ["9007199254740993", "fixed", 28, "6.5000000000000000001", "euler", "normal"], inputs: [{ name: "model", link: linkId }, { name: "steps", link: linkId + 1 }] });
+ links.push([linkId, "model", 0, "s", 0, "MODEL"], [linkId + 1, "steps", 0, "s", 1, "INT"]);
+ const facts = factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph({ nodes, links }) });
+ assert.deepEqual(factFields.map(field => facts[field].value), ["9007199254740993", "28", "6.5000000000000000001", "euler", "normal", "selected.safetensors"]);
+});
+
+test("workflow facts require supported passthrough output slots and bypass ancestry", () => {
+ function workflow(type, slot, mode) {
+ const pass = { id: "pass", type, ...(mode === undefined ? {} : { mode }), widgets_values: ["selected"], inputs: [{ name: type === "Reroute" ? "value" : "model", type: "MODEL", link: 1 }] };
+ const nodes = [{ id: "m", type: "CheckpointLoaderSimple", widgets_values_named: { ckpt_name: "selected.safetensors" } }, pass,
+ { id: "s", type: "KSampler", widgets_values: [0, "fixed", 28, "0", "euler", "normal"], inputs: [{ name: "model", link: 2 }] }];
+ if (type === "GetNode") {
+ nodes.push({ id: "set", type: "SetNode", widgets_values: ["selected"], inputs: [{ name: "model", link: 1 }] });
+ pass.inputs = [];
+ }
+ return { nodes, links: [[1, "m", 0, "pass", 0, "MODEL"], [2, "pass", slot, "s", 0, "MODEL"]] };
+ }
+ const model = value => factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(value) }).model;
+ for (const [type, mode] of [["Reroute", undefined], ["GetNode", undefined], ["SetNode", undefined], ["LoraLoader", 4], ["LoraLoaderModelOnly", 4]]) {
+ for (const slot of [0, 1, 999, -1, 0.5]) {
+ assert.equal(model(workflow(type, slot, mode)).status, slot === 0 ? "known" : "unresolved", `${type} output${slot}`);
+ }
+ }
+ assert.equal(model(workflow("UnknownBypass", 0, 4)).status, "unresolved");
+ const duplicate = workflow("LoraLoader", 0, 4);
+ duplicate.nodes[1].inputs.push({ name: "model", type: "MODEL", link: 1 });
+ assert.equal(model(duplicate).status, "unresolved", "bypass model input is uniquely named");
+ const wrongOrigin = workflow("Reroute", 0); wrongOrigin.links[0][2] = 1;
+ assert.equal(model(wrongOrigin).status, "unresolved", "passthrough cannot turn CLIP into MODEL");
+ const cycle = workflow("Reroute", 0); cycle.links[0][1] = "pass";
+ assert.equal(model(cycle).status, "unresolved");
+ const unnamed = workflow("GetNode", 0);
+ delete unnamed.nodes.find(node => node.id === "pass").widgets_values;
+ delete unnamed.nodes.find(node => node.id === "set").widgets_values;
+ assert.equal(model(unnamed).status, "unresolved", "missing Get/Set names are not a source association");
+ const scalar = factGraph({ seed: ["bypass", 0] });
+ scalar.bypass = { class_type: "LoraLoader", inputs: { model: ["primitive", 0] } };
+ scalar.primitive = { class_type: "PrimitiveInt", inputs: { value: 42 } };
+ const scalarWorkflow = factWorkflowFromGraph(scalar); scalarWorkflow.nodes.find(node => node.id === "bypass").mode = 4;
+ assert.equal(factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(scalarWorkflow) }).seed.status, "unresolved", "bypassed MODEL output cannot become a scalar");
+ for (const [input, field] of [["noise", "seed"], ["sampler", "sampler"], ["sigmas", "steps"], ["guider", "model"]]) {
+ const graph = customFactGraph(), target = graph.s.inputs[input];
+ graph.s.inputs[input] = ["bypass", 0]; graph.bypass = { class_type: "LoraLoaderModelOnly", inputs: { model: target } };
+ const workflow = factWorkflowFromGraph(graph); workflow.nodes.find(node => node.id === "bypass").mode = 4;
+ assert.equal(factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(workflow) })[field].status, "unresolved", `MODEL output cannot become ${input}`);
+ }
+});
+
+function factWorkflowFromGraph(graph) {
+ const nodes = [], links = [];
+ for (const [id, node] of Object.entries(graph)) {
+ const inputs = [], widgets_values_named = {};
+ for (const [name, value] of Object.entries(node.inputs)) {
+ if (Array.isArray(value)) {
+ const link = links.length + 1;
+ links.push([link, value[0], value[1], id, inputs.length, "*"]);
+ inputs.push({ name, link });
+ } else widgets_values_named[name] = value;
+ }
+ nodes.push({ id, type: node.class_type, inputs, widgets_values_named });
+ }
+ return { nodes, links };
+}
+
+test("workflow facts count combined original ancestry once across passthroughs and providers", () => {
+ const inspect = graph => factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(factWorkflowFromGraph(graph)) });
+ function chain(graph, reroutes, primitives, model = false) {
+ let link = model ? ["model", 0] : null;
+ for (let index = 0; index < primitives; index++) {
+ graph[`p${index}`] = { class_type: model ? "LoraLoader" : "PrimitiveInt", inputs: model ? { model: link } : { value: link || "42" } };
+ link = [`p${index}`, 0];
+ }
+ for (let index = 0; index < reroutes; index++) {
+ graph[`r${index}`] = { class_type: "Reroute", inputs: { value: link } };
+ link = [`r${index}`, 0];
+ }
+ return link;
+ }
+ for (const [reroutes, known] of [[32, true], [33, false]]) {
+ const scalar = factGraph(); scalar.s.inputs.seed = chain(scalar, reroutes, 32);
+ assert.equal(inspect(scalar).seed.status, known ? "known" : "unresolved", `${reroutes}+32 scalar links`);
+ const model = factGraph(); model.s.inputs.model = chain(model, reroutes, 31, true);
+ assert.equal(inspect(model).model.status, known ? "known" : "unresolved", `${reroutes}+31 LoRA+loader links`);
+ const noise = customFactGraph();
+ noise.n.inputs.noise_seed = chain(noise, reroutes - 1, 31);
+ noise.noiseAlias = { class_type: "Reroute", inputs: { value: ["n", 0] } };
+ noise.s.inputs.noise = ["noiseAlias", 0];
+ assert.equal(inspect(noise).seed.status, known ? "known" : "unresolved", `noise alias+provider+${reroutes - 1}+31 links`);
+ const guider = customFactGraph();
+ guider.model = { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "model.safetensors" } };
+ guider.g.inputs.model = chain(guider, reroutes - 1, 30, true);
+ guider.guiderAlias = { class_type: "Reroute", inputs: { value: ["g", 0] } };
+ guider.s.inputs.guider = ["guiderAlias", 0];
+ assert.equal(inspect(guider).model.status, known ? "known" : "unresolved", `guider alias+provider+${reroutes - 1}+30 LoRA+loader links`);
+ }
+ const cycle = factGraph();
+ cycle.s.inputs.seed = ["r", 0]; cycle.r = { class_type: "Reroute", inputs: { value: ["p", 0] } }; cycle.p = { class_type: "PrimitiveInt", inputs: { value: ["r", 0] } };
+ assert.equal(inspect(cycle).seed.status, "unresolved");
+ cycle.s.inputs.model = ["r", 0]; cycle.p = { class_type: "LoraLoader", inputs: { model: ["r", 0] } };
+ assert.equal(inspect(cycle).model.status, "unresolved");
+ for (const [reroutes, known] of [[32, true], [33, false]]) {
+ const graph = factGraph(), value = chain(graph, reroutes, 31);
+ const workflow = factWorkflowFromGraph(graph);
+ const sourceLink = workflow.links.length + 1;
+ workflow.nodes.push({ id: "set", type: "SetNode", widgets_values: ["seed"], inputs: [{ name: "value", link: sourceLink }] }, { id: "get", type: "GetNode", widgets_values: ["seed"], inputs: [] });
+ workflow.links.push([sourceLink, value[0], 0, "set", 0, "INT"], [sourceLink + 1, "get", 0, "s", 0, "INT"]);
+ workflow.nodes.find(node => node.id === "s").inputs.push({ name: "seed", link: sourceLink + 1 });
+ const fact = factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(workflow) }).seed;
+ assert.equal(fact.status, known ? "known" : "unresolved", `Get+${reroutes}+31 links, no extra setter expansion`);
+ }
+});
+
+test("workflow sampler selection retains prototype-named node identities", () => {
+ const workflow = { nodes: [
+ { id: "__proto__", type: "KSampler", widgets_values: [1, "fixed", 20, "7", "euler", "normal"] },
+ { id: "other", type: "KSampler", widgets_values: [2, "fixed", 20, "7", "euler", "normal"] },
+ ], links: [] };
+ assert.ok(Object.values(factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(workflow) })).every(fact => fact.status === "ambiguous"));
+});
+
+test("workflow-only facts are configuration without creating sendable roles", () => {
+ const workflow = { nodes: [
+ { id: 1, type: "UNETLoader", widgets_values: ["flux.safetensors", "default"] },
+ { id: 2, type: "SetNode", widgets_values: ["model"], inputs: [{ name: "MODEL", link: 1 }] },
+ { id: 3, type: "GetNode", widgets_values: ["model"] },
+ { id: 4, type: "KSamplerAdvanced", widgets_values: ["enable", "9007199254740993", "fixed", 28, "6.5000000000000000001", "euler", "normal", 0, 28, "disable"], inputs: [{ name: "model", link: 2 }] },
+ ], links: [[1, 1, 0, 2, 0, "MODEL"], [2, 3, 0, 4, 0, "MODEL"]] };
+ const facts = factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(workflow) });
+ assert.deepEqual(factFields.map(field => facts[field].value), ["9007199254740993", "28", "6.5000000000000000001", "euler", "normal", "flux.safetensors"]);
+ assert.ok(Object.values(facts).every(fact => fact.source === "workflow" && fact.evidence === "configuration"));
+ assert.equal(facts.positive, undefined);
+ workflow.nodes.push({ ...workflow.nodes[3] });
+ assert.ok(Object.values(factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(workflow) })).every(fact => fact.status === "unresolved"));
+});
+
+test("limited projection stays unresolved", () => {
+ for (const extra of [
+ { prompt_graph: { status: "limited", value: null } },
+ { prompt_graph: absentFactGraph(), workflow_graph: { status: "limited", value: null } },
+ { prompt_graph: absentFactGraph(), metadataStatus: "limited", parameters: null },
+ ]) {
+ const facts = factsFor(null, extra);
+ assert.ok(Object.values(facts).every(fact => fact.status === "unresolved" && fact.value === null && fact.reason === "projection-limited"));
+ }
+ const unreadable = factsFor(null, { prompt_graph: absentFactGraph(), metadataStatus: "unreadable" });
+ assert.equal(unreadable.seed.reason, "metadata-unreadable");
+ const absent = factsFor(null, { prompt_graph: absentFactGraph(), metadataStatus: "absent" });
+ assert.equal(absent.seed.status, "not-recorded");
+});
+
+test("facts preserve exact video-projected decimals without reading reserialized raw", () => {
+ const graph = factGraph();
+ const facts = factsFor(graph, { recordFormats: { prompt: "reserialized-json" }, raw: { prompt: '{"s":{"inputs":{"seed":9007199254740992,"cfg":6.5}}}' } });
+ assert.equal(facts.seed.value, "9007199254740993");
+ assert.equal(facts.cfg.value, "6.5000000000000000001");
+ assert.equal(factsFor(factGraph({ cfg: "1E+1000000000" })).cfg.value, "1E+1000000000");
+});
+
+test("nontext parameters remain unresolved", () => {
+ const parameters = "A portrait\nNegative prompt: blur\nSteps: 28, Sampler: Euler, Schedule type: Karras, CFG scale: 6.5000000000000000001, Seed: 9007199254740993, Model: model";
+ const base = { prompt_graph: absentFactGraph(), parameters };
+ for (const format of [undefined, "reserialized-json", "invalid"]) {
+ const facts = factsFor(null, { ...base, recordFormats: format ? { parameters: format } : {} });
+ assert.ok(Object.values(facts).every(fact => fact.status === "unresolved" && fact.value === null));
+ }
+ const facts = factsFor(null, { ...base, recordFormats: { parameters: "original-text" } });
+ assert.deepEqual(factFields.map(field => facts[field].value), ["9007199254740993", "28", "6.5000000000000000001", "Euler", "Karras", "model"]);
+ assert.ok(Object.values(facts).every(fact => fact.source === "embedded-parameters" && fact.evidence === "embedded-parameters"));
+ for (const invalid of [parameters + "\nSteps: 5, Seed: 2", parameters + ", Seed: 1", parameters + "\ntrailing", parameters.replace("Seed: 9007199254740993", "Seed: -1"), parameters.replace("Karras", "invented"), parameters.replace("Negative prompt: blur", "Negative prompt: blur\nNegative prompt: again")]) {
+ assert.ok(Object.values(factsFor(null, { ...base, parameters: invalid, recordFormats: { parameters: "original-text" } })).some(fact => fact.status === "unresolved"));
+ }
+});
+
+test("malformed or unsupported API evidence blocks plausible workflow and parameter fallback", () => {
+ const workflow = { nodes: [{ id: 1, type: "KSampler", widgets_values: [42, "fixed", 20, 7, "euler", "normal"] }], links: [] };
+ const extra = { workflow_graph: parsedFactGraph(workflow), parameters: "portrait\nSteps: 20, Sampler: Euler, CFG scale: 7, Seed: 42", recordFormats: { parameters: "original-text" } };
+ for (const prompt_graph of [{ status: "malformed", value: null }, parsedFactGraph({ s: { class_type: "UnknownSampler", inputs: { seed: 99 } } }), parsedFactGraph({ s: { class_type: "KSampler", inputs: [] } })]) {
+ const facts = factsFor(null, { ...extra, prompt_graph });
+ assert.ok(Object.values(facts).every(fact => fact.status === "unresolved" && fact.value === null));
+ assert.ok(Object.values(facts).every(fact => fact.source !== "workflow" && fact.source !== "embedded-parameters"));
+ }
+});
+
+test("conservative facts bound cycles fan-out visits and link depth", () => {
+ const graph = factGraph({ seed: ["loop", 0], model: ["loop", 0] });
+ graph.loop = { class_type: "Unknown", inputs: { seed: ["loop", 0], model: ["loop", 0], a: ["loop", 0], b: ["loop", 0] } };
+ assert.equal(factsFor(graph).seed.status, "unresolved");
+ assert.equal(factsFor(graph).model.status, "unresolved");
+ graph.loop = { class_type: "PrimitiveInt", inputs: { value: ["loop", 0] } };
+ assert.equal(factsFor(graph).seed.status, "unresolved", "registered scalar cycle is bounded");
+ graph.loop = { class_type: "LoraLoader", inputs: { model: ["loop", 0] } };
+ assert.equal(factsFor(graph).model.status, "unresolved", "registered model cycle is bounded");
+ const chain = depth => {
+ const value = factGraph({ seed: ["p0", 0] });
+ for (let index = 0; index < depth; index++) value[`p${index}`] = { class_type: "PrimitiveInt", inputs: { value: index === depth - 1 ? "42" : [`p${index + 1}`, 0] } };
+ return value;
+ };
+ assert.equal(factsFor(chain(64)).seed.value, "42");
+ assert.equal(factsFor(chain(65)).seed.status, "unresolved");
+ const custom = chain(64);
+ custom.s = { class_type: "SamplerCustomAdvanced", inputs: { noise: ["noise", 0] } };
+ custom.noise = { class_type: "RandomNoise", inputs: { noise_seed: ["p0", 0] } };
+ assert.equal(factsFor(custom).seed.status, "unresolved", "the provider itself consumes one of the 64 allowed links");
+ const large = factGraph();
+ for (let index = 0; index < 498; index++) large[`unused${index}`] = { class_type: "PrimitiveInt", inputs: { value: index } };
+ assert.equal(factsFor(large).seed.value, "9007199254740993");
+ large.extra = { class_type: "PrimitiveInt", inputs: { value: 1 } };
+ assert.equal(factsFor(large).seed.status, "unresolved");
+});
// Same shape as the workflow attached to #75: PromptManager nodes are saved as
// CLIPTextEncode with extra inputs, and the checkpoint loader is pysssss'.
diff --git a/tests/js/prompt-inspector.test.js b/tests/js/prompt-inspector.test.js
index d662877..72095bc 100644
--- a/tests/js/prompt-inspector.test.js
+++ b/tests/js/prompt-inspector.test.js
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
-import { inspectPath, promptRoles, createInspectionLoader } from '../../web/js/workspace/prompt-inspector.mjs';
+import { inspectPath, promptRoles, createInspectionLoader, mountPromptBlocks } from '../../web/js/workspace/prompt-inspector.mjs';
test('inspection keeps exact physical root and encodes every path component', () => {
assert.equal(inspectPath({root_index:2,path:'a b/x#%.png'}), '/prompt_manager/images/inspect/a%20b/x%23%25.png?root=2');
@@ -13,6 +13,14 @@ test('only confirmed strings can be sent, including explicit empty negatives', (
}
assert.deepEqual(promptRoles(null), {});
});
+test('already aborted prompt blocks touch no container DOM or active handlers', () => {
+ const parent = new AbortController(); parent.abort();
+ const container = new Proxy({}, { get() { assert.fail('aborted mount accessed the container'); } });
+ const parentScope = { signal: parent.signal };
+ const view = mountPromptBlocks(container, { parentScope, sender: { open() { assert.fail('aborted action'); } } });
+ view.update({ positive: { status: 'resolved', text: 'late' } });
+ view.setState({ loading: true }); view.dispose();
+});
test('superseded requests and disposed loaders cannot replace current metadata', async () => {
const pending = [], changes = [];
const loader = createInspectionLoader({get:(url,{signal})=>new Promise(resolve=>pending.push({url,signal,resolve}))}, value=>changes.push(value));
diff --git a/tests/js/workspace-file-inspector.test.js b/tests/js/workspace-file-inspector.test.js
new file mode 100644
index 0000000..8843406
--- /dev/null
+++ b/tests/js/workspace-file-inspector.test.js
@@ -0,0 +1,50 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+const modulePath='../../web/js/workspace/file-inspector.mjs';
+const load=()=>import(modulePath);
+const rawWorkflow='{ "nodes": [], "seed": 9007199254740993, "cfg": 1.0000000000000000001 }';
+function record() {
+ return {identity:{kind:'rooted-file',key:'2:a.png',root_index:2,path:'a.png',root_scope:'a'.repeat(64)},file:{format:'PNG'},prompts:{positive:{status:'resolved',text:'a\n猫'}},generation:{seed:{status:'known',value:'9007199254740993',source:'workflow',evidence:'configuration'}},library:{metadata_scope:'pair',tags:['saved']},download:{filename:'a.png',url:'/prompt_manager/images/serve/a.png?root=2'},metadata:{status:'available',omitted_reason:null,raw:{workflow:rawWorkflow,prompt:'{broken',parameters:'literal