From 27ea042018e87f82149a0afe54c4f2774da45584 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Thu, 22 Oct 2026 12:01:15 -0700 Subject: [PATCH] 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. --- TESTING.md | 130 +++++++++++ docs/application-redesign.md | 1 + tests/browser/inspect-concept.spec.mjs | 307 +++++++++++++++++++++++++ tests/comfyui/inspect-sender.spec.mjs | 126 ++++++++++ tests/vendor/comfyui-config.test.mjs | 2 +- 5 files changed, 565 insertions(+), 1 deletion(-) create mode 100644 tests/browser/inspect-concept.spec.mjs create mode 100644 tests/comfyui/inspect-sender.spec.mjs diff --git a/TESTING.md b/TESTING.md index e0cf324..5e32b35 100644 --- a/TESTING.md +++ b/TESTING.md @@ -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 diff --git a/docs/application-redesign.md b/docs/application-redesign.md index a18b637..7df5619 100644 --- a/docs/application-redesign.md +++ b/docs/application-redesign.md @@ -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 diff --git a/tests/browser/inspect-concept.spec.mjs b/tests/browser/inspect-concept.spec.mjs new file mode 100644 index 0000000..509cc44 --- /dev/null +++ b/tests/browser/inspect-concept.spec.mjs @@ -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'); +}); diff --git a/tests/comfyui/inspect-sender.spec.mjs b/tests/comfyui/inspect-sender.spec.mjs new file mode 100644 index 0000000..2fcb8f1 --- /dev/null +++ b/tests/comfyui/inspect-sender.spec.mjs @@ -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();} +}); diff --git a/tests/vendor/comfyui-config.test.mjs b/tests/vendor/comfyui-config.test.mjs index 4713167..971dee7 100644 --- a/tests/vendor/comfyui-config.test.mjs +++ b/tests/vendor/comfyui-config.test.mjs @@ -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');