test(inspect): concept parity and managed-host send for local files

Compare the native Inspect a file page with the immutable concept's
#metadata at 1600x1100 and 390x844 in both themes (initial, selected,
viewer-open), mapping D60-D66 and pinning the concept's CSS values and
the chooser's displayed file name, with a mutation check. Send a local
PNG (explicitly empty negative) and core-tags.webm to a real Prompt
Manager (+/-) node across Classic and Nodes 2.0 switches and reloads;
a connected input rejects the send unchanged. Document D60-D66, the
legacy-floor dispositions and the commands in TESTING.md.
This commit is contained in:
Vito Sansevero
2026-10-22 12:01:15 -07:00
parent 4e62be338e
commit 27ea042018
5 changed files with 565 additions and 1 deletions
+130
View File
@@ -1240,6 +1240,136 @@ npm run --prefix tools/vendor test:workflows -- history-concept.spec.mjs \
workspace-history.spec.mjs --project chromium --project touch --project firefox --project webkit
```
### Inspect a file page and recorded deviations (#249 local Inspect, PR L2)
`#/inspect` ("Inspect a file") is a native workspace route; it no longer hands off to
the legacy metadata page, which stays reachable from Overview's "Open existing metadata
inspector" card (`/prompt_manager/gallery.html`). The page is the concept's `#metadata`
(app.js `renderMetadata` a740–748, selection a1715–1729; style.css 709–722, 1180–1194,
1543–1545, 1559–1564) in the concept's own classes (`.surface`, `.file-drop`,
`.spaced-20`, `.local-file-preview`, all from the synced `concept.css`), with no
`workspace.css` override. One local image (PNG/JPEG/WebP) or video (MP4/MOV/M4V/WebM/MKV;
AVI/WMV preview only), chosen or dropped, previews inline. The heading's one action opens
the shared viewer with the full inspector on a record read entirely in this browser
(`web/js/workspace/inspect.mjs`, `local-session.mjs`, `local-inspection.mjs` and the L1
readers `png-metadata.js`, `local-video.mjs`, `local-projection.mjs`). Nothing is
uploaded, imported or stored. Only HTML and JS change, so no ComfyUI restart is needed.
Bounds (the server's own budgets, ported by PR L1; see "Follow-up: local Inspect parity"
under "Chronological Library history" for the known divergences):
- PNG: at most 10,000 chunks; keyword 1–79 bytes; every chunk range checked against the
File's size before slicing. Role records (`prompt`, `parameters`,
`comfyassets_prompt_pairs`) share 2 MiB encoded and 2 MiB decoded (over that, the read
fails); a workflow over 4 MiB is `limited`; duplicates are capped at 8 MiB (workflow)
and 4 MiB (roles, decoded `parameters`). The sum of slices read from any PNG, however
large or sparse, is at most 19,794,389 bytes.
- Video: the same record rule over the container's tags (per-key carrier first, then the
`comment` envelope, then `description`); at most 10,000 boxes or elements; no slice
inside `mdat` or a Cluster beyond its header.
- JPEG/WebP: at most 64 KiB, for dimensions only.
- Response: 8 MiB of actual UTF-8 JSON; omission order workflow graph, prompt graph, all
raw. The whole File is never read with `arrayBuffer()`, `text()` or `stream()`.
Privacy, asserted by `local inspection makes no content request or storage write` and
`local roles send exactly` in `tests/browser/workspace-inspect.spec.mjs`, and by the
managed-host spec below: no request carries the file's bytes, name or text (fetch, XHR,
beacon, WebSocket, EventSource); Inspect writes nothing to `localStorage`,
`sessionStorage` or IndexedDB; only an explicit Send crosses the host bridge, and its
payload carries neither the `blob:` URL nor the filename; no console message names the
file or quotes its prompts (asserted on the managed host). The selection's `blob:` URL belongs to one local session and is revoked once,
on replacement, route change, unmount or `pagehide`, never while an element holds it
(`URLs and media end with their owning session`).
Recorded deviations on this page (maintainer rulings LI-3, LI-6; plan "Mock inventory and
mapped differences"):
- **D60** (heading action, LI-3): "Open file metadata" (`ca-primary`) replaces the
concept's "Open sample metadata". It is `aria-disabled="true"` and
`aria-describedby="local-file-note"` (the drop note) until a classified selection is
ready, then opens the shared viewer on that file.
- **D61** (copy, LI-3): the surface `p` reads "Read embedded metadata in this browser.
The file is not uploaded or added to your library."; the drop note "Your file stays in
this browser. Its metadata opens with “Open file metadata”."; the chooser label "Choose
an image or video for a local preview"; the toast "Local preview only. No file
uploaded; metadata is available above.". The h2 "Inspect without importing" is the
concept's, verbatim.
- **D62** (accept and video): `accept` is the concept's
`image/png,image/jpeg,image/webp` plus `video/*,.mp4,.mov,.m4v,.webm,.mkv,.avi,.wmv`. A
video previews in the same `#local-preview` slot as `video.local-file-preview` with
native `controls`, `playsInline`, `preload="metadata"` and `muted`, never autoplays
inline, and is paused, unloaded (`removeAttribute('src')`, `load()`) and removed on
replacement or dispose.
- **D63** (status line): one `p.small.spaced-16` in `#local-preview`, for states the
concept never shows: "Drop one file at a time.", "Folders cannot be inspected. Choose
one image or video file.", "This file type cannot be previewed or inspected here.
Choose an image or video file.", "The preview could not be displayed. Metadata and the
original remain available in the viewer." and "Embedded metadata could not be read."
(the shared inspector's own copy) are `role="alert"`; "Metadata in AVI and WMV files
is not read here. The preview and the original remain available." is `role="status"`.
- **D64** (drop, LI-6): `dragover`/`drop` on `.file-drop` accept exactly one File, read
only `dataTransfer.files`/`items` (never its strings) and change nothing visually.
While the page is mounted, `window` `dragover`/`drop` call `preventDefault`, so a missed
drop cannot navigate away. A drop is ignored while a dialog is open.
- **D65** (no revoke on load): unlike the concept, the preview's `blob:` URL is not
revoked on `load`: the viewer and Download original use it until the session ends.
- **D66** (local rail, in `file-inspector.mjs` for `identity.kind === 'local-file'`): the
viewer and rail title is the filename; "File & provenance" opens with "Selected local
file · read in this browser", lists Source, Size, Dimensions, Format, Media type and
Modified with no Path row and no Show in folder, and ends with "Local file; no saved
library association was checked." and Download original. There is no Prompt history
action and no category or tag chips.
The chooser keeps naming the chosen file, as the concept's does: it is cleared as the
picker opens (so choosing the same file again is a new selection), not after `change`,
and an accepted drop clears it so it never names a replaced file.
Legacy floor (`web/metadata.html`), dispositions:
- PNG by chooser and by drop: kept (`#local-file`, D64).
- Positive and negative with Copy: kept, in the shared inspector's prompt blocks.
- Full text (the old "Show more"): the prompt blocks show the whole text.
- Raw workflow view and Download workflow JSON: kept, in "Workflow & raw metadata".
- Copy all summary and filename copy (LI-4): the filename is selectable text in the rail
and Download original saves the file under its name. The ruled "Copy settings summary"
button after the six facts of the shared full inspector is not part of PR L2; until it
lands, the legacy page keeps COPY ALL and stays reachable from Overview.
- Fully browser-local reading: kept and asserted (privacy above).
- Case-variant keys `Workflow|comfy|ComfyUI|Prompt|Parameters` (LI-5): a separate
backend issue; Inspect follows the server's readers, so the two never disagree.
`tests/browser/inspect-concept.spec.mjs` compares the page with the immutable concept's
`#metadata` on the concept fixture, at 1600×1100 and 390×844 in both themes, on Chromium
and touch, in three states: initial, selected (`f02.png` from the parity corpus set into
both `#local-file` inputs with a real `setInputFiles`) and viewer-open (the heading
action). `withProductionCopy` applies D60–D62 to the open concept page before it is
measured (it throws if the concept's own copy changed), so the browser lays the concept
out with production's words; `mapViewerConcept` applies D66 (title, no tag chips, no
Prompt history). The diff of the heading, surface, drop area (icon, label, input, note),
preview and viewer (dialog box, layout below its header, toolbar control order, rail
section order) must then be empty. Pinned values are asserted on production: surface
padding 22px and radius 7px; drop area dashed 1px border, radius 7px, min-height 170px,
gap 12px, padding 22px, margin-top 20px; h2 17px/600; p 12px; input 12px and
max-width 100%; preview max-height 400px, `object-fit: contain`, margin-top 20px. The
chooser's displayed name is asserted to be `f02.png` in both documents. `mutation is
noticed` makes the drop border solid (noticed on all four sides and nowhere else),
renames the label and removes the note. Paired captures
(`{concept,production}-{initial,selected,viewer}-{dark,light}-{1600,390}.png`) and
`inspect-geometry.json` are attached as test artifacts; there are no PNG baselines.
`tests/comfyui/inspect-sender.spec.mjs` (`local file roles send exactly across renderer
transitions`) runs on the managed CPU host: the Inspect page reads a PNG whose positive
spans lines and whose negative is a ConditioningZeroOut (explicitly empty) and
`core-tags.webm`, and sends each pair to a Prompt Manager (+/-) node in Classic and
Nodes 2.0, switching both ways with a graph reload each time. Each send previews the
node's current and new roles, writes exactly those roles, and neither queues nor loads a
workflow. A connected `negative_text` input rejects the whole preview with no change. The
workspace page makes no non-GET request and logs neither filename nor prompt text.
```sh
node --test tests/js/workspace-local-session.test.js tests/js/workspace-state.test.js
npm run --prefix tools/vendor test:workflows -- inspect-concept.spec.mjs workspace-inspect.spec.mjs \
--project chromium --project touch --project firefox --project webkit
npm run --prefix tools/vendor test:comfyui -- inspect-sender.spec.mjs
```
## Workspace data fields for the concept pages (#249)
These fields are additive. The legacy `/prompt_manager/images/output` listing (no
+1
View File
@@ -12,6 +12,7 @@ code. This plan proposes implementation order and acceptance gates.
- 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. [#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.
- Local Inspect (#249, PRs L1–L2) replaces the `#/inspect` handoff with the concept's native "Inspect a file" page: one local image or video previews inline and opens the shared viewer and full inspector on a record read entirely in the browser, with nothing uploaded, imported or stored. The legacy metadata page (`web/metadata.html`, served at `/prompt_manager/gallery.html`) stays reachable from Overview; its capabilities map to the shared inspector, except its one-click COPY ALL summary, whose ruled replacement ("Copy settings summary") is pending. Recorded deviations D60–D66 are in [TESTING.md](../TESTING.md). Local roles equal to the server's resolver follow in PR L3.
## Scope and compatibility
+307
View File
@@ -0,0 +1,307 @@
import { writeFile } from 'node:fs/promises';
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
import { captureCanonicalState } from './history-canonical-comparison.mjs';
import { SIZES, THEMES, seedConcept, openConcept, openProduction, diffInventory, mutate } from './concept-inventory.mjs';
import { parityPng } from './local-inspect-files.mjs';
// PR L2 Task 7 of the local-Inspect plan (#249): the native "Inspect a file" page against the immutable concept's
// `#metadata` (docs/concepts/workspace/app.js renderMetadata 740–748, selection 1715–1729, style.css 709–722,
// 1180–1194, 1543–1545, 1559–1564), at 1600x1100 and 390x844 in both themes, in three states: initial, selected (the
// same synthetic PNG set into both `#local-file` inputs) and viewer-open. The concept is never edited: the recorded
// differences D60–D66 (TESTING.md) are applied to its measurement, and, where they change copy, to its page before it
// is measured, so the browser lays the concept out with production's words. After that the diff must be empty.
// Paired captures are artifacts only; there are no PNG baselines.
test.use({ locale: 'en-US', timezoneId: 'UTC', reducedMotion: 'reduce' });
const FILE = 'f02.png';
/** D60 and D61, the maintainer's LI-3 copy, keyed by the concept's own copy. */
const COPY = {
action: ['Open sample metadata', 'Open file metadata'],
intro: ['Keep the existing local-file inspection workflow. Only add the file to your library when you choose to import it.',
'Read embedded metadata in this browser. The file is not uploaded or added to your library.'],
chooser: ['Choose an image for a local preview', 'Choose an image or video for a local preview'],
note: ['This mock previews your file locally. Sample metadata is available with “Open sample metadata”.',
'Your file stays in this browser. Its metadata opens with “Open file metadata”.'],
toast: ['Local preview only. No file uploaded; sample metadata is available above.',
'Local preview only. No file uploaded; metadata is available above.'],
};
/** D62: the concept's image list plus the videos the local readers support. */
const ACCEPT = ['image/png,image/jpeg,image/webp', 'image/png,image/jpeg,image/webp,video/*,.mp4,.mov,.m4v,.webm,.mkv,.avi,.wmv'];
test.beforeEach(async ({ page, browserName }) => {
await startCoverage(page, browserName);
page.conceptErrors = [];
page.on('pageerror', error => page.conceptErrors.push(error.message));
});
test.afterEach(async ({ page, browserName }) => {
await saveCoverage(page, browserName);
expect(page.conceptErrors).toEqual([]);
});
/** Runs in the page: the Inspect surface below the heading, and the heading's actions. Self-contained. */
function measureInspect(scope) {
const main = document.querySelector(scope);
const css = (node, ...keys) => node ? Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]])) : null;
const name = node => [node.localName, ...[...node.classList].sort()].join('.');
const font = node => css(node, 'fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'color');
const origin = main.querySelector(':scope > section.surface');
const box = node => {
if (!node || !origin) return null;
const a = node.getBoundingClientRect(), b = origin.getBoundingClientRect();
return { left: Math.round(a.left - b.left), top: Math.round(a.top - b.top), width: Math.round(a.width), height: Math.round(a.height) };
};
const text = node => node ? node.textContent.replace(/\s+/g, ' ').trim() : null;
const heading = main.querySelector(':scope > .heading');
const surface = origin;
const drop = surface?.querySelector(':scope > .file-drop');
const input = drop?.querySelector('input#local-file');
const preview = surface?.querySelector(':scope > #local-preview');
const media = preview?.querySelector('.local-file-preview');
return {
heading: {
title: text(heading?.querySelector('h1')),
description: text(heading?.querySelector('p')),
actions: [...(heading?.querySelectorAll('.heading-actions > *') ?? [])].map(node => ({ name: name(node), text: text(node),
...css(node, 'backgroundColor', 'color', 'fontSize', 'paddingTop', 'paddingLeft', 'borderTopLeftRadius'), box: box(node) })),
},
surface: surface && {
children: [...surface.children].map(name), box: box(surface),
...css(surface, 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'borderTopWidth', 'borderTopStyle',
'borderTopColor', 'borderTopLeftRadius', 'backgroundColor'),
h2: { text: text(surface.querySelector(':scope > h2')), ...font(surface.querySelector(':scope > h2')), box: box(surface.querySelector(':scope > h2')) },
p: { text: text(surface.querySelector(':scope > p')), ...font(surface.querySelector(':scope > p')),
...css(surface.querySelector(':scope > p'), 'marginTop'), box: box(surface.querySelector(':scope > p')) },
},
drop: drop && {
children: [...drop.children].map(name), box: box(drop),
...css(drop, 'display', 'placeItems', 'textAlign', 'minHeight', 'rowGap', 'columnGap', 'marginTop', 'paddingTop',
'paddingRight', 'paddingBottom', 'paddingLeft', 'borderTopWidth', 'borderTopStyle', 'borderTopColor',
'borderBottomStyle', 'borderLeftStyle', 'borderRightStyle', 'borderTopLeftRadius', 'backgroundColor'),
icon: { box: box(drop.querySelector(':scope > svg.icon')), viewBox: drop.querySelector(':scope > svg.icon')?.getAttribute('viewBox'),
d: drop.querySelector(':scope > svg.icon path')?.getAttribute('d') ?? null,
...css(drop.querySelector(':scope > svg.icon'), 'color', 'strokeWidth') },
label: { text: text(drop.querySelector(':scope > label')), for: drop.querySelector(':scope > label')?.htmlFor ?? null,
...font(drop.querySelector(':scope > label')), box: box(drop.querySelector(':scope > label')) },
input: input && { type: input.type, accept: input.accept, multiple: input.multiple, ...css(input, 'maxWidth', 'fontSize', 'color'),
box: box(input) },
note: { text: text(drop.querySelector(':scope > span.small')), ...font(drop.querySelector(':scope > span.small')),
box: box(drop.querySelector(':scope > span.small')) },
},
preview: preview && {
children: [...preview.children].map(name), box: box(preview),
media: media && { tag: media.localName, alt: media.getAttribute('alt'), blob: media.src.startsWith('blob:'),
...css(media, 'width', 'maxHeight', 'objectFit', 'marginTop', 'display'), box: box(media) },
},
// The chooser's displayed name: a file input shows the last path segment of its value.
chosen: input ? { name: input.value.split(/[\\/]/).pop(), files: [...input.files].map(file => file.name) } : null,
};
}
/** Runs in the page: the open viewer's dialog box, its toolbar controls in order and its rail's sections in order. */
function measureViewer() {
const dialog = document.querySelector('dialog#viewer-dialog[open]');
if (!dialog) return null;
const css = (node, ...keys) => node ? Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]])) : null;
const rect = node => { const b = node.getBoundingClientRect(); return { left: Math.round(b.left), top: Math.round(b.top), width: Math.round(b.width), height: Math.round(b.height) }; };
const text = node => node.textContent.replace(/\s+/g, ' ').trim();
const rail = dialog.querySelector('#viewer-details');
// The rail's sections in document order, whatever wraps them.
const SECTIONS = [
['.panel-heading', () => 'heading'],
['.panel-heading + p.small', () => 'filename'],
['.tags', node => node.querySelector('button.chip') ? 'tag chips' : 'badges'],
['section.prompt-section.positive', () => 'positive'],
['section.prompt-section.negative', () => 'negative'],
['dl.meta-grid', node => node.closest('details') ? null : 'facts'],
['details > summary', node => text(node)],
['button.send', node => node.closest('.prompt-section') ? `send ${node.closest('.prompt-section').matches('.positive') ? 'positive' : 'negative'}` : 'send pair'],
['button', node => /Prompt history/.test(node.textContent) ? 'Prompt history' : null],
];
const sections = [...rail.querySelectorAll('*')].map(node => {
for (const [selector, label] of SECTIONS) if (node.matches(selector)) return label(node);
return null;
}).filter(Boolean);
return {
dialog: rect(dialog),
// The layout below the header; the header's height follows the title (D66) and the rail's height its content.
layout: (() => {
const header = dialog.querySelector('.dialog-header').getBoundingClientRect().bottom;
const left = rect(dialog.querySelector('.viewer-left')), side = rect(rail);
return { left: { ...left, top: Math.round(left.top - header) }, rail: { left: side.left, width: side.width },
...css(dialog.querySelector('.viewer-layout'), 'display', 'gridTemplateColumns') };
})(),
title: text(dialog.querySelector('#viewer-title')),
subtitle: text(dialog.querySelector('.dialog-header p')),
toolbar: [...dialog.querySelectorAll('#viewer-toolbar > button, #viewer-toolbar > a')].map(text),
railTitle: text(rail.querySelector('.panel-heading h2')),
sections,
};
}
/**
* D60–D62 applied to the open concept page before it is measured: production's action label, copy and accept list,
* so the browser lays out what follows them. The concept file itself is never touched.
*/
async function withProductionCopy(page) {
await page.evaluate(({ COPY, ACCEPT }) => {
const main = document.querySelector('#main');
const swap = (node, [from, to]) => { if (node.textContent !== from) throw new Error(`concept copy changed: ${node.textContent}`); node.textContent = to; };
swap(main.querySelector('.heading-actions > button'), COPY.action);
swap(main.querySelector('section.surface > p'), COPY.intro);
swap(main.querySelector('.file-drop > label'), COPY.chooser);
swap(main.querySelector('.file-drop > span.small'), COPY.note);
const input = main.querySelector('#local-file');
if (input.accept !== ACCEPT[0]) throw new Error(`concept accept changed: ${input.accept}`);
input.accept = ACCEPT[1];
}, { COPY, ACCEPT });
}
/** D66: the viewer of a local file is titled by its filename and has no tag chips and no Prompt history. */
function mapViewerConcept(reference) {
return {
...reference,
title: FILE,
railTitle: FILE,
sections: reference.sections.filter(section => section !== 'tag chips' && section !== 'Prompt history'),
};
}
// Two frames, then every running transition finished.
const settle = page => page.evaluate(async () => {
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
await Promise.all(document.getAnimations().map(animation => animation.finished.catch(() => {})));
});
async function conceptStates(page, theme, testInfo, tag) {
await openConcept(page, 'metadata', theme);
await page.mouse.move(0, 0);
await withProductionCopy(page);
await settle(page);
const initial = await page.evaluate(measureInspect, '#main');
await captureCanonicalState(page, testInfo, `concept-initial-${tag}`);
await page.locator('#main #local-file').setInputFiles(parityPng(FILE));
await expect(page.locator('#main #local-preview img.local-file-preview')).toBeVisible();
await page.locator('#main #local-preview img').evaluate(image => image.decode().catch(() => {}));
await expect(page.locator('#toast')).toHaveText(COPY.toast[0]);
await settle(page);
const selected = await page.evaluate(measureInspect, '#main');
await captureCanonicalState(page, testInfo, `concept-selected-${tag}`);
await page.locator('#main .heading-actions > button').click();
await expect(page.locator('dialog#viewer-dialog[open] #viewer-details .panel-heading')).toBeVisible();
await page.mouse.move(0, 0);
await settle(page);
const viewer = await page.evaluate(measureViewer);
await captureCanonicalState(page, testInfo, `concept-viewer-${tag}`);
return { initial, selected, viewer };
}
async function productionStates(page, theme, testInfo, tag) {
await openProduction(page, 'inspect', theme);
await page.mouse.move(0, 0);
const action = page.locator('#workspace-main .heading-actions').getByRole('button', { name: COPY.action[1], exact: true });
// D60: unavailable, and described by the drop note, until a classified file is chosen.
await expect(action).toHaveAttribute('aria-disabled', 'true');
await expect(action).toHaveAttribute('aria-describedby', 'local-file-note');
await settle(page);
const initial = await page.evaluate(measureInspect, '#workspace-main');
await captureCanonicalState(page, testInfo, `production-initial-${tag}`);
await page.locator('#workspace-main #local-file').setInputFiles(parityPng(FILE));
await expect(page.locator('#workspace-main #local-preview img.local-file-preview')).toBeVisible();
await page.locator('#workspace-main #local-preview img').evaluate(image => image.decode().catch(() => {}));
await expect(page.locator('#workspace-toast')).toHaveText(COPY.toast[1]);
await expect(action).toHaveAttribute('aria-disabled', 'false');
// D63: a readable PNG shows no status line.
await expect(page.locator('#workspace-main #local-preview p.small')).toHaveCount(0);
await settle(page);
const selected = await page.evaluate(measureInspect, '#workspace-main');
await captureCanonicalState(page, testInfo, `production-selected-${tag}`);
await action.click();
await expect(page.locator('dialog#viewer-dialog[open] #viewer-details .panel-heading')).toBeVisible();
await expect(page.locator('dialog#viewer-dialog[open] #viewer-details .meta-grid').first()).toBeVisible();
await page.mouse.move(0, 0);
await settle(page);
const viewer = await page.evaluate(measureViewer);
await captureCanonicalState(page, testInfo, `production-viewer-${tag}`);
return { initial, selected, viewer };
}
/** The concept's fixed CSS values (style.css 709–722, 1180–1194, 1543–1545, 1559–1564), restated as the plan pins them. */
function expectPinnedValues(state) {
expect(state.surface).toMatchObject({ paddingTop: '22px', paddingLeft: '22px', borderTopLeftRadius: '7px', borderTopStyle: 'solid' });
expect(state.surface.h2).toMatchObject({ fontSize: '17px', fontWeight: '600' });
expect(state.surface.p).toMatchObject({ fontSize: '12px' });
expect(state.drop).toMatchObject({ display: 'grid', textAlign: 'center', minHeight: '170px', rowGap: '12px', columnGap: '12px',
marginTop: '20px', paddingTop: '22px', paddingLeft: '22px', borderTopStyle: 'dashed', borderBottomStyle: 'dashed',
borderLeftStyle: 'dashed', borderRightStyle: 'dashed', borderTopWidth: '1px', borderTopLeftRadius: '7px' });
expect(state.drop.input).toMatchObject({ maxWidth: '100%', fontSize: '12px' });
}
for (const [width, height] of SIZES) for (const theme of THEMES) {
test(`inspect surface matches the concept ${width}x${height} ${theme}`, async ({ page, request }, testInfo) => {
await page.setViewportSize({ width, height });
await seedConcept(request);
const tag = `${theme}-${width}`;
const concept = await conceptStates(page, theme, testInfo, tag);
const production = await productionStates(page, theme, testInfo, tag);
const mappedViewer = mapViewerConcept(concept.viewer);
const diff = {
initial: diffInventory(concept.initial, production.initial),
selected: diffInventory(concept.selected, production.selected),
viewer: diffInventory(mappedViewer, production.viewer),
};
const path = testInfo.outputPath('inspect-geometry.json');
await writeFile(path, JSON.stringify({ concept, production, diff }, null, 2));
await testInfo.attach('inspect-geometry', { path, contentType: 'application/json' });
// The concept is what this comparison assumes: its structure, its own copy (swapped above) and its chooser.
expect(concept.initial.surface.children).toEqual(['h2', 'p', 'div.file-drop.spaced-20', 'div']);
expect(concept.initial.drop.children).toEqual(['svg.icon', 'label', 'input', 'span.small']);
expect(concept.selected.preview.children).toEqual(['img.local-file-preview']);
expect(concept.selected.chosen).toEqual({ name: FILE, files: [FILE] });
expect(concept.viewer.toolbar).toEqual(['← Previous', '−', '+', 'Fit', 'Rotate', 'Flip', 'Play', 'Download', 'Next →']);
expect(diff).toEqual({ initial: [], selected: [], viewer: [] });
for (const state of [production.initial, production.selected]) expectPinnedValues(state);
expect(production.selected.preview.media).toMatchObject({ tag: 'img', alt: 'Local file preview', blob: true,
maxHeight: '400px', objectFit: 'contain', marginTop: '20px' });
// The chooser names the chosen file after a real setInputFiles, as the concept's does.
expect(production.initial.chosen).toEqual({ name: '', files: [] });
expect(production.selected.chosen).toEqual({ name: FILE, files: [FILE] });
// D66 in the rail: no tag chips and no Prompt history; the title is the filename.
expect(production.viewer.sections).toEqual(['heading', 'filename', 'badges', 'positive', 'send positive', 'negative',
'send negative', 'facts', 'File & provenance', 'Workflow & raw metadata', 'send pair']);
});
}
test('mutation is noticed', async ({ page, request }, testInfo) => {
await page.setViewportSize({ width: 1600, height: 1100 });
await seedConcept(request);
await openConcept(page, 'metadata', 'dark');
await withProductionCopy(page);
const concept = await page.evaluate(measureInspect, '#main');
await openProduction(page, 'inspect', 'dark');
await page.mouse.move(0, 0);
expect(diffInventory(concept, await page.evaluate(measureInspect, '#workspace-main'))).toEqual([]);
// A solid border on the drop area is noticed on every side and nowhere else.
const undoCss = await mutate(page, { css: '#workspace-main .file-drop { border-style: solid; }' });
expect(diffInventory(concept, await page.evaluate(measureInspect, '#workspace-main')).map(item => item.path))
.toEqual(['drop.borderTopStyle', 'drop.borderBottomStyle', 'drop.borderLeftStyle', 'drop.borderRightStyle']);
await undoCss();
// A renamed label is noticed (its text; its box only if the new words lay out differently).
const label = page.locator('#workspace-main .file-drop > label');
await label.evaluate(node => { node.dataset.original = node.textContent; node.textContent = 'Choose a file'; });
const renamed = diffInventory(concept, await page.evaluate(measureInspect, '#workspace-main')).map(item => item.path);
expect(renamed).toContain('drop.label.text');
expect(renamed.every(path => path.startsWith('drop.label.'))).toBe(true);
await label.evaluate(node => { node.textContent = node.dataset.original; delete node.dataset.original; });
// A removed note is noticed by its path.
const undoDom = await mutate(page, { remove: ['#workspace-main .file-drop > span.small'] });
expect(diffInventory(concept, await page.evaluate(measureInspect, '#workspace-main')).map(item => item.path))
.toEqual(expect.arrayContaining(['drop.children.3', 'drop.note.text']));
await undoDom();
expect(diffInventory(concept, await page.evaluate(measureInspect, '#workspace-main'))).toEqual([]);
await captureCanonicalState(page, testInfo, 'production-after-mutations');
});
+126
View File
@@ -0,0 +1,126 @@
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import {installHostHooks} from './host-fixture.mjs';
import {sendablePng,localFixture,POSITIVE_LINES} from '../browser/local-inspect-files.mjs';
// PR L2 Task 7 of the local-Inspect plan (#249): a file read entirely in the browser by the native Inspect page sends
// its exact roles into a real Prompt Manager (+/-) node, in Classic and Nodes 2.0, switching both ways and reloading
// the graph each time. Nothing queues or loads a workflow, and a connected destination input rejects the whole send.
installHostHooks(test,['PromptManagerPair']);
// The PNG's positive spans lines and its negative is a ConditioningZeroOut (explicitly empty); the WebM carries the
// committed core video's per-key tags.
const FILES=[
{file:()=>sendablePng('host-send.png'),positive:POSITIVE_LINES,negative:'',shown:'No negative prompt (explicitly empty)'},
{file:()=>localFixture('core-tags.webm','video/webm','host-send.webm'),positive:'a red fox in snow, detailed fur',negative:'blurry, lowres',shown:'blurry, lowres'},
];
async function nodeState(page,id) {
return 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,widgets:Object.fromEntries(node.widgets.map(widget=>[widget.name,widget.value])),
serialized:Object.fromEntries(node.widgets.map((widget,index)=>[widget.name,serialized.widgets_values[index]])),effects:window.inspectEffects};
},id);
}
/**
* Counts every queue or workflow load until `restore` runs; with `reset`, first sets the destination's roles back to
* known values (the connected-input case sets them itself, before it links the input, as library-sender.spec does).
*/
async function watchHost(page,id,reset=true) {
await page.evaluate(async ({id,reset})=>{
const {app}=await import('/scripts/app.js');const node=app.graph.getNodeById(id);
if(reset){node.widgets.find(widget=>widget.name==='text').value='Before positive';node.widgets.find(widget=>widget.name==='negative_text').value='Before negative';}
window.inspectEffects={queue:0,load:0};window.inspectOriginals={};
for(const [method,key] of [['queuePrompt','queue'],['loadGraphData','load']]){const original=app[method];window.inspectOriginals[method]=original;app[method]=function(...args){window.inspectEffects[key]++;return original.apply(this,args);};}
},{id,reset});
}
const restore=page=>page.evaluate(async()=>{const {app}=await import('/scripts/app.js');Object.assign(app,window.inspectOriginals);});
/** Chooses `file` on the Inspect page and opens the shared viewer on its local record. */
async function inspect(workspace,{file,positive,shown}) {
await workspace.locator('#local-file').setInputFiles(file());
const open=workspace.locator('#workspace-main .heading-actions').getByRole('button',{name:'Open file metadata',exact:true});
await expect(open).toHaveAttribute('aria-disabled','false');await open.click();
const rail=workspace.locator('dialog#viewer-dialog[open] #viewer-details');
await expect(rail.locator('.prompt-section.positive p.prompt-text')).toHaveText(positive);
await expect(rail.locator('.prompt-section.negative p.prompt-text')).toHaveText(shown);
return rail;
}
/** The rail's pair send, through to a previewed destination; returns the sender dialog. */
async function preview(workspace,rail) {
await rail.getByRole('button',{name:/^Send prompt pair to ComfyUI: /}).click();
const sender=workspace.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
const hosts=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});await expect(hosts.locator('option')).toHaveCount(2);await hosts.selectOption({index:1});
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({index:1});
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
return sender;
}
async function closeViewer(workspace,sender) {
await sender.getByRole('button',{name:'Close',exact:true}).click();await expect(sender).toHaveCount(0);
await workspace.keyboard.press('Escape');await expect(workspace.locator('dialog#viewer-dialog[open]')).toHaveCount(0);
}
test('local file roles send exactly across renderer transitions',async({page,context},testInfo)=>{
test.setTimeout(180000);
const id=await page.evaluate(async()=>{
const {app}=await import('/scripts/app.js');const node=LiteGraph.createNode('PromptManagerPair');app.graph.add(node);
node.setSize([700,1000]);app.canvas.ds.scale=.8;app.canvas.centerOnNode(node);return node.id;
});
const workspace=await context.newPage();const errors=[];workspace.on('pageerror',error=>errors.push(error.message));
const requests=[];workspace.on('request',request=>{if(request.method()!=='GET')requests.push(`${request.method()} ${request.url()}`);});
const logged=[];workspace.on('console',message=>logged.push(message.text()));
try {
await workspace.goto('/prompt_manager/workspace#/inspect');
await expect(workspace.locator('#workspace-main h1')).toHaveText('Inspect a file');
const initial=testInfo.project.metadata.initialRenderer;
for(const renderer of [initial,!initial,initial]) {
// The renderer switch reloads the graph, as a workflow reload does.
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);
for(const entry of FILES) {
await watchHost(page,id);
try {
const rail=await inspect(workspace,entry);
const sender=await preview(workspace,rail);
for(const text of ['Before positive','Before negative',...entry.positive.split('\n'),entry.negative||'(empty)'])await expect(sender).toContainText(text);
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
const state=await nodeState(page,id);
expect(state.effects).toEqual({queue:0,load:0});
for(const field of ['inputs','serialized'])expect(state[field]).toMatchObject({text:entry.positive,negative_text:entry.negative});
await closeViewer(workspace,sender);
} finally {await restore(page);}
}
}
// A connected destination input rejects the whole preview: neither role changes, nothing queues or loads.
const link=await page.evaluate(async id=>{
const {app}=await import('/scripts/app.js');const {convertToInput}=await import('/extensions/core/widgetInputs.js');
const node=app.graph.getNodeById(id);const widget=node.widgets.find(item=>item.name==='negative_text');
if(!node.inputs.some(input=>input.name==='negative_text'))convertToInput(node,widget);
node.widgets.find(item=>item.name==='text').value='Before positive';widget.value='Before negative';
const source=LiteGraph.createNode('PrimitiveString');app.graph.add(source);source.widgets.find(item=>item.name==='value').value='Upstream negative';
source.connect(0,node,node.inputs.findIndex(input=>input.name==='negative_text'));
return [String(source.id),0];
},id);
await watchHost(page,id,false);
try {
const before=await nodeState(page,id);
expect(before.widgets).toMatchObject({text:'Before positive',negative_text:'Before negative'});
const rail=await inspect(workspace,FILES[1]);
const sender=await preview(workspace,rail);
await expect(sender.getByRole('alert')).toContainText(/connected/i);
await expect(sender.getByRole('button',{name:'Apply to ComfyUI',exact:true})).toBeDisabled();
const rejected=await nodeState(page,id);
expect(rejected.effects).toEqual({queue:0,load:0});
expect(rejected.inputs.negative_text).toEqual(link);
for(const field of ['widgets','serialized'])expect(rejected[field]).toMatchObject({text:'Before positive',negative_text:'Before negative'});
await closeViewer(workspace,sender);
} finally {await restore(page);}
// The workspace page sent nothing to the server (the only crossing is the host bridge) and logged nothing of
// the files: no name and no prompt text.
expect(requests).toEqual([]);
expect(logged.filter(text=>/host-send|lighthouse|long exposure|red fox|lowres/.test(text))).toEqual([]);
expect(errors).toEqual([]);
} finally {await workspace.close();}
});
+1 -1
View File
@@ -22,7 +22,7 @@ test('execution script discovers only the new matrix and preserves all 78 origin
'test', '--config', config, '--list'], { cwd: root, encoding: 'utf8',
env: { ...process.env, PM_COMFY_ROOT: '/unused-pinned-checkout' } });
const stock = list('tools/vendor/comfyui.config.mjs');
assert.match(stock, /Total: 80 tests in 7 files/);
assert.match(stock, /Total: 82 tests in 8 files/); // + local Inspect host case (inspect-sender.spec.mjs, 2 projects)
assert.doesNotMatch(stock, /execution-history/);
assert.doesNotMatch(stock, /execution-outputs/);
const executions = list('tools/vendor/playwright.execution.config.mjs');