diff --git a/TESTING.md b/TESTING.md index e77e01e..c2a82ca 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1431,6 +1431,160 @@ 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 in the page holds it: the inline preview +and, when it is open, the viewer drop their media first. The page, not the session, owns `pagehide`: it closes the +viewer, releases the inline media, then ends the selection and clears the chooser, so a page restored from the +back-forward cache shows an empty chooser beside the empty selection (`URLs and media end with their owning +session`, which also leaves with the viewer open). + +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, has its `src` removed and is removed from the page on replacement + or dispose, before its URL is revoked. Each preview owns its listeners, so the page never + pins a released node. A settled video (metadata read, or failed) is then reset with + `load()` at once; one still starting is reset only once it reports metadata or an error, + because WebKit (GStreamer) blocked the page when such a pipeline was reset (`replacing a + video that just started loading never blocks the page`, `a video replaced while still + starting is reset once it settles`). A WebKit load cut off by the revoked URL usually + stalls and never settles; a fallback then only drops the waiting listeners. The viewer's + own video teardown was checked the same way (`closing the viewer on a video that just + started never blocks the page`, 15 fresh pages on WebKit) and needed no change. + Follow-up: on WebKit, a video released exactly at its `loadstart` sometimes blocks the + page, with or without any reset and also before the deferred reset (2 of 24 runs in the + pinned image), so the `loadstart` spec is skipped on WebKit until that is investigated. +- **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"`. + A rejected chooser or drop ("Drop one file at a time.", folders, an unsupported drop) + keeps the selection and its status line: its alert is a second line after it, removed + by the next selection attempt or after the concept's toast interval (6.5 s). The toast + "Local preview only…; metadata is available above." shows only for a selection whose + status is empty or the AVI/WMV note, never beside an unreadable or failed preview. +- **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 named again with the current File (through a `DataTransfer`, which fires no +`change`) when the picker is cancelled. An accepted drop names the dropped file, so the +chooser never names a replaced file and never reads "No file chosen" beside a selection. + +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. +Follow-up (pair node, separate investigation): the node's `text` widget ignored a value +set after a `PrimitiveString` linked `negative_text` (it kept its prior value), so this spec +and `library-sender.spec.mjs` set the positive before connecting the negative input. + +```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/browser/local-inspect-files.mjs b/tests/browser/local-inspect-files.mjs new file mode 100644 index 0000000..af9f6ae --- /dev/null +++ b/tests/browser/local-inspect-files.mjs @@ -0,0 +1,66 @@ +// Files for the Inspect page workflows, as Playwright `setInputFiles` payloads ({name, mimeType, buffer}) or as the +// bytes a page builds a File or DataTransfer from. Built from the committed fixtures and the byte-exact PNG builders +// the node tests use (tests/js/helpers/png-bytes.mjs), so the browser reads exactly what the unit tests pin. +import fs from 'node:fs'; +import path from 'node:path'; +import zlib from 'node:zlib'; +import { fileURLToPath } from 'node:url'; +import { raw, ihdr, idat, iend, tEXt } from '../js/helpers/png-bytes.mjs'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const FIXTURES = path.join(here, '..', 'fixtures'); +const read = (...parts) => fs.readFileSync(path.join(FIXTURES, ...parts)); +const file = (name, mimeType, buffer) => ({ name, mimeType, buffer: Buffer.from(buffer) }); + +/** One committed parity PNG (tests/fixtures/inspection_parity), e.g. f02.png. */ +export const parityPng = (name, as = name) => file(as, 'image/png', read('inspection_parity', name)); +/** One committed local-inspect fixture (tests/fixtures/local-inspect). */ +export const localFixture = (name, mimeType, as = name) => file(as, mimeType, read('local-inspect', name)); +/** The committed CC0 VP8 clip with no generation metadata. */ +export const tinyWebm = () => file('tiny.webm', 'video/webm', read('tiny.webm')); + +/** One decodable 2×2 opaque pixel row set, so a synthetic PNG previews. */ +function pixels(width = 2, height = 2) { + const row = [0, ...Array.from({ length: width }, () => [40, 120, 200, 255]).flat()]; + return zlib.deflateSync(Buffer.from(Array.from({ length: height }, () => row).flat())); +} + +/** + * A previewable PNG whose positive spans lines and whose negative is a ConditioningZeroOut: the legacy fill shows the + * positive exactly and the ZeroOut guard makes the negative explicitly empty. + */ +export const POSITIVE_LINES = 'a lighthouse at dusk\nsoft rain, long exposure'; +export function sendablePng(name = 'send-me.png') { + const prompt = JSON.stringify({ + 3: { class_type: 'KSampler', inputs: { seed: 11, steps: 12, cfg: 5, sampler_name: 'euler', scheduler: 'normal', denoise: 1, model: ['4', 0], positive: ['6', 0], negative: ['8', 0], latent_image: ['5', 0] } }, + 4: { class_type: 'CheckpointLoaderSimple', inputs: { ckpt_name: 'local.safetensors' } }, + 5: { class_type: 'EmptyLatentImage', inputs: { width: 512, height: 512, batch_size: 1 } }, + 6: { class_type: 'CLIPTextEncode', inputs: { text: POSITIVE_LINES, clip: ['4', 1] } }, + 8: { class_type: 'ConditioningZeroOut', inputs: { conditioning: ['6', 0] } }, + }); + return file(name, 'image/png', raw(ihdr(2, 2), idat(pixels()), tEXt('prompt', prompt), iend())); +} + +/** A PNG whose text chunk fails its CRC: the reader reports the metadata unreadable; the bytes stay downloadable. */ +export const corruptPng = (name = 'corrupt.png') => + file(name, 'image/png', raw(ihdr(2, 2), idat(pixels()), tEXt('prompt', '{"3":{}}', { crc: 1 }), iend())); + +/** Bytes no reader claims, under an image or video name. */ +export const disguised = () => [ + file('notes.png', 'image/png', Buffer.from('plain text, not an image')), + file('page.png', 'image/png', Buffer.from('x

hi')), + file('vector.png', 'image/png', Buffer.from('')), + file('stream.mp4', 'video/mp4', Buffer.from('#EXTM3U\n#EXT-X-VERSION:3\n')), +]; + +/** + * core-faststart.mp4 with its video sample entry renamed from `avc1` to `zzzz`: the container and its tags read as + * before, but no browser has a decoder for the stream, so the preview fails deterministically on every engine. + */ +export function undecodableMp4(name = 'undecodable.mp4') { + const bytes = Buffer.from(read('local-inspect', 'core-faststart.mp4')); + const at = bytes.indexOf(Buffer.from('avc1', 'latin1'), bytes.indexOf(Buffer.from('stsd', 'latin1'))); + if (at < 0) throw new Error('core-faststart.mp4 has no avc1 sample entry'); + bytes.write('zzzz', at, 'latin1'); + return file(name, 'video/mp4', bytes); +} diff --git a/tests/browser/server.py b/tests/browser/server.py index 4ff976d..0d202da 100644 --- a/tests/browser/server.py +++ b/tests/browser/server.py @@ -541,6 +541,33 @@ def create_app(root: Path) -> web.Application: shutil.copyfile(root / "output" / "shared" / "same.png", destination) return web.json_response({"success": True}) + @routes.post("/__test__/add-local-inspect-file") + async def add_local_inspect_file(request: web.Request) -> web.Response: + """Serve one committed Inspect fixture from the output folder. + + The local Inspect page reads a chosen file in the browser; its page + workflow compares that rail with the Images viewer for the same bytes. + Only a committed fixture name is accepted. + """ + data = await request.json() if request.can_read_body else None + fixtures = CANONICAL_ROOT / "tests" / "fixtures" + allowed = { + path.name: path + for folder in ("inspection_parity", "local-inspect") + for path in (fixtures / folder).iterdir() + if path.suffix.lower() in {".png", ".jpg", ".webp", ".webm", ".mp4"} + } + if ( + not isinstance(data, dict) + or set(data) != {"name"} + or data["name"] not in allowed + ): + raise web.HTTPBadRequest() + destination = root / "output" / "local-inspect" / data["name"] + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(allowed[data["name"]], destination) + return web.json_response({"success": True}) + @routes.get("/api/settings/Comfy.ColorPalette") async def palette(request: web.Request) -> web.Response: return web.json_response("dark") diff --git a/tests/browser/workspace-inspect.spec.mjs b/tests/browser/workspace-inspect.spec.mjs new file mode 100644 index 0000000..5923259 --- /dev/null +++ b/tests/browser/workspace-inspect.spec.mjs @@ -0,0 +1,687 @@ +// The native "Inspect a file" page (#249 local Inspect, PR L2 Task 6): one chosen or dropped local file previews +// inline, and the heading's one action opens the shared viewer with the full inspector on a record read entirely in +// this browser. The concept is docs/concepts/workspace/app.js renderMetadata (740–748) and its selection (1715–1729); +// the production differences are D60–D66 in TESTING.md. +import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs'; +import { test, expect } from './fixtures.mjs'; +import { parityPng, localFixture, tinyWebm, sendablePng, corruptPng, disguised, undecodableMp4, POSITIVE_LINES } from './local-inspect-files.mjs'; + +const workspace = '/prompt_manager/workspace'; +const TOAST = 'Local preview only. No file uploaded; metadata is available above.'; +const ACTION = 'Open file metadata'; +const COPY = { + intro: 'Read embedded metadata in this browser. The file is not uploaded or added to your library.', + note: 'Your file stays in this browser. Its metadata opens with “Open file metadata”.', + chooser: 'Choose an image or video for a local preview', + multiple: 'Drop one file at a time.', + directory: 'Folders cannot be inspected. Choose one image or video file.', + unsupported: 'This file type cannot be previewed or inspected here. Choose an image or video file.', + previewFailed: 'The preview could not be displayed. Metadata and the original remain available in the viewer.', +}; + +test.use({ locale: 'en-US', timezoneId: 'UTC', reducedMotion: 'reduce' }); + +test.beforeEach(async ({ page, request, browserName }) => { + await startCoverage(page, browserName); + await expect(await request.post('/__test__/reset')).toBeOK(); + page.inspectErrors = []; + page.on('pageerror', error => page.inspectErrors.push(error.message)); +}); + +test.afterEach(async ({ page, browserName }) => { + await page.unrouteAll({ behavior: 'ignoreErrors' }); + await saveCoverage(page, browserName); + expect(page.inspectErrors).toEqual([]); +}); + +const main = page => page.locator('#workspace-main'); +const action = page => main(page).locator('.heading-actions').getByRole('button', { name: ACTION, exact: true }); +const chooser = page => page.locator('#local-file'); +const preview = page => page.locator('#local-preview'); +const status = page => preview(page).locator('p.small'); +const viewer = page => page.locator('dialog#viewer-dialog'); +const rail = page => viewer(page).locator('#viewer-details'); + +async function openInspect(page) { + await page.goto(`${workspace}#/inspect`); + await expect(main(page).getByRole('heading', { level: 1, name: 'Inspect a file', exact: true })).toBeVisible(); + await expect(chooser(page)).toBeAttached(); +} + +/** Selects through the real chooser, then waits for the page to classify the file. */ +async function choose(page, file) { + await chooser(page).setInputFiles(file); +} + +async function openViewer(page) { + await expect(action(page)).toHaveAttribute('aria-disabled', 'false'); + await action(page).click(); + await expect(viewer(page)).toBeVisible(); + await expect(rail(page).locator('.panel-heading')).toBeVisible(); +} + +/** Dispatches dragover + drop on `selector` with Files built in the page (and optional string items). */ +function drop(page, selector, { files = [], strings = [] } = {}) { + return page.evaluate(async ({ selector, files, strings }) => { + const transfer = new DataTransfer(); + for (const file of files) transfer.items.add(new File([new Uint8Array(file.bytes)], file.name, { type: file.mimeType })); + for (const [type, value] of strings) transfer.items.add(value, type); + const target = document.querySelector(selector); + const over = new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer: transfer }); + const dropped = new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: transfer }); + target.dispatchEvent(over); + target.dispatchEvent(dropped); + return { over: over.defaultPrevented, drop: dropped.defaultPrevented }; + }, { selector, files: files.map(file => ({ name: file.name, mimeType: file.mimeType, bytes: [...file.buffer] })), strings }); +} + +/** The rail's comparable content: prompts, the six facts, the raw records and the two downloads (D66 aside). */ +const railContent = locator => locator.evaluate(node => { + const raw = [...node.querySelectorAll('details')].find(item => item.querySelector('summary')?.textContent === 'Workflow & raw metadata'); + const facts = node.querySelector(':scope .meta-grid:not(details .meta-grid)'); + return { + positive: node.querySelector('.prompt-section.positive p.prompt-text')?.textContent ?? null, + negative: node.querySelector('.prompt-section.negative p.prompt-text')?.textContent ?? null, + facts: facts ? [...facts.querySelectorAll(':scope > div')].map(item => [item.querySelector('dt').textContent, item.querySelector('dd').textContent]) : null, + raw: raw ? [...raw.querySelectorAll('p.small, pre')].map(item => item.textContent) : null, + downloads: raw ? [...raw.querySelectorAll('button')].map(item => [item.textContent, item.getAttribute('aria-disabled')]) : null, + }; +}); + +/** Counts storage writes and object URLs created and revoked for Files (download blobs are not the session's). */ +async function instrument(page) { + await page.addInitScript(() => { + window.__inspect = { writes: [], created: [], revoked: [], files: new Set() }; + for (const name of ['setItem', 'removeItem', 'clear']) { + const original = Storage.prototype[name]; + Storage.prototype[name] = function (...args) { window.__inspect.writes.push([name, args[0]]); return original.apply(this, args); }; + } + const open = indexedDB.open.bind(indexedDB); + indexedDB.open = (...args) => { window.__inspect.writes.push(['indexedDB.open', args[0]]); return open(...args); }; + const create = URL.createObjectURL, revoke = URL.revokeObjectURL; + URL.createObjectURL = object => { + const url = create(object); + if (object instanceof File) { window.__inspect.created.push(url); window.__inspect.files.add(url); } + return url; + }; + URL.revokeObjectURL = url => { if (window.__inspect.files.has(url)) window.__inspect.revoked.push(url); return revoke(url); }; + }); +} +const urls = page => page.evaluate(() => ({ created: [...window.__inspect.created], revoked: [...window.__inspect.revoked], writes: [...window.__inspect.writes] })); + +test('chooser opens the shared full inspector without a second read', async ({ page, request }) => { + // The Images viewer's rail for the same bytes, served from the output folder. + await expect(await request.post('/__test__/add-local-inspect-file', { data: { name: 'f02.png' } })).toBeOK(); + await page.goto(`${workspace}#/images`); + await page.locator('article[data-image-key="0:local-inspect/f02.png"] .image-open').click(); + await expect(rail(page).locator('.prompt-section.positive p.prompt-text')).toBeVisible(); + const served = await railContent(rail(page)); + await page.keyboard.press('Escape'); + await expect(viewer(page)).toHaveCount(0); + + await page.locator('aside.sidebar nav').getByRole('link', { name: 'Inspect a file', exact: true }).click(); + await expect(main(page).getByRole('heading', { level: 1, name: 'Inspect a file', exact: true })).toBeVisible(); + await expect(main(page).locator('.heading p.muted')).toHaveText('A standalone file uses the same image viewer and prompt actions.'); + // The concept DOM: one surface, the drop area with its icon, label, input and note, then the preview slot. + const surface = main(page).locator('section.surface'); + await expect(surface).toHaveCount(1); + await expect(surface.locator('h2')).toHaveText('Inspect without importing'); + await expect(surface.locator(':scope > p')).toHaveText(COPY.intro); + const area = surface.locator('div.file-drop.spaced-20'); + await expect(area.locator(':scope > svg')).toHaveCount(1); + await expect(area.locator('label[for="local-file"]')).toHaveText(COPY.chooser); + await expect(chooser(page)).toHaveAttribute('type', 'file'); + await expect(chooser(page)).toHaveAttribute('accept', 'image/png,image/jpeg,image/webp,video/*,.mp4,.mov,.m4v,.webm,.mkv,.avi,.wmv'); + await expect(chooser(page)).not.toHaveAttribute('multiple', /.*/); + await expect(area.locator('span.small#local-file-note')).toHaveText(COPY.note); + await expect(surface.locator(':scope > #local-preview')).toBeEmpty(); + // D60: the one heading action is described by the note until a file is chosen. + await expect(main(page).locator('.heading-actions button')).toHaveCount(1); + await expect(action(page)).toHaveClass(/\bca-primary\b/); + await expect(action(page)).toHaveAttribute('aria-disabled', 'true'); + await expect(action(page)).toHaveAccessibleDescription(COPY.note); + await action(page).dispatchEvent('click'); + await expect(viewer(page)).toHaveCount(0); + + await choose(page, parityPng('f02.png')); + const image = preview(page).locator('img.local-file-preview'); + await expect(image).toHaveAttribute('alt', 'Local file preview'); + await expect(image).toHaveJSProperty('complete', true); + expect(await image.evaluate(node => node.naturalWidth)).toBe(64); + expect(await image.getAttribute('src')).toMatch(/^blob:/); + const box = await image.evaluate(node => ({ height: node.getBoundingClientRect().height, fit: getComputedStyle(node).objectFit, margin: getComputedStyle(node).marginTop })); + expect(box.height).toBeLessThanOrEqual(400); + expect(box).toMatchObject({ fit: 'contain', margin: '20px' }); + await expect(page.locator('#workspace-toast')).toHaveText(TOAST); + await expect(action(page)).toHaveAttribute('aria-disabled', 'false'); + await expect(action(page)).not.toHaveAttribute('aria-describedby', /.*/); + + await openViewer(page); + await expect(viewer(page).locator('#viewer-title')).toHaveText('f02.png'); + const position = viewer(page).locator('.media-position'); + await expect(position).toHaveText('1 of 1 loaded: f02.png'); + for (const name of ['Previous media', 'Next media', 'Play slideshow']) { + await expect(viewer(page).getByRole('button', { name, exact: true })).toHaveAttribute('aria-disabled', 'true'); + } + await expect(viewer(page).locator('.viewer-canvas img')).toBeVisible(); + for (const name of ['Fit image', 'Rotate image 90 degrees', 'Flip image horizontally']) { + await expect(viewer(page).getByRole('button', { name, exact: true })).toHaveAttribute('aria-disabled', 'false'); + } + const canvas = viewer(page).locator('.viewer-canvas img'); + await viewer(page).getByRole('button', { name: 'Rotate image 90 degrees', exact: true }).click(); + await expect(canvas).toHaveAttribute('style', /rotate\(90deg\)/); + await viewer(page).getByRole('button', { name: 'Flip image horizontally', exact: true }).click(); + await expect(canvas).toHaveAttribute('style', /scaleX\(-1\)/); + await viewer(page).getByRole('button', { name: 'Fit image', exact: true }).click(); + await expect(canvas).not.toHaveAttribute('style', /rotate\(90deg\)/); + + // The same rail as Images for the same bytes, except the D66 provenance. + const local = await railContent(rail(page)); + expect(local).toEqual(served); + expect(local.positive).toBe('a red fox in snow, detailed fur'); + expect(local.negative).toBe('blurry, lowres'); + expect(local.facts.map(([label]) => label)).toEqual(['Seed', 'Steps', 'CFG', 'Sampler', 'Scheduler', 'Model']); + expect(local.downloads).toEqual([['Download metadata JSON', null], ['Download workflow JSON', null]]); + const provenance = rail(page).locator('details').filter({ has: page.locator('summary', { hasText: 'File & provenance' }) }); + await expect(provenance).toContainText('Selected local file · read in this browser'); + await expect(provenance).toContainText('Local file; no saved library association was checked.'); + await expect(provenance.locator('dt', { hasText: /^Path$/ })).toHaveCount(0); + await expect(rail(page).getByRole('button', { name: 'Show in folder', exact: true })).toHaveCount(0); + await expect(rail(page).getByRole('button', { name: /Prompt history/ })).toHaveCount(0); + await expect(rail(page).locator('.chip-row button.chip')).toHaveCount(0); + const original = viewer(page).getByRole('link', { name: 'Download original', exact: true }); + await expect(original).toHaveAttribute('href', /^blob:/); + await expect(original).toHaveAttribute('download', 'f02.png'); + + await page.keyboard.press('Escape'); + await expect(viewer(page)).toHaveCount(0); + await expect(action(page)).toBeFocused(); + // Opening the viewer again shows the same record without reading the File a second time. + await page.evaluate(() => { + window.__slices = 0; + const slice = Blob.prototype.slice; + Blob.prototype.slice = function (...args) { if (this.name === 'f02.png') window.__slices++; return slice.apply(this, args); }; + }); + await openViewer(page); + expect(await railContent(rail(page))).toEqual(served); + expect(await page.evaluate(() => window.__slices)).toBe(0); +}); + +test('Import → Inspect a local image lands here', async ({ page }) => { + await page.goto(`${workspace}#/images`); + await page.getByRole('button', { name: 'Import', exact: true }).click(); + const dialog = page.getByRole('dialog', { name: 'Import & inspect', exact: true }); + await dialog.getByRole('button', { name: 'Open inspector', exact: true }).click(); + await expect(page).toHaveURL(/#\/inspect$/); + await expect(main(page).getByRole('heading', { level: 1, name: 'Inspect a file', exact: true })).toBeVisible(); + await expect(chooser(page)).toBeAttached(); + await expect(main(page).locator('.handoff')).toHaveCount(0); + await expect(page.getByRole('dialog')).toHaveCount(0); +}); + +test('drop accepts exactly one File and never navigates', async ({ page }) => { + await openInspect(page); + // A drop that misses the area is cancelled, so the browser never opens the file in place of the page. + expect(await drop(page, 'body', { files: [parityPng('f02.png')] })).toEqual({ over: true, drop: true }); + await expect(preview(page)).toBeEmpty(); + expect(await drop(page, '.file-drop', { files: [parityPng('f02.png')] })).toEqual({ over: true, drop: true }); + await expect(preview(page).locator('img.local-file-preview')).toBeVisible(); + await expect(page.locator('#workspace-toast')).toHaveText(TOAST); + // The chooser names the dropped file, never the one it replaced and never "No file chosen". + expect(await chooser(page).evaluate(node => [...node.files].map(file => file.name))).toEqual(['f02.png']); + const first = await preview(page).locator('img').getAttribute('src'); + // Two files, a link and markup keep the selection and say why. + await drop(page, '.file-drop', { files: [parityPng('f02.png'), parityPng('f03.png')] }); + await expect(status(page)).toHaveText(COPY.multiple); + await expect(status(page)).toHaveAttribute('role', 'alert'); + await expect(preview(page).locator('img')).toHaveAttribute('src', first); + for (const strings of [[['text/uri-list', 'https://example.invalid/a.png']], [['text/html', '']]]) { + await drop(page, '.file-drop', { strings }); + await expect(status(page)).toHaveText(COPY.unsupported); + await expect(preview(page).locator('img')).toHaveAttribute('src', first); + } + await expect(action(page)).toHaveAttribute('aria-disabled', 'false'); + await openViewer(page); + await expect(viewer(page).locator('#viewer-title')).toHaveText('f02.png'); + await expect(page).toHaveURL(/#\/inspect$/); +}); + +test('cancel and same-file reselect', async ({ page }) => { + await instrument(page); + await openInspect(page); + await choose(page, parityPng('f02.png')); + await expect(preview(page).locator('img.local-file-preview')).toBeVisible(); + const first = await preview(page).locator('img').getAttribute('src'); + // The chooser still names the chosen file, as in the concept; a cancel (no files) keeps the page as it is. + expect(await chooser(page).evaluate(node => node.files[0]?.name)).toBe('f02.png'); + await chooser(page).setInputFiles([]); + await expect(preview(page).locator('img')).toHaveAttribute('src', first); + await expect(status(page)).toHaveCount(0); + await expect(action(page)).toHaveAttribute('aria-disabled', 'false'); + // Opening the picker and cancelling it keeps naming the live selection (modern engines fire `cancel`, not `change`). + await chooser(page).dispatchEvent('click'); + await chooser(page).dispatchEvent('cancel'); + expect(await chooser(page).evaluate(node => [...node.files].map(file => file.name))).toEqual(['f02.png']); + await expect(preview(page).locator('img')).toHaveAttribute('src', first); + await expect(action(page)).toHaveAttribute('aria-disabled', 'false'); + // The same file again is a new selection with its own URL; the old one is released. Opening the picker clears + // the chooser, so the browser reports the same file as a change. + // (Read in the same task: a picker the click opens is cancelled by the harness, which names the file again.) + expect(await chooser(page).evaluate(node => { node.dispatchEvent(new MouseEvent('click')); return node.value; })).toBe(''); + await choose(page, parityPng('f02.png')); + await expect(preview(page).locator('img')).not.toHaveAttribute('src', first); + await expect(preview(page).locator('img.local-file-preview')).toBeVisible(); + const state = await urls(page); + expect(state.created).toHaveLength(2); + expect(state.revoked).toEqual([first]); + await openViewer(page); + await expect(viewer(page).locator('.media-position')).toHaveText('1 of 1 loaded: f02.png'); +}); + +test('preview does not imply metadata support', async ({ page }) => { + await instrument(page); + await openInspect(page); + // JPEG and WebP preview and report no supported metadata, exactly as Images reports them. + for (const [file, format] of [[localFixture('tiny.jpg', 'image/jpeg'), 'JPEG'], [localFixture('tiny.webp', 'image/webp'), 'WEBP']]) { + await choose(page, file); + await expect(preview(page).locator('img.local-file-preview')).toBeVisible(); + await openViewer(page); + await expect(rail(page)).toContainText('No supported embedded metadata was recorded in this file.'); + await expect(rail(page).locator('details').first()).toContainText(format); + await page.keyboard.press('Escape'); + await expect(viewer(page)).toHaveCount(0); + } + // A PNG whose text chunk fails its CRC: unreadable metadata, the original still downloads. + await choose(page, corruptPng()); + await expect(action(page)).toHaveAttribute('aria-disabled', 'false'); + await openViewer(page); + await expect(rail(page)).toContainText('Embedded metadata could not be read.'); + const [download] = await Promise.all([page.waitForEvent('download'), viewer(page).getByRole('link', { name: 'Download original', exact: true }).click()]); + expect(download.suggestedFilename()).toBe('corrupt.png'); + await page.keyboard.press('Escape'); + await expect(viewer(page)).toHaveCount(0); + // Text, markup, SVG and a playlist under media names are unsupported and never get a URL. + const before = (await urls(page)).created.length; + for (const file of disguised()) { + await choose(page, file); + await expect(status(page)).toHaveText(COPY.unsupported); + await expect(status(page)).toHaveAttribute('role', 'alert'); + await expect(preview(page).locator('img, video')).toHaveCount(0); + await expect(action(page)).toHaveAttribute('aria-disabled', 'true'); + } + expect((await urls(page)).created).toHaveLength(before); +}); + +test('local video plays inline and in the viewer with real tags', async ({ page, browserName }) => { + await openInspect(page); + await choose(page, localFixture('core-tags.webm', 'video/webm')); + const video = preview(page).locator('video.local-file-preview'); + await expect(video).toBeVisible(); + expect(await video.evaluate(node => ({ controls: node.controls, playsInline: node.playsInline, preload: node.preload, muted: node.muted, autoplay: node.autoplay, paused: node.paused }))) + .toEqual({ controls: true, playsInline: true, preload: 'metadata', muted: true, autoplay: false, paused: true }); + // WebKit may not decode VP8: whichever state it reports, metadata is still read. + await expect.poll(() => video.evaluate(node => node.readyState >= 1 || !!node.error)).toBe(true); + await openViewer(page); + await expect(rail(page).locator('.prompt-section.positive p.prompt-text')).toHaveText('a red fox in snow, detailed fur'); + await expect(rail(page).locator('.meta-grid').first().locator('dd').first()).toHaveText('7'); + await expect(viewer(page).locator('#viewer-stage video')).toHaveCount(1); + await page.keyboard.press('Escape'); + await choose(page, tinyWebm()); + await expect(preview(page).locator('video.local-file-preview')).toBeVisible(); + await openViewer(page); + await expect(rail(page)).toContainText('No supported embedded metadata was recorded in this file.'); + if (browserName !== 'webkit') await expect.poll(() => viewer(page).locator('#viewer-stage video').evaluate(node => node.readyState >= 1)).toBe(true); +}); + +test('the toast and a rejected drop never contradict the status line', async ({ page }) => { + // A file whose bytes are gone after choosing: the status says its metadata cannot be read, so no toast says it is + // available. + await page.addInitScript(() => { + const slice = Blob.prototype.slice; + Blob.prototype.slice = function (...args) { + if (this.name === 'gone.png') return { arrayBuffer: () => Promise.reject(new DOMException('The file is gone', 'NotReadableError')) }; + return slice.apply(this, args); + }; + }); + await openInspect(page); + await choose(page, parityPng('f02.png', 'gone.png')); + await expect(status(page)).toHaveText('Embedded metadata could not be read.'); + await expect(action(page)).toHaveAttribute('aria-disabled', 'false'); + await expect(page.locator('#workspace-toast')).toHaveText(''); + // A rejected drop is its own alert beside the selection's status, which it never replaces, and it ends with the + // concept's toast interval. + await choose(page, undecodableMp4()); + const failed = status(page).filter({ hasText: COPY.previewFailed }); + await expect(failed).toHaveCount(1); + await drop(page, '.file-drop', { files: [parityPng('f02.png'), parityPng('f03.png')] }); + const rejected = status(page).filter({ hasText: COPY.multiple }); + await expect(rejected).toHaveAttribute('role', 'alert'); + await expect(failed).toHaveCount(1); + await expect(rejected).toHaveCount(0, { timeout: 15000 }); + await expect(status(page)).toHaveText([COPY.previewFailed]); + // The next selection attempt ends a rejection at once. + await drop(page, '.file-drop', { files: [parityPng('f02.png'), parityPng('f03.png')] }); + await expect(rejected).toHaveCount(1); + await chooser(page).dispatchEvent('click'); + await chooser(page).dispatchEvent('cancel'); + await expect(status(page)).toHaveText([COPY.previewFailed]); +}); + +test('undecodable video keeps container metadata', async ({ page }) => { + await openInspect(page); + // A real MP4 whose sample entry names a codec no engine decodes; its container tags are intact. + await choose(page, undecodableMp4()); + await expect(status(page)).toHaveText(COPY.previewFailed); + await expect(status(page)).toHaveAttribute('role', 'alert'); + await expect(preview(page).locator('video')).toHaveCount(0); + await openViewer(page); + await expect(rail(page).locator('.prompt-section.positive p.prompt-text')).toHaveText('a red fox in snow, detailed fur'); + await expect(rail(page).locator('.meta-grid').first().locator('dd').first()).toHaveText('7'); +}); + +/** Records, for each revoked URL, how many elements (inline or in the viewer) still held it, and counts resets. */ +const trackReleases = page => page.evaluate(() => { + window.__released = []; + const revoke = URL.revokeObjectURL; + URL.revokeObjectURL = url => { + const live = [...document.querySelectorAll('img, video')].filter(node => node.getAttribute('src') === url); + window.__released.push({ url, live: live.length }); + return revoke(url); + }; + const load = HTMLMediaElement.prototype.load; + window.__loads = 0; + HTMLMediaElement.prototype.load = function () { if (!this.hasAttribute('src')) window.__loads++; return load.call(this); }; +}); + +test('URLs and media end with their owning session', async ({ page }) => { + await instrument(page); + await openInspect(page); + // Every release pauses the media and drops its src before the URL is revoked. + await trackReleases(page); + await choose(page, localFixture('core-tags.webm', 'video/webm')); + await expect(preview(page).locator('video')).toBeVisible(); + // A settled video (metadata read, or failed) is reset with load() when it is released. + await expect.poll(() => preview(page).locator('video').evaluate(node => node.readyState >= 1 || !!node.error)).toBe(true); + // Replacement. + await choose(page, parityPng('f02.png')); + await expect(preview(page).locator('img.local-file-preview')).toBeVisible(); + let state = await urls(page); + expect(state.revoked).toEqual([state.created[0]]); + expect(await page.evaluate(() => window.__loads)).toBeGreaterThanOrEqual(1); + // Route change. + await page.locator('aside.sidebar nav').getByRole('link', { name: 'Overview', exact: true }).click(); + await expect(main(page).getByRole('heading', { level: 1, name: 'Overview', exact: true })).toBeVisible(); + state = await urls(page); + expect(state.revoked).toEqual(state.created); + // Five mount/unmount cycles with a selection and an opened viewer leave no live URL and no dialog. + for (let cycle = 0; cycle < 5; cycle++) { + await page.locator('aside.sidebar nav').getByRole('link', { name: 'Inspect a file', exact: true }).click(); + await choose(page, parityPng('f02.png', `cycle-${cycle}.png`)); + await openViewer(page); + // The modal viewer makes the sidebar inert, so the route changes underneath it. + await page.evaluate(() => { location.hash = '#/overview'; }); + await expect(main(page).getByRole('heading', { level: 1, name: 'Overview', exact: true })).toBeVisible(); + } + state = await urls(page); + expect(state.created).toHaveLength(7); + expect([...state.revoked].sort()).toEqual([...state.created].sort()); + expect(new Set(state.revoked).size).toBe(state.revoked.length); + expect((await page.evaluate(() => window.__released)).every(entry => entry.live === 0)).toBe(true); + await expect(page.locator('dialog')).toHaveCount(0); + // Page leave releases the selection once. + await page.locator('aside.sidebar nav').getByRole('link', { name: 'Inspect a file', exact: true }).click(); + await choose(page, parityPng('f02.png', 'leave.png')); + await expect(preview(page).locator('img.local-file-preview')).toBeVisible(); + await page.evaluate(() => window.dispatchEvent(new PageTransitionEvent('pagehide', { persisted: false }))); + state = await urls(page); + expect(state.created).toHaveLength(8); + expect(state.revoked.filter(url => url === state.created[7])).toHaveLength(1); + // Page leave with the viewer open, on a page loaded at Inspect (so its listeners precede the shell's): the viewer + // closes and releases its media before the URL is revoked, and a page restored from the back-forward cache shows + // an empty chooser beside the empty selection. + await page.reload(); + await expect(chooser(page)).toBeAttached(); + await trackReleases(page); + await choose(page, parityPng('f02.png', 'leave-open.png')); + await openViewer(page); + await expect(viewer(page).getByRole('button', { name: 'Zoom in', exact: true })).toHaveAttribute('aria-disabled', 'false'); + await page.evaluate(() => window.dispatchEvent(new PageTransitionEvent('pagehide', { persisted: true }))); + await expect(page.locator('dialog')).toHaveCount(0); + state = await urls(page); + expect(state.created).toHaveLength(1); + expect(state.revoked).toEqual(state.created); + expect(await page.evaluate(() => window.__released)).toEqual([{ url: state.created[0], live: 0 }]); + await page.evaluate(() => window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true }))); + await expect(chooser(page)).toBeAttached(); + expect(await chooser(page).evaluate(node => ({ value: node.value, files: node.files.length }))).toEqual({ value: '', files: 0 }); + await expect(preview(page).locator('img, video')).toHaveCount(0); + await expect(action(page)).toHaveAttribute('aria-disabled', 'true'); +}); + +test('replacing a video that just started loading never blocks the page', async ({ context, browserName }) => { + test.setTimeout(120000); + // WebKit (GStreamer) blocked the page when a video whose pipeline was still starting was reset with load(): the + // next chooser selection, or the steps after it, never completed. Each fresh page replaces its first video as + // soon as it shows. + for (let attempt = 0; attempt < 15; attempt++) { + const page = await context.newPage(); + try { + if (attempt === 0) await startCoverage(page, browserName); + await openInspect(page); + await choose(page, localFixture('core-tags.webm', 'video/webm')); + await expect(preview(page).locator('video.local-file-preview')).toBeVisible(); + await chooser(page).setInputFiles(parityPng('f02.png'), { timeout: 8000 }); + await expect(preview(page).locator('img.local-file-preview')).toBeVisible(); + await expect(preview(page).locator('video')).toHaveCount(0); + if (attempt === 0) await saveCoverage(page, browserName); + } finally { + await page.close(); + } + } +}); + +test('a video replaced while still starting is reset once it settles', async ({ context, browserName }) => { + // A WebKit (GStreamer) video released exactly at its `loadstart` sometimes blocks the page, with or without any + // reset and also with the code before the deferred reset (2 of 24 runs); tracked in TESTING.md (D62). WebKit's + // no-hang guard is `replacing a video that just started loading never blocks the page`. + test.skip(browserName === 'webkit', 'WebKit release at loadstart is a tracked follow-up (D62)'); + test.setTimeout(120000); + // The video is replaced from its own `loadstart` (readyState HAVE_NOTHING). It leaves the page with no src before + // its URL is revoked, and is reset (load() with no src, which frees its decoder) only once it reports metadata or + // an error, so the page never blocks. Fresh pages share one browser process. + for (let attempt = 0; attempt < 5; attempt++) { + const page = await context.newPage(); + try { + if (attempt === 0) await startCoverage(page, browserName); + await page.addInitScript(() => { + const load = HTMLMediaElement.prototype.load; + HTMLMediaElement.prototype.load = function () { if (!this.hasAttribute('src')) this.__resets = (this.__resets ?? 0) + 1; return load.call(this); }; + const revoke = URL.revokeObjectURL; + URL.revokeObjectURL = url => { + const starting = window.__starting; + if (url === starting?.url) starting.revoked = { src: starting.node.hasAttribute('src'), connected: starting.node.isConnected, resets: starting.node.__resets ?? 0 }; + return revoke(url); + }; + }); + await openInspect(page); + await page.evaluate(() => { + const input = document.querySelector('#local-file'), slot = document.querySelector('#local-preview'); + new MutationObserver((records, observer) => { + const video = slot.querySelector('video'); + if (!video) return; + observer.disconnect(); + video.addEventListener('loadstart', () => { + window.__starting = { node: video, url: video.getAttribute('src'), readyState: video.readyState }; + input.files = window.__pngFiles; input.dispatchEvent(new Event('change', { bubbles: true })); + }, { once: true }); + }).observe(slot, { childList: true }); + }); + const png = parityPng('f02.png'); + await page.evaluate(bytes => { + const transfer = new DataTransfer(); + transfer.items.add(new File([new Uint8Array(bytes)], 'f02.png', { type: 'image/png' })); + window.__pngFiles = transfer.files; + }, [...png.buffer]); + await choose(page, localFixture('core-tags.webm', 'video/webm')); + await expect(preview(page).locator('img.local-file-preview')).toBeVisible({ timeout: 8000 }); + await expect(preview(page).locator('video')).toHaveCount(0); + // Released while starting, and never reset synchronously: the URL went once the node had no src. + expect(await page.evaluate(() => ({ readyState: window.__starting.readyState, revoked: window.__starting.revoked }))) + .toEqual({ readyState: 0, revoked: { src: false, connected: false, resets: 0 } }); + await expect.poll(() => page.evaluate(() => window.__starting.node.__resets ?? 0), { timeout: 15000 }).toBe(1); + // The page still answers. + await chooser(page).setInputFiles(parityPng('f02.png', 'after.png'), { timeout: 8000 }); + await expect(preview(page).locator('img.local-file-preview')).toBeVisible({ timeout: 8000 }); + if (attempt === 0) await saveCoverage(page, browserName); + } finally { + await page.close(); + } + } +}); + +test('closing the viewer on a video that just started never blocks the page', async ({ context, browserName }) => { + test.setTimeout(180000); + // The viewer's video teardown resets its element; on WebKit a reset of a still-starting pipeline blocked the page. + for (let attempt = 0; attempt < 15; attempt++) { + const page = await context.newPage(); + try { + if (attempt === 0) await startCoverage(page, browserName); + await openInspect(page); + await choose(page, localFixture('core-tags.webm', 'video/webm')); + await openViewer(page); + await page.keyboard.press('Escape'); + await expect(viewer(page)).toHaveCount(0, { timeout: 8000 }); + await chooser(page).setInputFiles(parityPng('f02.png'), { timeout: 8000 }); + await expect(preview(page).locator('img.local-file-preview')).toBeVisible({ timeout: 8000 }); + if (attempt === 0) await saveCoverage(page, browserName); + } finally { + await page.close(); + } + } +}); + +test('replacing a file mid-read never shows or sends the old record', async ({ page, context }) => { + const host = await bridgeHost(context); + try { + await openInspect(page); + // Hold every read of held.png after its classification prefix. + await page.evaluate(() => { + let release; + window.__gate = new Promise(resolve => { release = resolve; }); + window.__releaseGate = release; + window.__held = 0; + const slice = Blob.prototype.slice; + Blob.prototype.slice = function (...args) { + const part = slice.apply(this, args); + if (this.name !== 'held.png' || (args[0] ?? 0) === 0 && (args[1] ?? this.size) <= 32) return part; + const read = part.arrayBuffer.bind(part); + part.arrayBuffer = async () => { window.__held++; await window.__gate; return read(); }; + return part; + }; + }); + await choose(page, parityPng('f03.png', 'held.png')); + await expect.poll(() => page.evaluate(() => window.__held)).toBeGreaterThan(0); + await choose(page, sendablePng('fresh.png')); + await expect(preview(page).locator('img.local-file-preview')).toBeVisible(); + await page.evaluate(() => window.__releaseGate()); + await openViewer(page); + await expect(viewer(page).locator('#viewer-title')).toHaveText('fresh.png'); + await expect(rail(page).locator('.prompt-section.positive p.prompt-text')).toHaveText(POSITIVE_LINES); + await sendPair(page); + expect(await host.evaluate(() => window.inspectApplied)).toEqual([{ positive: POSITIVE_LINES, negative: '' }]); + } finally { + await host.evaluate(() => window.inspectHost.dispose()); + await host.close(); + } +}); + +/** A second tab acting as a ComfyUI host with one Prompt Manager pair destination. */ +async function bridgeHost(context) { + const host = await context.newPage(); + await host.goto(`${workspace}#/overview`); + await host.evaluate(async () => { + const { createBridgeHost } = await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js'); + const target = { id: 'pair-1', label: 'Inspect destination', type: 'PromptManagerPair', roles: ['positive', 'negative'] }; + window.inspectApplied = []; + window.inspectPreviews = []; + window.inspectHost = createBridgeHost({ origin: location.origin, namespace: '/', label: 'Inspect ComfyUI', adapter: { + listTargets: () => [target], + preview: (_id, roles) => { + window.inspectPreviews.push(roles); + return { preview: { target, fields: Object.entries(roles).map(([role, after]) => ({ role, before: `Previous ${role}`, after })), + composition: { prepend: '', append: '', prependConnected: false, appendConnected: false, before: 'Previous positive', after: roles.positive ?? 'Previous positive' } }, snapshot: roles }; + }, + apply: roles => { window.inspectApplied.push(roles); return { targetId: target.id, values: roles }; }, + } }); + }); + return host; +} + +async function sendPair(page) { + await rail(page).getByRole('button', { name: /^Send prompt pair to ComfyUI: / }).click(); + const sender = page.getByRole('dialog', { name: 'Send to ComfyUI', exact: true }); + await sender.getByRole('combobox', { name: 'ComfyUI window', exact: true }).selectOption({ label: 'Inspect ComfyUI' }); + await sender.getByRole('combobox', { name: 'Destination node', exact: true }).selectOption({ label: 'Inspect destination' }); + 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.'); + await sender.getByRole('button', { name: 'Close', exact: true }).click(); +} + +test('local roles send exactly', async ({ page, context }) => { + const host = await bridgeHost(context); + try { + await openInspect(page); + await choose(page, sendablePng()); + await openViewer(page); + await expect(rail(page).locator('.prompt-section.positive p.prompt-text')).toHaveText(POSITIVE_LINES); + await expect(rail(page).locator('.prompt-section.negative p.prompt-text')).toHaveText('No negative prompt (explicitly empty)'); + await sendPair(page); + const applied = await host.evaluate(() => window.inspectApplied); + expect(applied).toEqual([{ positive: POSITIVE_LINES, negative: '' }]); + const payload = JSON.stringify([applied, await host.evaluate(() => window.inspectPreviews)]); + expect(payload).not.toContain('blob:'); + expect(payload).not.toContain('send-me'); + // The viewer is still open, focus came back to the send action. + await expect(viewer(page)).toBeVisible(); + } finally { + await host.evaluate(() => window.inspectHost.dispose()); + await host.close(); + } +}); + +test('local inspection makes no content request or storage write', async ({ page }) => { + await instrument(page); + await openInspect(page); + // The shell's own reads (counts, Activity) settle before the baseline. + await expect(page.locator('aside.sidebar nav [data-route="images"] .nav-count')).toBeAttached(); + const baseline = (await urls(page)).writes.length; + const requests = []; + page.on('request', request => requests.push({ url: request.url(), method: request.method(), body: request.postData() })); + page.on('websocket', socket => requests.push({ url: socket.url(), method: 'WS', body: null })); + for (const file of [sendablePng('private-name.png'), localFixture('core-tags.webm', 'video/webm', 'private-clip.webm')]) { + await choose(page, file); + await openViewer(page); + await rail(page).locator('summary', { hasText: 'Workflow & raw metadata' }).click(); + await page.keyboard.press('Escape'); + await expect(viewer(page)).toHaveCount(0); + } + const network = requests.filter(entry => !entry.url.startsWith('blob:') && !entry.url.startsWith('data:')); + expect(network.filter(entry => /private|lighthouse|fox/.test(`${entry.url} ${entry.body ?? ''}`))).toEqual([]); + expect(network.filter(entry => entry.method !== 'GET')).toEqual([]); + expect(network.filter(entry => !/\/prompt_manager\/(activity|images\/output|library|vocabulary)/.test(entry.url))).toEqual([]); + expect((await urls(page)).writes.slice(baseline)).toEqual([]); +}); + +test('touch layout at 390 has no horizontal overflow at 200% zoom', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await openInspect(page); + await choose(page, parityPng('f02.png')); + await expect(preview(page).locator('img.local-file-preview')).toBeVisible(); + for (const zoom of ['1', '2']) { + await page.evaluate(value => { document.documentElement.style.zoom = value; }, zoom); + await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); + const area = await page.locator('.file-drop').evaluate(node => node.getBoundingClientRect().right <= document.documentElement.clientWidth + 0.5); + expect(area).toBe(true); + } +}); diff --git a/tests/browser/workspace.spec.mjs b/tests/browser/workspace.spec.mjs index c8ed505..fff42a6 100644 --- a/tests/browser/workspace.spec.mjs +++ b/tests/browser/workspace.spec.mjs @@ -16,7 +16,8 @@ const routes = [ // #249 E3: the shelf no longer links the legacy Library; Overview's tool card still does. ['library', 'Prompt library', null], ['tags', 'Tags & categories', null], - ['inspect', 'Inspect a file', '/prompt_manager/gallery.html'], + // #249 local Inspect: the native Inspect a file page; Overview's tool card still links the legacy metadata page. + ['inspect', 'Inspect a file', null], ['system', 'System', '/prompt_manager/admin'], // #249 D51: SAVED VIEWS' Favorites, Images filtered to favorites. ['favorites', 'Favorites', null], @@ -80,7 +81,8 @@ test('all shell destinations deep-link, reload and expose working existing appli const main = page.locator('#workspace-main'); await expect(main.getByRole('heading', { level: 1, name: label, exact: true })).toBeVisible(); await expect(sidebarLink(page, label)).toHaveAttribute('aria-current', 'page'); - if (route === 'tags') await expect(main.locator('.handoff')).toHaveCount(0); + if (route === 'tags' || route === 'inspect') await expect(main.locator('.handoff')).toHaveCount(0); + if (route === 'inspect') await expect(main.locator('#local-file')).toBeAttached(); if (legacy) { await expect(main.locator(`a[href="${legacy}"]`)).toBeVisible(); await expect(await request.get(legacy.split('#')[0])).toBeOK(); @@ -93,6 +95,10 @@ test('all shell destinations deep-link, reload and expose working existing appli const library = page.locator('#workspace-main').getByRole('link', { name: 'Open existing prompt library', exact: true }); await expect(library).toHaveAttribute('href', '/prompt_manager/admin'); await expect(await request.get('/prompt_manager/admin')).toBeOK(); + // The legacy metadata page stays reachable from Overview beside the native Inspect page. + const metadata = page.locator('#workspace-main').getByRole('link', { name: 'Open existing metadata inspector', exact: true }); + await expect(metadata).toHaveAttribute('href', '/prompt_manager/gallery.html'); + await expect(await request.get('/prompt_manager/gallery.html')).toBeOK(); const gallery = page.locator('#workspace-main').getByRole('link', { name: 'Open existing image gallery', exact: true }); await expect(gallery).toHaveAttribute('href', '/prompt_manager/gallery'); await expect(await request.get('/prompt_manager/gallery')).toBeOK(); 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/js/helpers/mounted-dom.mjs b/tests/js/helpers/mounted-dom.mjs new file mode 100644 index 0000000..3b8bffc --- /dev/null +++ b/tests/js/helpers/mounted-dom.mjs @@ -0,0 +1,40 @@ +// A minimal DOM for mounting the workspace viewer and inspector in Node: elements with +// children, attributes, listeners (fire() dispatches), dialogs and media methods. +export function mountedDom() { + class Node { + constructor(tag) {this.tagName=tag;this.children=[];this.dataset={};this.attributes={};this.listeners=new Map();this._text='';this.classList={add(){},remove(){}};} + get textContent(){return this._text+this.children.map(c=>c.textContent).join('');} + set textContent(value){this._text=String(value);this.children=[];} + get childNodes(){return this.children;} + get isConnected(){return this.root || !!this.parent?.isConnected;} + // As in the DOM, a string argument becomes a text node. + static wrap(node){if(typeof node!=='string')return node;const text=new Node('#text');text.textContent=node;return text;} + append(...nodes){for(const node of nodes.map(Node.wrap)){node.parent=this;this.children.push(node);}} + prepend(...nodes){nodes=nodes.map(Node.wrap);for(const node of nodes)node.parent=this;this.children.unshift(...nodes);} + replaceChildren(...nodes){for(const child of this.children)child.parent=null;this.children=[];this._text='';this.append(...nodes);} + remove(){if(this.parent)this.parent.children=this.parent.children.filter(c=>c!==this);this.parent=null;} + setAttribute(key,value){this.attributes[key]=String(value);} + getAttribute(key){return this.attributes[key]??null;} + removeAttribute(key){delete this.attributes[key];} + addEventListener(type,callback,options={}){const list=this.listeners.get(type)||[];list.push({callback,signal:options.signal});this.listeners.set(type,list);} + removeEventListener(type,callback){this.listeners.set(type,(this.listeners.get(type)||[]).filter(e=>e.callback!==callback));} + fire(type,event={}){for(const e of [...(this.listeners.get(type)||[])])if(!e.signal?.aborted)e.callback({target:this,preventDefault(){},...event});} + focus(){this.focused=true;} + showModal(){this.open=true;} + close(){this.open=false;this.fire('close');} + contains(node){return node===this||this.children.some(c=>c.contains(node));} + closest(selector){if(selector==='section'&&this.tagName==='section')return this;if(selector==='[data-send-role]'&&this.dataset.sendRole)return this;if(selector==='[data-copy-role]'&&this.dataset.copyRole)return this;return this.parent?.closest(selector)||null;} + querySelector(selector){return walk(this,n=>n.tagName===selector)[0]||null;} + pause(){this.paused=true;} + load(){this.loaded=true;} + play(){this.played=true;return Promise.resolve();} + } + const walk=(node,predicate)=>[...(predicate(node)?[node]:[]),...node.children.flatMap(child=>walk(child,predicate))]; + const oldDocument=globalThis.document,oldWindow=globalThis.window; + const container=new Node('main');container.root=true; + const doc=new Node('document');doc.createElement=tag=>new Node(tag);doc.createElementNS=(_,tag)=>new Node(tag); + doc.querySelectorAll=()=>walk(container,n=>n.tagName==='dialog'&&n.open);doc.activeElement=new Node('button');doc.activeElement.root=true; + const win=new Node('window');win.location={href:'http://localhost/prompt_manager/workspace'};win.getSelection=()=>({isCollapsed:true}); + globalThis.document=doc;globalThis.window=win; + return {container,doc,win,walk,Node,restore(){globalThis.document=oldDocument;globalThis.window=oldWindow;}}; +} diff --git a/tests/js/workspace-file-inspector.test.js b/tests/js/workspace-file-inspector.test.js index 15c512f..cb722ff 100644 --- a/tests/js/workspace-file-inspector.test.js +++ b/tests/js/workspace-file-inspector.test.js @@ -87,6 +87,104 @@ test('Show in folder outcomes use the D38 toasts',async()=>{ assert.equal(revealOutcome(error),'The folder could not be opened. Try again.'); }); +// PR L2 Task 4(c): a record read in this browser renders the D66 local rail. +const {mountedDom} = await import('./helpers/mounted-dom.mjs'); +const {createScope} = await import('../../web/js/shared/toolkit/core.js'); +const {createRequire} = await import('node:module'); +const fs = await import('node:fs'); +const require = createRequire(import.meta.url); +const PngMetadata = require('../../web/js/png-metadata.js'), ComfyMetadata = require('../../web/js/comfy-metadata.js'); +const {raw:pngRaw, ihdr, idat, iend, tEXt} = await import('./helpers/png-bytes.mjs'); +const LOCAL_PROMPT = '{"3":{"class_type":"KSampler","inputs":{"seed":7,"steps":20,"cfg":8.0,"sampler_name":"euler","scheduler":"normal","denoise":1.0,"model":["4",0],"positive":["6",0],"negative":["7",0],"latent_image":["5",0]}},"4":{"class_type":"CheckpointLoaderSimple","inputs":{"ckpt_name":"synthetic-v1.safetensors"}},"5":{"class_type":"EmptyLatentImage","inputs":{"width":512,"height":512,"batch_size":1}},"6":{"class_type":"CLIPTextEncode","inputs":{"text":"a red fox in snow, detailed fur","clip":["4",1]}},"7":{"class_type":"CLIPTextEncode","inputs":{"text":"blurry, lowres","clip":["4",1]}}}'; +const LOCAL_URL = 'blob:http://localhost/11111111-2222-3333-4444-555555555555'; +const PARITY = new URL('../fixtures/inspection_parity/', import.meta.url); + +async function localRecord(file) { + const local = await import('../../web/js/workspace/local-inspection.mjs'); + const previous = {png:globalThis.PngMetadata, comfy:globalThis.ComfyMetadata}; + globalThis.PngMetadata = PngMetadata; globalThis.ComfyMetadata = ComfyMetadata; + try { + const classification = PngMetadata.classifyPrefix(new Uint8Array(await file.slice(0, 32).arrayBuffer())); + const handle = local.createLocalFileHandle({file, url:LOCAL_URL, classification}); + return local.localFileAdapter.toRecord(await local.localFileAdapter.read(handle), handle, ComfyMetadata.extractGenerationFacts); + } finally { + if (previous.png === undefined) delete globalThis.PngMetadata; else globalThis.PngMetadata = previous.png; + if (previous.comfy === undefined) delete globalThis.ComfyMetadata; else globalThis.ComfyMetadata = previous.comfy; + } +} +const localPng = name => new File([pngRaw(ihdr(640, 480), idat(), tEXt('prompt', LOCAL_PROMPT), iend())], name, {type:'image/png', lastModified:1_700_000_000_000}); +function renderFull(record) { + const f = mountedDom(), scope = createScope(); + const forbidden = new Proxy({}, {get(_, key) {assert.fail(`api.${String(key)} touched by a local record`);}}); + const view = mountFileInspector(f.container, {api:forbidden, sender:{open() {}}, parentScope:scope, variant:'full'}); + view.setState({loading:false, error:false, record}); + const texts = predicate => f.walk(f.container, predicate).map(node => node.textContent); + return {f, texts, done() {view.dispose(); scope.dispose(); f.restore();}}; +} +const {mountFileInspector} = await load(); + +test('local record renders local provenance', async () => { + const record = await localRecord(localPng('Fox Render.png')); + const {f, texts, done} = renderFull(record); + try { + const section = f.walk(f.container, n => n.tagName === 'details' && n.children[0]?.textContent === 'File & provenance')[0]; + assert.ok(section, 'the full variant has File & provenance'); + const lines = f.walk(section, n => n.tagName === 'p').map(n => n.textContent); + assert.equal(lines[0], 'Selected local file · read in this browser'); + assert.ok(lines.includes('Local file; no saved library association was checked.')); + assert.ok(!lines.some(line => /historical bytes|saved positive prompt|Saved association/.test(line))); + assert.deepEqual(f.walk(section, n => n.tagName === 'dt').map(n => n.textContent), ['Source', 'Size', 'Dimensions', 'Format', 'Media type', 'Modified']); + const values = Object.fromEntries(f.walk(section, n => n.tagName === 'dl')[0].children.map(row => [row.children[0].textContent, row.children[1].textContent])); + assert.equal(values.Source, 'Selected local file'); + assert.equal(values.Dimensions, '640 × 480'); + assert.equal(values.Format, 'PNG'); + assert.equal(values['Media type'], 'image'); + assert.equal(texts(n => n.tagName === 'button' && /Show .* in folder|Show in folder/.test(n.textContent)).length, 0); + assert.equal(f.walk(f.container, n => n.tagName === 'button' && n.textContent.includes('Prompt history')).length, 0); + const original = f.walk(section, n => n.tagName === 'a' && n.textContent === 'Download original')[0]; + assert.ok(original, 'Download original is present'); + assert.equal(original.href, LOCAL_URL); + assert.equal(original.download, 'Fox Render.png'); + // The heading names the file itself; no library title replaces it. + assert.equal(f.walk(f.container, n => n.tagName === 'h2')[0].textContent, 'Fox Render.png'); + } finally { done(); } +}); + +test('local record shows no image-tag chips', async () => { + const record = await localRecord(localPng('a.png')); + assert.equal(record.library.status, 'unavailable'); + // A local payload carries no image_tags facet (#281), so the record has none and only linked chips could show. + assert.equal(record.imageTags, null); + const {inspectorTagChips} = await load(); + assert.deepEqual(inspectorTagChips(record, {filterable:true}), []); + const {f, done} = renderFull(record); + try { + // Only the format chip and the D21 evidence badge: no category, saved tag or image-tag chips. + const chips = f.walk(f.container, n => /(^| )chip( |$)/.test(n.className ?? '')).map(n => n.textContent); + assert.deepEqual(chips, ['PNG', 'Workflow inferred']); + assert.equal(f.walk(f.container, n => n.tagName === 'button' && /^Tag/.test(n.textContent)).length, 0); + } finally { done(); } +}); + +test('legacy fill applies to local records', async () => { + // f09: the server's inspection is ambiguous; the local record shows the legacy parse, labelled as inferred. + const ambiguous = await localRecord(new File([fs.readFileSync(new URL('f09.png', PARITY))], 'f09.png')); + const graphs = {prompt:ambiguous.metadata.prompt_graph.value, workflow:ambiguous.metadata.workflow_graph.status === 'parsed' ? ambiguous.metadata.workflow_graph.value : null}; + const legacy = ComfyMetadata.extractGenerationParams(graphs); + assert.deepEqual(ambiguous.prompts.positive, {status:'resolved', text:legacy.positivePrompt, source:'graph-inferred'}); + assert.deepEqual(ambiguous.prompts.negative, {status:'resolved', text:legacy.negativePrompt, source:'graph-inferred'}); + assert.equal(ambiguous.prompts.positive.text, 'a red fox in snow, detailed fur'); + const view = renderFull(ambiguous); + try { + assert.deepEqual(view.texts(n => n.className === 'chip blue'), ['Workflow inferred']); + assert.ok(view.texts(n => n.tagName === 'p').includes('Prompt evidence: workflow inferred')); + } finally { view.done(); } + // f01b: the legacy parse would read the positive text through ConditioningZeroOut; the guard shows the zeroed negative. + const zeroed = await localRecord(new File([fs.readFileSync(new URL('f01b.png', PARITY))], 'f01b.png')); + assert.deepEqual(zeroed.prompts.negative, {status:'resolved', text:'', source:'zeroed-conditioning'}); + assert.notEqual(zeroed.prompts.positive.text, ''); +}); + // #249 PR C Task C3 (D88): one chip row, the file's current image tags first, then D21's linked tags not already shown. function tagged(imageTags, library = {status:'linked', tags:['Minimal', 'warm_light', 'editorial'], tags_scope:'positive'}) { return {...record(), imageTags, library}; diff --git a/tests/js/workspace-inspection-state.test.js b/tests/js/workspace-inspection-state.test.js index ac33dae..82ea79d 100644 --- a/tests/js/workspace-inspection-state.test.js +++ b/tests/js/workspace-inspection-state.test.js @@ -703,6 +703,75 @@ test('a workflow the legacy parser throws on keeps the server result instead of assert.equal(record.prompts.negative.status, 'ambiguous'); }); +// PR L2 Task 4(a): an adapter that reads by itself (the local file adapter) never reaches the API. +test('controller prefers adapter.read and never touches api', async () => { + const createController = contract('createInspectionController'); + const forbidden = new Proxy({}, { get(_, key) { assert.fail(`api.${String(key)} touched`); } }); + const reads = [], records = []; + const adapter = { + validate(item) { if (!item?.key) throw new TypeError('bad'); }, key: item => item.key, scope: () => 'own', inspectionPath: item => `own:${item.key}`, + read(item, options) { const pending = {}; reads.push({ item, options, pending }); return new Promise((resolve, reject) => Object.assign(pending, { resolve, reject })); }, + toRecord(result, item) { records.push([result, item.key]); return { identity: { kind: 'own', key: item.key }, prompts: result.prompts }; }, + }; + for (const api of [forbidden, null]) { + reads.length = 0; records.length = 0; + const controller = createController({ api, adapter, extractFacts: extractGenerationFacts }); + const first = controller.select({ key: 'a' }); + assert.equal(reads.length, 1); + assert.equal(reads[0].item.key, 'a'); + assert.ok(reads[0].options.signal instanceof AbortSignal); + assert.equal(controller.state.loading, true); + const second = controller.select({ key: 'b' }); + assert.equal(reads[0].options.signal.aborted, true, 'a new selection aborts the previous read'); + reads[0].pending.resolve({ prompts: { positive: { status: 'resolved', text: 'late' } } }); + await first; + assert.equal(controller.state.record, null, 'a superseded read never publishes'); + reads[1].pending.resolve({ prompts: { positive: { status: 'resolved', text: 'current' } } }); + await second; + assert.deepEqual(records.map(([, key]) => key), ['b']); + assert.equal(controller.state.record.prompts.positive.text, 'current'); + const retry = controller.retry(); + reads[2].pending.reject(new Error('read failed')); + await retry; + assert.equal(controller.state.error, true); + assert.equal(controller.state.message, 'Could not read details from this file.'); + controller.dispose(); + } +}); + +test('rooted and history adapters are unchanged', async () => { + const createController = contract('createInspectionController'); + const { reportedLocationAdapter } = await import('../../web/js/workspace/history-media.mjs'); + for (const adapter of [module.rootedFileAdapter, reportedLocationAdapter]) { + assert.equal(adapter.read, undefined, 'server adapters read through the API'); + assert.equal(adapter.mediaUrl, undefined, 'server adapters keep the cache-busted original'); + } + const { api, requests } = pendingApi(); + const rooted = createController({ api, extractFacts: extractGenerationFacts }); + rooted.select(image()); + assert.deepEqual(requests.map(request => request.url), [`/prompt_manager/images/inspect/folder/a%20%23%25.png?root=0&root_scope=${scopeA}`]); + rooted.dispose(); + const run = '22222222-2222-2222-2222-222222222222', epoch = '11111111-1111-1111-1111-111111111111'; + const query = new URLSearchParams({ node: '1', ordinal: '1', epoch, current_scope: scopeA }), base = `/prompt_manager/history/runs/${run}/output/current`; + const target = { kind: 'reported-location', location_key: 'c'.repeat(64), history_epoch: epoch, current_scope: scopeA, occurrence: { run_id: run, output_node_id: '1', image_ordinal: 1 }, + reference: { folder_type: 'output', subfolder: '', filename: 'a.png', path_style: 'posix', root_key: scopeB }, filename: 'a.png', is_video: false, + url: `${base}/media?${query}`, thumbnail_url: `${base}/thumbnail?${query}`, inspection_url: `${base}/inspection?${query}` }; + const history = pendingApi(), recorded = createController({ api: history.api, adapter: reportedLocationAdapter, extractFacts: extractGenerationFacts }); + recorded.select(target); + assert.deepEqual(history.requests.map(request => request.url), [target.inspection_url]); + recorded.dispose(); +}); + +test('fileFor rejects selected-local-file for rooted identity', () => { + const entry = image(), data = envelope(entry); + data.file = { ...data.file, observation: 'selected-local-file', source_label: 'Selected local file' }; + assert.throws(() => contract('toInspectionRecord')(data, entry, extractGenerationFacts), /Invalid inspection file/); + const project = contract('projectInspectionPayload'); + assert.throws(() => project(data, { kind: 'rooted-file', key: 'k', path: 'a.png' }, null, extractGenerationFacts), /Invalid inspection file/); + assert.throws(() => project(envelope(entry), { kind: 'local-file', key: 'local:1', path: 'a.png' }, null, extractGenerationFacts), /Invalid inspection file/); + assert.equal(project(data, { kind: 'local-file', key: 'local:1', path: 'a.png' }, null, extractGenerationFacts).file.observation, 'selected-local-file'); +}); + // #249 PR C Task C3: the B3 envelope's `image_tags` facet, the file's own current tags (S1). test('image_tags facet is kept exactly and absent stays null', () => { const entry = image(), project = contract('toInspectionRecord'); diff --git a/tests/js/workspace-local-inspection.test.js b/tests/js/workspace-local-inspection.test.js index f20400d..0769055 100644 --- a/tests/js/workspace-local-inspection.test.js +++ b/tests/js/workspace-local-inspection.test.js @@ -267,9 +267,10 @@ test("select reuses the same local key", async () => { assert.equal(m.localFileAdapter.inspectionPath(first), `local-file:${first.key}`); assert.equal(m.localFileAdapter.key(first), first.key); const reads = []; - // Until the loader seam lands (PR L2 Task 4) the controller reaches the adapter through api.get. - const api = { get: (sentinel) => { reads.push(sentinel); return m.localFileAdapter.read(reads.length === 1 || sentinel === `local-file:${first.key}` ? first : second); } }; - const controller = createInspectionController({ api, extractFacts: extractGenerationFacts, adapter: m.localFileAdapter }); + // The controller calls adapter.read itself (PR L2 Task 4); the API is never reached. + const api = new Proxy({}, { get: (_, key) => assert.fail(`api.${String(key)} touched`) }); + const adapter = { ...m.localFileAdapter, read: (handle, options) => { reads.push(m.localFileAdapter.inspectionPath(handle)); return m.localFileAdapter.read(handle, options); } }; + const controller = createInspectionController({ api, extractFacts: extractGenerationFacts, adapter }); const pending = controller.select(first); assert.equal(controller.select(first), pending, "the same handle returns the pending read"); await pending; diff --git a/tests/js/workspace-local-session.test.js b/tests/js/workspace-local-session.test.js new file mode 100644 index 0000000..7749876 --- /dev/null +++ b/tests/js/workspace-local-session.test.js @@ -0,0 +1,408 @@ +// web/js/workspace/local-session.mjs owns one local selection at a time: it +// classifies the chosen File by its bytes, creates the blob: URL only for a +// classified file, hands an owned handle to a real inspection controller, and +// releases the URL, the handle and the record on replacement, dispose, parent +// abort and page leave. +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import { raw, ihdr, idat, iend, tEXt } from "./helpers/png-bytes.mjs"; +import { createScope } from "../../web/js/shared/toolkit/core.js"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); +const PngMetadata = require("../../web/js/png-metadata.js"); +const ComfyMetadata = require("../../web/js/comfy-metadata.js"); +globalThis.PngMetadata = PngMetadata; +globalThis.ComfyMetadata = ComfyMetadata; +const m = await import("../../web/js/workspace/local-session.mjs").catch((error) => { + if (error.code !== "ERR_MODULE_NOT_FOUND") throw error; + return {}; +}); +const { localFileAdapter } = await import("../../web/js/workspace/local-inspection.mjs"); + +const FIXTURES = path.join(here, "..", "fixtures", "local-inspect"); +const promptFor = (text) => JSON.stringify({ 3: { class_type: "KSampler", inputs: { seed: 7, steps: 20, cfg: 8, sampler_name: "euler", scheduler: "normal", model: ["4", 0], positive: ["6", 0], negative: ["7", 0] } }, + 4: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "m.safetensors" } }, 6: { class_type: "CLIPTextEncode", inputs: { text, clip: ["4", 1] } }, 7: { class_type: "CLIPTextEncode", inputs: { text: "blurry", clip: ["4", 1] } } }); +const MODIFIED = 1_700_000_000_000; + +/** A File that records each slice and lets a test hold slice reads (`gate(index)` returns a promise). */ +class RecordingFile extends File { + constructor(parts, name, { gate, ...options } = {}) { + super(parts, name, { lastModified: MODIFIED, ...options }); + this.ranges = []; + this.gate = gate; + } + slice(start, end) { + const index = this.ranges.push([start, end]) - 1; + const part = super.slice(start, end), gate = this.gate; + return { size: part.size, arrayBuffer: async () => { if (gate) await gate(index); return part.arrayBuffer(); } }; + } +} +const pngFile = (name, text = "a red fox", options) => new RecordingFile([raw(ihdr(64, 48), idat(), tEXt("prompt", promptFor(text)), iend())], name, { type: "image/png", ...options }); +const fixture = (name, as = name) => new RecordingFile([fs.readFileSync(path.join(FIXTURES, name))], as); +function deferred() { + let resolve, reject; + const promise = new Promise((ok, fail) => { resolve = ok; reject = fail; }); + return { promise, resolve, reject }; +} +const settle = async () => { for (let i = 0; i < 30; i++) await new Promise((resolve) => setImmediate(resolve)); }; + +function urls({ fail = false } = {}) { + const created = [], revoked = []; + return { + created, revoked, + createObjectURL(file) { if (fail) throw new Error("no object URLs"); const url = `blob:http://localhost/${created.length + 1}`; created.push({ url, file }); return url; }, + revokeObjectURL(url) { revoked.push(url); }, + }; +} +function pageTarget() { + const listeners = new Map(); + return { + listeners, + addEventListener(type, callback) { listeners.set(type, callback); }, + removeEventListener(type, callback) { if (listeners.get(type) === callback) listeners.delete(type); }, + fire(type) { listeners.get(type)?.({ type }); }, + }; +} +function session(options = {}) { + const parentScope = options.parentScope ?? createScope(), spies = options.urls ?? urls(), page = options.page ?? pageTarget(); + const value = m.createLocalSession({ parentScope, createObjectURL: spies.createObjectURL, revokeObjectURL: spies.revokeObjectURL, + schedulerYield: () => Promise.resolve(), pageTarget: page, extractFacts: ComfyMetadata.extractGenerationFacts, ...options.overrides }); + return { session: value, parentScope, urls: spies, page }; +} +/** Resolves with the first controller state `predicate` accepts. */ +function until(controller, predicate) { + return new Promise((resolve) => { + let stop = null, done = false; + stop = controller.subscribe((state) => { if (!done && predicate(state)) { done = true; resolve(state); stop?.(); } }); + if (done) stop(); + }); +} +const recorded = (controller) => until(controller, (state) => state.record !== null || state.error); + +test("contract", () => { + assert.equal(typeof m.createLocalSession, "function", "createLocalSession is exported"); + assert.equal(typeof m.createYield, "function", "createYield is exported"); + assert.deepEqual(Object.keys(m.LOCAL_SESSION_MESSAGES).sort(), ["containerOnly", "directory", "multiple", "previewFailed", "unreadable", "unsupported"]); + const { session: value } = session(); + for (const name of ["select", "retry", "subscribe", "dispose"]) assert.equal(typeof value[name], "function", name); + assert.equal(value.state.phase, "empty"); + assert.equal(value.state.preview, null); + assert.equal(value.entry, null); + assert.equal(typeof value.controller.select, "function"); + value.dispose(); +}); + +test("replacement synchronously clears old capabilities", async () => { + const { session: value, urls: spies } = session(); + const states = []; + value.subscribe((state) => states.push({ ...state, revokedAtPublish: spies.revoked.length })); + // Two different Files with the same name, size and time are two selections. + const a = pngFile("same.png", "first fox"), b = pngFile("same.png", "other fox"); + assert.equal(a.size, b.size); + assert.equal(value.select([a]), "accepted"); + assert.equal(value.state.phase, "classifying"); + const first = await recorded(value.controller); + assert.equal(first.record.prompts.positive.text, "first fox"); + const entryA = value.entry, urlA = spies.created[0].url; + assert.deepEqual([entryA.filename, entryA.is_video], ["same.png", false]); + assert.deepEqual(value.state.preview, { url: urlA, media: "image" }); + assert.equal(localFileAdapter.originalDownload(entryA).url, urlA); + + const before = states.length; + assert.equal(value.select([b]), "accepted"); + // Synchronously: no record, no entry, no preview, the old URL revoked once and the old handle foreign. + assert.equal(value.controller.state.record, null); + assert.equal(value.entry, null); + assert.equal(value.state.preview, null); + assert.deepEqual(spies.revoked, [urlA]); + for (const method of ["originalDownload", "mediaUrl", "key"]) assert.throws(() => localFileAdapter[method](entryA), TypeError, method); + assert.throws(() => localFileAdapter.originalDownload({ ...entryA }), TypeError, "a copy of a released handle is foreign too"); + // The preview left the published state before its URL was revoked. + assert.deepEqual([states[before].phase, states[before].preview, states[before].revokedAtPublish], ["classifying", null, 0]); + const second = await recorded(value.controller); + assert.equal(second.record.prompts.positive.text, "other fox"); + assert.notEqual(value.entry.key, entryA.key); + assert.equal(spies.revoked.length, 1); + + // A read still in flight when the next file arrives never publishes. + const held = deferred(), published = []; + value.controller.subscribe((state) => { if (state.record) published.push(state.record.identity.key); }); + const slow = pngFile("slow.png", "late fox", { gate: (index) => (index < 2 ? Promise.resolve() : held.promise) }); + value.select([slow]); + await until(value.controller, (state) => state.loading); + const slowKey = value.entry.key; + assert.equal(value.select([pngFile("next.png", "current fox")]), "accepted"); + held.resolve(); + const current = await recorded(value.controller); + await settle(); + assert.equal(current.record.prompts.positive.text, "current fox"); + assert.ok(!published.includes(slowKey), "the replaced read never published"); + assert.equal(value.controller.state.record.prompts.positive.text, "current fox"); + value.dispose(); + assert.equal(new Set(spies.revoked).size, spies.revoked.length, "each URL revoked once"); + assert.deepEqual(spies.revoked.sort(), spies.created.map((item) => item.url).sort()); +}); + +test("cancel and invalid drops keep the previous selection", async () => { + const { session: value, urls: spies } = session(); + value.select([pngFile("kept.png", "kept fox")]); + await recorded(value.controller); + const entry = value.entry, state = value.state; + const directory = new File([], "folder"); + const drops = [ + [[], "rejected-empty"], [{ files: [], items: [] }, "rejected-empty"], [null, "ignored"], [undefined, "ignored"], + [[pngFile("one.png"), pngFile("two.png")], "rejected-multiple"], + [{ files: [pngFile("one.png"), pngFile("two.png")], items: [{ kind: "file" }, { kind: "file" }] }, "rejected-multiple"], + [{ files: [directory], items: [{ kind: "file", webkitGetAsEntry: () => ({ isDirectory: true, isFile: false }) }] }, "rejected-directory"], + [{ files: [], items: [{ kind: "string" }] }, "rejected-empty"], + ]; + for (const [input, outcome] of drops) { + assert.equal(value.select(input), outcome, JSON.stringify(input)); + assert.equal(value.entry, entry); + assert.equal(value.state, state); + assert.equal(value.controller.state.record.prompts.positive.text, "kept fox"); + } + assert.equal(spies.created.length, 1); + assert.deepEqual(spies.revoked, []); + // One File in a DataTransfer whose entry is a file is accepted. + assert.equal(value.select({ files: [pngFile("dropped.png", "dropped fox")], items: [{ kind: "file", webkitGetAsEntry: () => ({ isDirectory: false, isFile: true }) }] }), "accepted"); + assert.equal((await recorded(value.controller)).record.prompts.positive.text, "dropped fox"); + value.dispose(); +}); + +test("unclassified input creates no object URL", async () => { + const { session: value, urls: spies } = session(); + const text = (body, name) => new RecordingFile([body], name); + for (const file of [text("plain text", "a.png"), text("

hi", "a.mp4"), text('', "a.png"), + text("#EXTM3U\n#EXT-X-VERSION:3\n", "a.mp4"), new RecordingFile([], "empty.png")]) { + assert.equal(value.select([file]), "accepted"); + await settle(); + assert.equal(value.state.phase, "unsupported", file.name); + assert.equal(value.state.message, m.LOCAL_SESSION_MESSAGES.unsupported); + assert.equal(value.state.preview, null); + assert.equal(value.entry, null); + assert.equal(value.controller.state.record, null); + assert.ok(file.ranges.every(([start, end]) => start === 0 && end <= 32), "only the prefix is read"); + } + // A file that cannot be read after choosing (removed or denied) is not an unsupported type: when the browser + // declares a supported type it opens the inspector's unreadable state, with no preview and no URL (ledger ruling). + const gone = pngFile("gone.png", "fox", { gate: () => Promise.reject(new DOMException("The file was removed", "NotReadableError")) }); + assert.equal(value.select([gone]), "accepted"); + await settle(); + const unreadable = value.controller.state; + assert.ok(unreadable.record, "the unreadable record is published"); + assert.deepEqual([value.state.phase, value.state.message, value.state.preview], ["ready", m.LOCAL_SESSION_MESSAGES.unreadable, null]); + assert.equal(m.LOCAL_SESSION_MESSAGES.unreadable, "Embedded metadata could not be read."); + assert.equal(value.entry.filename, "gone.png"); + assert.equal(unreadable.record.metadata.status, "unreadable"); + assert.equal(unreadable.record.download, null); + assert.equal(spies.created.length, 0); + // A prefix read that fails for another reason than the browser's read failure is not claimed as unreadable. + const broken = pngFile("broken.png", "fox", { gate: () => Promise.reject(new TypeError("not a read failure")) }); + assert.equal(value.select([broken]), "accepted"); + await settle(); + assert.deepEqual([value.state.phase, value.state.message, value.entry], ["unsupported", m.LOCAL_SESSION_MESSAGES.unsupported, null]); + // A read failure for a selection already replaced publishes nothing for it. + const slow = deferred(); + const replaced = pngFile("replaced.png", "fox", { gate: () => slow.promise }); + value.select([replaced]); + await settle(); + value.select([new RecordingFile([new Uint8Array(4)], "next.txt")]); + slow.reject(new DOMException("The file was removed", "NotReadableError")); + await settle(); + assert.deepEqual([value.state.phase, value.entry], ["unsupported", null]); + // An unreadable file the browser declares no supported type for stays unsupported. + const unknown = new RecordingFile([new Uint8Array(8)], "gone.bin", { gate: () => Promise.reject(new DOMException("The file was removed", "NotReadableError")) }); + assert.equal(value.select([unknown]), "accepted"); + await settle(); + assert.deepEqual([value.state.phase, value.state.message, value.entry], ["unsupported", m.LOCAL_SESSION_MESSAGES.unsupported, null]); + assert.equal(spies.created.length, 0); + // A real MP4 named .png is a video by its bytes. + value.select([fixture("core-faststart.mp4", "clip.png")]); + await settle(); + assert.equal(value.state.phase, "ready"); + assert.equal(value.state.preview.media, "video"); + assert.equal(value.entry.is_video, true); + assert.equal(spies.created.length, 1); + value.dispose(); +}); + +test("video record publishes even if preview fails", async () => { + const { session: value } = session({ urls: urls({ fail: true }) }); + value.select([fixture("core-tags.webm")]); + const state = await recorded(value.controller); + assert.equal(value.state.phase, "ready"); + assert.equal(value.state.preview, null); + assert.equal(value.state.message, m.LOCAL_SESSION_MESSAGES.previewFailed); + assert.equal(value.entry.is_video, true); + assert.equal(state.error, false); + assert.equal(state.record.file.media_type, "video"); + assert.equal(state.record.metadata.status, "available"); + assert.equal(state.record.generation.seed.value, "7"); + assert.equal(state.record.download, null, "no original without an object URL"); + value.dispose(); + // AVI keeps its preview and says its metadata is not read. + const avi = session(); + avi.session.select([new RecordingFile([Buffer.from("RIFF"), new Uint8Array(4), Buffer.from("AVI LIST"), new Uint8Array(64)], "clip.avi")]); + await recorded(avi.session.controller); + assert.equal(avi.session.state.message, m.LOCAL_SESSION_MESSAGES.containerOnly); + assert.equal(avi.session.state.preview.media, "video"); + avi.session.dispose(); +}); + +test("disposal settles a pending fallback yield", async () => { + const timers = new Map(), cleared = []; + let serial = 0; + const setTimer = (callback, delay) => { assert.equal(delay, 0); timers.set(++serial, callback); return serial; }; + const clearTimer = (token) => { cleared.push(token); timers.delete(token); }; + // The yield itself: a timer that rejects with AbortError on abort and removes its listener. + const yieldControl = m.createYield({ schedulerYield: null, setTimer, clearTimer }); + const controller = new AbortController(); + const pending = yieldControl(controller.signal); + assert.equal(timers.size, 1); + controller.abort(); + await assert.rejects(pending, { name: "AbortError" }); + assert.deepEqual(cleared, [1], "the timer is cleared on abort (the once listener is gone with it)"); + // A turn that completes removes its abort listener, so a long-lived signal does not collect them. + const kept = new AbortController(), removed = []; + const remove = kept.signal.removeEventListener.bind(kept.signal); + kept.signal.removeEventListener = (type, callback) => { removed.push(type); remove(type, callback); }; + const ok = yieldControl(kept.signal); + timers.get(2)(); + await ok; + assert.deepEqual(removed, ["abort"]); + await assert.rejects(yieldControl(AbortSignal.abort()), { name: "AbortError" }); + // The session: a selection waiting on the fallback yield is settled by dispose and does no work. + timers.clear(); cleared.length = 0; + const { session: value, urls: spies } = session({ overrides: { schedulerYield: null, setTimer, clearTimer } }); + const file = pngFile("a.png"); + value.select([file]); + assert.equal(timers.size, 1); + const [token] = timers.keys(); + value.dispose(); + assert.deepEqual(cleared, [token]); + await settle(); + assert.deepEqual(file.ranges, []); + assert.equal(spies.created.length, 0); +}); + +test("old scheduler.yield continuation is fenced", async () => { + const turns = []; + const { session: value, urls: spies } = session({ overrides: { schedulerYield: () => { const turn = deferred(); turns.push(turn); return turn.promise; } } }); + const a = pngFile("a.png", "old fox"), b = pngFile("b.png", "new fox"); + value.select([a]); + value.select([b]); + assert.equal(turns.length, 2); + turns[0].resolve(); + await settle(); + assert.deepEqual(a.ranges, [], "the replaced selection never reads"); + assert.equal(spies.created.length, 0); + assert.equal(value.state.phase, "classifying"); + turns[1].resolve(); + const state = await recorded(value.controller); + assert.equal(state.record.prompts.positive.text, "new fox"); + assert.deepEqual(spies.created.map((item) => item.file), [b]); + // A continuation that resumes after dispose is fenced too. + value.select([pngFile("c.png")]); + const late = turns.at(-1); + value.dispose(); + late.resolve(); + await settle(); + assert.equal(spies.created.length, 1); +}); + +test("retry rereads identically", async () => { + const { session: value } = session(); + const file = pngFile("a.png", "retry fox"); + value.select([file]); + const first = await recorded(value.controller); + const classified = 1, reads = file.ranges.slice(classified); + assert.ok(reads.length > 1); + const key = value.entry.key; + await value.retry(); + const again = value.controller.state; + assert.deepEqual(file.ranges.slice(classified + reads.length), reads, "the same slice ranges"); + assert.equal(again.record.identity.key, key); + assert.equal(again.record.identity.key, first.record.identity.key); + assert.equal(again.record.prompts.positive.text, "retry fox"); + value.dispose(); + assert.equal(value.retry(), undefined, "a disposed session never reads"); + assert.equal(file.ranges.length, classified + 2 * reads.length); +}); + +test("already disposed parent does zero work", () => { + const parentScope = createScope(); + parentScope.dispose(); + const fail = (name) => () => assert.fail(`${name} called`); + const page = { addEventListener: fail("addEventListener"), removeEventListener: fail("removeEventListener") }; + const value = m.createLocalSession({ parentScope, createObjectURL: fail("createObjectURL"), revokeObjectURL: fail("revokeObjectURL"), + schedulerYield: fail("schedulerYield"), setTimer: fail("setTimer"), clearTimer: fail("clearTimer"), pageTarget: page }); + const file = pngFile("a.png"); + assert.equal(value.select([file]), "ignored"); + assert.equal(value.retry(), undefined); + assert.equal(value.entry, null); + assert.equal(value.state.phase, "empty"); + const unsubscribe = value.subscribe(fail("listener")); + unsubscribe(); + value.dispose(); value.dispose(); + assert.deepEqual(file.ranges, []); +}); + +test("dispose, parent abort and page leave each release the URL once", async () => { + // dispose: the preview is cleared for subscribers before its URL is revoked. + const first = session(), seen = []; + first.session.select([pngFile("a.png")]); + await recorded(first.session.controller); + first.session.subscribe((state) => seen.push([state.phase, state.preview, first.urls.revoked.length])); + first.session.dispose(); first.session.dispose(); + assert.deepEqual(seen.at(-1), ["empty", null, 0]); + assert.deepEqual(first.urls.revoked, [first.urls.created[0].url]); + assert.equal(first.session.select([pngFile("b.png")]), "ignored"); + assert.equal(first.page.listeners.size, 0, "the page listener is removed"); + // Parent abort disposes. + const second = session(); + second.session.select([pngFile("a.png")]); + await recorded(second.session.controller); + second.parentScope.dispose(); + assert.deepEqual(second.urls.revoked, [second.urls.created[0].url]); + assert.equal(second.session.controller.state.record, null); + assert.equal(second.page.listeners.size, 0); + // pagehide releases the selection; a page restored from the back/forward cache can choose again. + const third = session(); + third.session.select([pngFile("a.png")]); + await recorded(third.session.controller); + const entry = third.session.entry; + third.page.fire("pagehide"); + assert.deepEqual(third.urls.revoked, [third.urls.created[0].url]); + assert.equal(third.session.entry, null); + assert.equal(third.session.state.phase, "empty"); + assert.equal(third.session.controller.state.record, null); + assert.throws(() => localFileAdapter.originalDownload(entry), TypeError); + third.page.fire("pagehide"); + assert.equal(third.urls.revoked.length, 1); + assert.equal(third.session.select([pngFile("b.png", "after return")]), "accepted"); + assert.equal((await recorded(third.session.controller)).record.prompts.positive.text, "after return"); + third.session.dispose(); + assert.equal(third.urls.revoked.length, 2); + // A page that owns page leave passes no pageTarget and ends the selection with clear(), as pagehide would. + const owned = session({ overrides: { pageTarget: null } }); + owned.session.select([pngFile("a.png")]); + await recorded(owned.session.controller); + assert.equal(owned.page.listeners.size, 0, "no pagehide listener of its own"); + owned.session.clear(); + assert.deepEqual(owned.urls.revoked, [owned.urls.created[0].url]); + assert.equal(owned.session.entry, null); + assert.equal(owned.session.state.phase, "empty"); + owned.session.clear(); + assert.equal(owned.urls.revoked.length, 1, "clear is idempotent"); + assert.equal(owned.session.select([pngFile("b.png")]), "accepted"); + await recorded(owned.session.controller); + owned.session.dispose(); + owned.session.clear(); + assert.equal(owned.urls.revoked.length, 2); +}); diff --git a/tests/js/workspace-media-viewer.test.js b/tests/js/workspace-media-viewer.test.js index 6fe3d91..749694a 100644 --- a/tests/js/workspace-media-viewer.test.js +++ b/tests/js/workspace-media-viewer.test.js @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import {createScope} from '../../web/js/shared/toolkit/core.js'; import gallerySettings from '../../web/js/gallery-settings.js'; +import {mountedDom} from './helpers/mounted-dom.mjs'; const module = await import('../../web/js/workspace/media-viewer.mjs').catch(error => { if (error.code !== 'ERR_MODULE_NOT_FOUND') throw error; @@ -15,7 +16,7 @@ test('disposed media viewer touches no DOM observers or requests', () => { const view = module.createMediaViewer({container:forbidden,parentScope,inspectionController:forbidden,sender:forbidden, onSelect:() => assert.fail('Disposed viewer selected an entry'),onRefresh:() => assert.fail('Disposed viewer refreshed')}); if (!aborted) view.dispose(); - view.open(forbidden); view.stopSlideshow?.(); view.dispose(); view.dispose(); parentScope.dispose(); + view.open(forbidden); view.stopSlideshow?.(); view.close(); view.dispose(); view.dispose(); parentScope.dispose(); } }); @@ -33,44 +34,6 @@ test('preferences handle denied malformed storage without mutation', () => { assert.deepEqual(module.readViewerPreferences(()=>storage,normalize),defaults); }); -function mountedDom() { - class Node { - constructor(tag) {this.tagName=tag;this.children=[];this.dataset={};this.attributes={};this.listeners=new Map();this._text='';this.classList={add(){},remove(){}};} - get textContent(){return this._text+this.children.map(c=>c.textContent).join('');} - set textContent(value){this._text=String(value);this.children=[];} - get childNodes(){return this.children;} - get isConnected(){return this.root || !!this.parent?.isConnected;} - // As in the DOM, a string argument becomes a text node. - static wrap(node){if(typeof node!=='string')return node;const text=new Node('#text');text.textContent=node;return text;} - append(...nodes){for(const node of nodes.map(Node.wrap)){node.parent=this;this.children.push(node);}} - prepend(...nodes){nodes=nodes.map(Node.wrap);for(const node of nodes)node.parent=this;this.children.unshift(...nodes);} - replaceChildren(...nodes){for(const child of this.children)child.parent=null;this.children=[];this._text='';this.append(...nodes);} - remove(){if(this.parent)this.parent.children=this.parent.children.filter(c=>c!==this);this.parent=null;} - setAttribute(key,value){this.attributes[key]=String(value);} - getAttribute(key){return this.attributes[key]??null;} - removeAttribute(key){delete this.attributes[key];} - addEventListener(type,callback,options={}){const list=this.listeners.get(type)||[];list.push({callback,signal:options.signal});this.listeners.set(type,list);} - removeEventListener(type,callback){this.listeners.set(type,(this.listeners.get(type)||[]).filter(e=>e.callback!==callback));} - fire(type,event={}){for(const e of [...(this.listeners.get(type)||[])])if(!e.signal?.aborted)e.callback({target:this,preventDefault(){},...event});} - focus(){this.focused=true;} - showModal(){this.open=true;} - close(){this.open=false;this.fire('close');} - contains(node){return node===this||this.children.some(c=>c.contains(node));} - closest(selector){if(selector==='section'&&this.tagName==='section')return this;if(selector==='[data-send-role]'&&this.dataset.sendRole)return this;if(selector==='[data-copy-role]'&&this.dataset.copyRole)return this;return this.parent?.closest(selector)||null;} - querySelector(selector){return walk(this,n=>n.tagName===selector)[0]||null;} - pause(){this.paused=true;} - load(){this.loaded=true;} - play(){this.played=true;return Promise.resolve();} - } - const walk=(node,predicate)=>[...(predicate(node)?[node]:[]),...node.children.flatMap(child=>walk(child,predicate))]; - const oldDocument=globalThis.document,oldWindow=globalThis.window; - const container=new Node('main');container.root=true; - const doc=new Node('document');doc.createElement=tag=>new Node(tag);doc.createElementNS=(_,tag)=>new Node(tag); - doc.querySelectorAll=()=>walk(container,n=>n.tagName==='dialog'&&n.open);doc.activeElement=new Node('button');doc.activeElement.root=true; - const win=new Node('window');win.location={href:'http://localhost/prompt_manager/workspace'};win.getSelection=()=>({isCollapsed:true}); - globalThis.document=doc;globalThis.window=win; - return {container,doc,win,walk,Node,restore(){globalThis.document=oldDocument;globalThis.window=oldWindow;}}; -} function occurrenceMedia(node,ordinal,key) { const run='22222222-2222-2222-2222-222222222222',epoch='11111111-1111-1111-1111-111111111111',scope='a'.repeat(64); const query=new URLSearchParams({node,ordinal:String(ordinal),epoch,current_scope:scope}),base=`/prompt_manager/history/runs/${run}/output/current`; @@ -247,3 +210,84 @@ test('viewer titles a linked card " · <filename>" before any inspection assert.equal(g.walk(g.container,n=>n.id==='viewer-title')[0].textContent,unlinked.filename); } finally { other.dispose(); scope.dispose(); g.restore(); } }); + +// PR L2 Task 4(d): an adapter's own media URL (a session-owned blob:) is used exactly, never cache-busted. +async function localHandles() { + const {createLocalFileHandle} = await import('../../web/js/workspace/local-inspection.mjs'); + const image = createLocalFileHandle({file:new File([new Uint8Array(8)], 'a.png'), url:'blob:http://localhost/aaaaaaaa-0000-0000-0000-000000000001', classification:{media:'image', kind:'png'}}); + const video = createLocalFileHandle({file:new File([new Uint8Array(8)], 'b.mp4'), url:'blob:http://localhost/aaaaaaaa-0000-0000-0000-000000000002', classification:{media:'video', kind:'iso-bmff'}}); + return {image, video}; +} +function withViewerGlobals(f) { + const globals = {ResizeObserver:globalThis.ResizeObserver, requestAnimationFrame:globalThis.requestAnimationFrame, cancelAnimationFrame:globalThis.cancelAnimationFrame, GallerySettings:globalThis.GallerySettings}; + Object.assign(globalThis, {ResizeObserver:class { observe() {} disconnect() {} }, requestAnimationFrame:() => 1, cancelAnimationFrame() {}, GallerySettings:gallerySettings}); + f.win.localStorage = {getItem:() => null, setItem:() => assert.fail('read-only preferences')}; + return () => Object.assign(globalThis, globals); +} + +test('viewer uses adapter.mediaUrl for image and video', async () => { + const {localFileAdapter:adapter} = await import('../../web/js/workspace/local-inspection.mjs'); + const {image, video} = await localHandles(); + const f = mountedDom(), restoreGlobals = withViewerGlobals(f), parentScope = createScope(), instances = []; + f.win.Viewer = class { constructor(node) { this.node = node; instances.push(this); } destroy() {} resize() {} }; + const view = module.createMediaViewer({container:f.container,parentScope,inspectionController:borrowedController(),adapter,sender:{open(){}}}); + try { + view.open({items:[image, video],selectedKey:image.key}); + const img = f.walk(f.container,n => n.tagName === 'img')[0]; + assert.equal(img.src, image.url, 'the blob URL is used exactly'); + assert.doesNotMatch(img.src, /\?/); + assert.equal(instances[0].node, img, 'Viewer.js shows the blob image'); + const download = f.walk(f.container,n => n.attributes['aria-label'] === 'Download original')[0]; + assert.equal(download.href, image.url);assert.equal(download.download, 'a.png'); + f.walk(f.container,n => n.attributes['aria-label'] === 'Next media')[0].fire('click'); + const node = f.walk(f.container,n => n.tagName === 'video')[0]; + assert.equal(node.src, video.url);assert.doesNotMatch(node.src, /\?/); + assert.equal(f.walk(f.container,n => n.tagName === 'img').length, 0); + view.dispose(); + assert.equal(node.attributes.src, undefined);assert.equal(node.loaded, true, 'closing releases the video source'); + } finally { view.dispose(); parentScope.dispose(); restoreGlobals(); f.restore(); } +}); + +test('retry media keeps the blob URL exact', async () => { + const {localFileAdapter:adapter} = await import('../../web/js/workspace/local-inspection.mjs'); + const {image} = await localHandles(); + const f = mountedDom(), restoreGlobals = withViewerGlobals(f), parentScope = createScope(); + const view = module.createMediaViewer({container:f.container,parentScope,inspectionController:borrowedController(),adapter,sender:{open(){}}}); + try { + view.open({items:[image],selectedKey:image.key}); + const first = f.walk(f.container,n => n.tagName === 'img')[0]; + assert.equal(first.src, image.url); + first.fire('error'); + const failure = f.walk(f.container,n => n.className === 'media-failure')[0]; + assert.ok(!failure.hidden, 'the media failure shows'); + f.walk(f.container,n => n.tagName === 'button' && n.textContent === 'Retry media')[0].fire('click'); + const second = f.walk(f.container,n => n.tagName === 'img')[0]; + assert.notEqual(second, first, 'retry rebuilds the media element'); + assert.equal(second.src, image.url, 'retry reuses the exact blob URL'); + } finally { view.dispose(); parentScope.dispose(); restoreGlobals(); f.restore(); } +}); + +test('server adapters still cache-bust the original on open and retry', async () => { + const {rootedFileAdapter, createInspectionController} = await import('../../web/js/workspace/inspection-state.mjs'); + const {reportedLocationAdapter} = await import('../../web/js/workspace/history-media.mjs'); + const scope = 'a'.repeat(64); + const rooted = {root_index:0,path:'folder/a.png',filename:'a.png',root_scope:scope,is_video:false,url:`/prompt_manager/images/serve/folder/a.png?root=0&root_scope=${scope}`}; + for (const [adapter, item, key, path] of [[rootedFileAdapter, rooted, '0:folder/a.png', `/prompt_manager/images/inspect/folder/a.png?root=0&root_scope=${scope}`], + [reportedLocationAdapter, occurrenceMedia('1',1,'c'), 'c'.repeat(64), occurrenceMedia('1',1,'c').inspection_url]]) { + const f = mountedDom(), restoreGlobals = withViewerGlobals(f), parentScope = createScope(), paths = []; + const controller = createInspectionController({adapter,api:{get(url) {paths.push(url);return new Promise(() => {});}}}); + const view = module.createMediaViewer({container:f.container,parentScope,inspectionController:controller,adapter,onSelect:entry => controller.select(entry),sender:{open(){}}}); + try { + view.open({items:[item],selectedKey:key}); + assert.deepEqual(paths, [path], 'the same api.get path'); + const first = f.walk(f.container,n => n.tagName === 'img')[0]; + assert.match(first.src, /_pm_viewer=/); + const url = new URL(first.src);url.searchParams.delete('_pm_viewer'); + assert.equal(`${url.pathname}${url.search}`, item.url); + first.fire('error'); + f.walk(f.container,n => n.tagName === 'button' && n.textContent === 'Retry media')[0].fire('click'); + const second = f.walk(f.container,n => n.tagName === 'img')[0]; + assert.match(second.src, /_pm_viewer=/);assert.notEqual(second.src, first.src, 'retry busts the cache again'); + } finally { view.dispose(); controller.dispose(); parentScope.dispose(); restoreGlobals(); f.restore(); } + } +}); diff --git a/tests/js/workspace-state.test.js b/tests/js/workspace-state.test.js index 5706dea..a02129a 100644 --- a/tests/js/workspace-state.test.js +++ b/tests/js/workspace-state.test.js @@ -23,8 +23,21 @@ test('tags is a native route titled Tags; only unbuilt routes hand off to legacy assert.equal(routeTitle(route), route.label); } assert.equal(routeTitle(resolveRoute('#/library/7')), 'Prompt library'); - for (const id of ['images', 'library', 'tags', 'overview', 'activity']) assert.equal(isHandoff(resolveRoute(`#/${id}`)), false, id); - for (const id of ['inspect', 'system']) assert.equal(isHandoff(resolveRoute(`#/${id}`)), true, id); + for (const id of ['images', 'library', 'tags', 'inspect', 'overview', 'activity']) assert.equal(isHandoff(resolveRoute(`#/${id}`)), false, id); + for (const id of ['system']) assert.equal(isHandoff(resolveRoute(`#/${id}`)), true, id); +}); + +test('inspect is the native Inspect a file page with the concept description', async () => { + const { resolveRoute, routeTitle, isHandoff } = await source; + const inspect = resolveRoute('#/inspect'); + assert.equal(inspect.label, 'Inspect a file'); + assert.equal(routeTitle(inspect), 'Inspect a file'); + // The concept's heading description (app.js renderMetadata 743). + assert.equal(inspect.description, 'A standalone file uses the same image viewer and prompt actions.'); + assert.equal(isHandoff(inspect), false); + // Overview keeps its card for the legacy metadata page, which stays working. + assert.equal(inspect.href, '/prompt_manager/gallery.html'); + assert.equal(inspect.action, 'Open existing metadata inspector'); }); test('a job of the Activity page is its own deep link', async () => { 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'); diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index d80511f..6869e83 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -281,6 +281,12 @@ "functions": 98, "statements": 98 }, + "workspace/inspect.mjs": { + "lines": 98, + "branches": 92, + "functions": 92, + "statements": 98 + }, "workspace/inspection-state.mjs": { "lines": 98, "branches": 94, @@ -319,7 +325,7 @@ }, "workspace/local-inspection.mjs": { "lines": 98, - "branches": 89, + "branches": 91, "functions": 98, "statements": 98 }, @@ -329,6 +335,12 @@ "functions": 98, "statements": 97 }, + "workspace/local-session.mjs": { + "lines": 98, + "branches": 92, + "functions": 98, + "statements": 98 + }, "workspace/local-video.mjs": { "lines": 98, "branches": 94, diff --git a/web/js/workspace/file-inspector.mjs b/web/js/workspace/file-inspector.mjs index 13d21b5..11309cb 100644 --- a/web/js/workspace/file-inspector.mjs +++ b/web/js/workspace/file-inspector.mjs @@ -278,14 +278,16 @@ export function mountFileInspector(container, {api, sender, parentScope, variant } return node; } - /** "File & provenance": the current-file facts, the D21 prompt evidence, the occurrence and the saved association. */ + /** "File & provenance": the file facts, the D21 prompt evidence, the occurrence and the saved association (D66 for a local file). */ function provenance(record, badge) { const {file, library} = record, section = disclosure('File & provenance'); - const reported = record.identity.kind === 'reported-location'; - section.append(el('p', reported ? 'Current file at the reported location · identity unverified' : 'Current file at this source; historical bytes are not verified.', 'small')); + const reported = record.identity.kind === 'reported-location', local = record.identity.kind === 'local-file'; + // D66: a file chosen in this browser has no server path, no folder to show and no saved association. + section.append(el('p', reported ? 'Current file at the reported location · identity unverified' : local ? 'Selected local file · read in this browser' : + 'Current file at this source; historical bytes are not verified.', 'small')); const path = el('dd', record.identity.path ?? 'Not available'); const facts = file && definitionList([ - ['Source', file.source_label], ['Path', path], ['Size', formatBytes(file.size)], + ['Source', file.source_label], ...(local ? [] : [['Path', path]]), ['Size', formatBytes(file.size)], ['Dimensions', file.width && file.height ? `${file.width} × ${file.height}` : null], ['Format', file.format || `${extensionOf(record)} extension`], ['Media type', file.media_type], ['Modified', file.modified_time !== null ? new Date(file.modified_time * 1000).toLocaleString() : null], @@ -304,7 +306,7 @@ export function mountFileInspector(container, {api, sender, parentScope, variant ['Recorded run', occurrence.run_id], ['Output node', occurrence.output_node_id], ['Image ordinal', String(occurrence.image_ordinal)], ], 'meta-grid')); } - section.append(el('p', record.historyTarget ? 'Saved association; not verified against current file' : library?.status === 'ambiguous' ? 'Saved association is ambiguous; no unique saved positive prompt is available.' : 'No unique saved positive prompt association is available.', 'small')); + section.append(el('p', local ? 'Local file; no saved library association was checked.' : record.historyTarget ? 'Saved association; not verified against current file' : library?.status === 'ambiguous' ? 'Saved association is ambiguous; no unique saved positive prompt is available.' : 'No unique saved positive prompt association is available.', 'small')); if (record.download) { const original = el('a', 'Download original'); original.href = record.download.url; original.download = safeFilename(record.download.filename); section.append(original); diff --git a/web/js/workspace/index.mjs b/web/js/workspace/index.mjs index 967062c..5e29f04 100644 --- a/web/js/workspace/index.mjs +++ b/web/js/workspace/index.mjs @@ -14,6 +14,8 @@ import { createSavedViews, openSaveViewDialog, openEditViewDialog, savedViewHash import { createActivityStore } from './activity-store.mjs'; import { mountActivityPage, mountActivityTopbar } from './activity.mjs'; import { mountActivityJobView } from './activity-job-view.mjs'; +import { mountInspect } from './inspect.mjs'; +import { createPromptSender } from './prompt-sender.mjs'; const { createApiClient, resolveUrl } = window.ApiClient; @@ -235,6 +237,12 @@ function mountWorkspace() { else mountActivityPage(main, { store: activity, scope: view }); return; } + if (route.id === 'inspect') { + // The concept's Inspect a file (app.js renderMetadata): a local file, read only in this browser. + description.textContent = route.description; + mountInspect(main, { parentScope: view, api, sender: createPromptSender({ scope: view }) }); + return; + } if (isHandoff(route)) { description.textContent = route.description; const panel = element('section', undefined, 'surface handoff'); diff --git a/web/js/workspace/inspect.mjs b/web/js/workspace/inspect.mjs new file mode 100644 index 0000000..cd6f2f7 --- /dev/null +++ b/web/js/workspace/inspect.mjs @@ -0,0 +1,242 @@ +// The concept's "Inspect a file" page (docs/concepts/workspace/app.js renderMetadata 740–748, selection 1715–1729): +// one local file, 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. Nothing is uploaded, imported or stored. The selection, its +// blob: URL and its record belong to one local session (local-session.mjs) that ends with this page. +import { createButton, createScope } from '../shared/toolkit/core.js'; +import { pageHeading } from './heading.mjs'; +import { createWorkspaceIcon } from './icons.mjs'; +import { createLocalSession, LOCAL_SESSION_MESSAGES } from './local-session.mjs'; +import { localFileAdapter } from './local-inspection.mjs'; +import { createMediaViewer } from './media-viewer.mjs'; +import { showWorkspaceToast, TOAST_MS } from './toast.mjs'; + +/** D60/D61 copy (maintainer ruling LI-3). */ +export const INSPECT_COPY = Object.freeze({ + action: 'Open file metadata', + heading: 'Inspect without importing', + intro: 'Read embedded metadata in this browser. The file is not uploaded or added to your library.', + chooser: 'Choose an image or video for a local preview', + note: 'Your file stays in this browser. Its metadata opens with “Open file metadata”.', + toast: 'Local preview only. No file uploaded; metadata is available above.', +}); +// D62: the concept's image list plus the videos the local readers support (AVI/WMV preview only). +export const INSPECT_ACCEPT = 'image/png,image/jpeg,image/webp,video/*,.mp4,.mov,.m4v,.webm,.mkv,.avi,.wmv'; +const REJECTIONS = Object.freeze({ + 'rejected-multiple': LOCAL_SESSION_MESSAGES.multiple, + 'rejected-directory': LOCAL_SESSION_MESSAGES.directory, + 'rejected-empty': LOCAL_SESSION_MESSAGES.unsupported, +}); +// D63: the status line is an alert for anything the person must act on; AVI/WMV's note is plain status. +const ALERTS = new Set([LOCAL_SESSION_MESSAGES.multiple, LOCAL_SESSION_MESSAGES.directory, LOCAL_SESSION_MESSAGES.unsupported, + LOCAL_SESSION_MESSAGES.previewFailed, LOCAL_SESSION_MESSAGES.unreadable]); + +function element(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; +} + +// The status shown for the selection; AVI/WMV's note and an empty status keep the toast's "metadata is available". +const TOASTABLE = new Set(['', LOCAL_SESSION_MESSAGES.containerOnly]); +// How long a released video still starting may take to settle before its deferred reset is dropped. +const DEFERRED_RESET_MS = 15000; + +/** + * Resets a released video once it settles. Removing `src` alone never aborts a load already under way, and WebKit + * (GStreamer) blocked the page when a pipeline still starting was reset, so load() waits for metadata or an error. + * The fallback only drops the waiting listeners (a WebKit load cut off by the revoked URL stalls and never settles), + * so the detached node is never pinned. + */ +function resetWhenSettled(node) { + const waiting = new AbortController(); + const fallback = setTimeout(() => waiting.abort(), DEFERRED_RESET_MS); + const reset = () => { waiting.abort(); clearTimeout(fallback); node.load(); }; + for (const type of ['loadedmetadata', 'error']) node.addEventListener(type, reset, { once: true, signal: waiting.signal }); +} + +/** + * Stops a preview from holding its blob: URL: its own listeners end, media is paused and its src dropped before the + * session revokes the URL, and the node leaves the page. A settled video (metadata read, or failed) is then reset with + * load() to free its decoder at once; one still starting is reset as soon as it settles. + */ +function releaseMedia(preview) { + if (!preview) return; + const { node, listeners } = preview; + listeners.abort(); + if (node instanceof HTMLMediaElement) { + const settled = node.readyState >= HTMLMediaElement.HAVE_METADATA || node.error !== null; + node.pause(); node.removeAttribute('src'); + if (settled) node.load(); + else resetWhenSettled(node); + } else node.removeAttribute('src'); + node.remove(); +} + +/** + * Mounts the page into `main` below the heading the shell rendered. `sender` is the shared prompt sender; `api` is + * only handed to the viewer (the local adapter never calls it). Returns `{dispose}`; the page also ends with + * `parentScope`. + */ +export function mountInspect(main, { parentScope, api, sender }) { + const scope = createScope(); + const dispose = () => scope.dispose(); + if (parentScope.signal.aborted) { scope.dispose(); return { dispose }; } + parentScope.signal.addEventListener('abort', dispose, { once: true }); + scope.cleanup(() => parentScope.signal.removeEventListener('abort', dispose)); + + // The concept DOM: section.surface > h2, p; div.file-drop.spaced-20 > icon, label, input, span.small; #local-preview. + const surface = element('section', 'surface'); + const area = element('div', 'file-drop spaced-20'); + const label = element('label', undefined, INSPECT_COPY.chooser); + label.htmlFor = 'local-file'; + const input = element('input'); + input.id = 'local-file'; input.type = 'file'; input.accept = INSPECT_ACCEPT; + const note = element('span', 'small', INSPECT_COPY.note); + note.id = 'local-file-note'; + area.append(createWorkspaceIcon('upload'), label, input, note); + const preview = element('div'); + preview.id = 'local-preview'; + surface.append(element('h2', undefined, INSPECT_COPY.heading), element('p', undefined, INSPECT_COPY.intro), area, preview); + main.append(surface); + + // /* D60 */ the concept's one primary heading action, unavailable until a classified file is chosen. + const open = createButton(INSPECT_COPY.action, 'ca-primary'); + pageHeading(main)?.actions.append(open); + scope.cleanup(() => open.remove()); + + // The session ends after the viewer has closed and the inline media is gone, so no element holds its URL then. + // The page owns page leave for the same reason: the session listens for no `pagehide` of its own. + const sessionScope = createScope(); + const session = createLocalSession({ parentScope: sessionScope, pageTarget: null }); + const viewer = createMediaViewer({ api, container: main, parentScope: scope, inspectionController: session.controller, sender, + adapter: localFileAdapter, onRestoreFocus: () => { if (open.isConnected) open.focus({ preventScroll: true }); } }); + + let media = null, mediaUrl = null, statusLine = null, rejectionLine = null, rejectionTimer = 0, announced = null, chosen = null; + function statusElement(message) { + // /* D63 */ one status line for the states the mock never shows. + const line = element('p', 'small spaced-16', message); + line.setAttribute('role', ALERTS.has(message) ? 'alert' : 'status'); + return line; + } + function showStatus(message) { + statusLine?.remove(); statusLine = null; + if (!message) return; + statusLine = statusElement(message); + preview.insertBefore(statusLine, rejectionLine); + } + function clearRejection() { + clearTimeout(rejectionTimer); rejectionTimer = 0; + rejectionLine?.remove(); rejectionLine = null; + } + // /* D63 */ a rejected chooser or drop keeps the selection and its status: its alert is a second line that ends + // with the next selection attempt or after the concept's toast interval. + function showRejection(message) { + clearRejection(); + rejectionLine = statusElement(message); + preview.append(rejectionLine); + rejectionTimer = setTimeout(clearRejection, TOAST_MS); + } + scope.cleanup(clearRejection); + /** Keeps the chooser naming the current selection; assigning `files` fires no `change`. */ + function nameChosen() { + if (!chosen) return; + const transfer = new DataTransfer(); + transfer.items.add(chosen); + input.files = transfer.files; + } + function setAvailable(available) { + open.setAttribute('aria-disabled', String(!available)); + if (available) open.removeAttribute('aria-describedby'); + else open.setAttribute('aria-describedby', note.id); + } + function showMedia(state) { + const url = state.preview?.url ?? null; + if (url === mediaUrl) return; + releaseMedia(media); media = null; mediaUrl = url; + if (!url) return; + const video = state.preview.media === 'video'; + const node = element(video ? 'video' : 'img', 'local-file-preview'); + // Each preview owns its listeners, so a released node is never held by the page. + const current = { node, listeners: new AbortController() }; + if (video) { + // /* D62 */ native controls, no inline autoplay. + node.controls = true; node.playsInline = true; node.preload = 'metadata'; node.muted = true; + } else { + node.alt = 'Local file preview'; + } + // /* D65 */ the URL is not revoked on load: the viewer and Download original use it. + // The listener ends with the node's release, so it only fires for the current preview. + node.addEventListener('error', () => { + releaseMedia(current); media = null; + showStatus(LOCAL_SESSION_MESSAGES.previewFailed); + }, { once: true, signal: current.listeners.signal }); + node.src = url; + media = current; + preview.prepend(node); + } + scope.cleanup(session.subscribe(state => { + showMedia(state); + showStatus(state.message); + const entry = session.entry; + setAvailable(state.phase === 'ready' && !!entry); + if (state.phase === 'ready' && entry && announced !== entry.key) { + announced = entry.key; + // An unreadable or failed preview says so in its alert; the toast never claims metadata is available. + if (TOASTABLE.has(state.message)) showWorkspaceToast(INSPECT_COPY.toast); + } + })); + // A replacement or page leave revokes the URL after this subscriber dropped the media; a dispose closes the + // viewer (on abort), drops the media, then ends the session. + scope.cleanup(() => viewer.dispose()); + scope.cleanup(() => { releaseMedia(media); media = null; mediaUrl = null; }); + scope.cleanup(() => sessionScope.dispose()); + + // Page leave: the viewer closes and the inline media goes before the session revokes the URL, and the chooser is + // cleared, so a page restored from the back-forward cache never names a file beside the empty selection. + function leave() { + clearRejection(); + viewer.close(); + releaseMedia(media); media = null; mediaUrl = null; + session.clear(); + chosen = null; input.value = ''; + } + scope.listen(window, 'pagehide', leave); + + function choose(source, file) { + clearRejection(); + const outcome = session.select(source); + if (outcome === 'accepted') chosen = file; + if (REJECTIONS[outcome]) showRejection(REJECTIONS[outcome]); + return outcome; + } + // The chooser keeps showing the chosen name, as in the concept; it is cleared as the picker opens, so choosing + // the same file again is a new selection, and named again when the picker is cancelled. + scope.listen(input, 'click', () => { clearRejection(); input.value = ''; }); + scope.listen(input, 'cancel', () => nameChosen()); + scope.listen(input, 'change', () => { + // A cancelled chooser carries no files: the page stays as it is. + if (!input.files?.length) { choose(null); nameChosen(); return; } + choose(input.files, input.files[0]); + }); + // /* D64 */ drop on the area, with no visual change; only `dataTransfer.files` is read, never its strings. + scope.listen(area, 'dragover', event => { + event.preventDefault(); + if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'; + }); + scope.listen(area, 'drop', event => { + event.preventDefault(); + if (document.querySelector('dialog[open]')) return; + const transfer = event.dataTransfer; + // A dropped file replaces the chosen one, so the chooser names the dropped file, never the old one. + if (choose(transfer ? { files: transfer.files, items: transfer.items } : null, transfer?.files?.[0]) === 'accepted') nameChosen(); + }); + // A drop that misses the area never navigates away from the page while it is mounted. + for (const type of ['dragover', 'drop']) scope.listen(window, type, event => event.preventDefault()); + scope.listen(open, 'click', () => { + const entry = session.entry; + if (open.getAttribute('aria-disabled') === 'true' || !entry || session.state.phase !== 'ready') return; + viewer.open({ items: [entry], selectedKey: entry.key, trigger: open }); + }); + return { dispose }; +} diff --git a/web/js/workspace/inspection-state.mjs b/web/js/workspace/inspection-state.mjs index 9b27a42..290797b 100644 --- a/web/js/workspace/inspection-state.mjs +++ b/web/js/workspace/inspection-state.mjs @@ -324,7 +324,9 @@ export function createInspectionLoader(api, onChange, { extractFacts, adapter = const current = request = new AbortController(); if (!quiet) onChange({ loading: true }); try { - const result = await api.get(adapter.inspectionPath(image), { signal: current.signal }); + // An adapter that reads by itself (a file chosen in this browser) never reaches the API. + const result = adapter.read ? await adapter.read(image, { signal: current.signal }) : + await api.get(adapter.inspectionPath(image), { signal: current.signal }); if (disposed || request !== current) return; const record = adapter.toRecord(result, image, extractFacts); onChange({ prompts: record.prompts, record }); diff --git a/web/js/workspace/local-inspection.mjs b/web/js/workspace/local-inspection.mjs index 8b5c034..3ccae32 100644 --- a/web/js/workspace/local-inspection.mjs +++ b/web/js/workspace/local-inspection.mjs @@ -135,6 +135,14 @@ export function createLocalFileHandle({ file, url = null, classification, filena return Object.freeze({ kind: 'local-file', key: secret.key, filename: secret.filename, is_video: classified.media === 'video', url, file, classification: classified, [TOKEN]: token }); } +/** + * Ends a selection: the handle and every copy of it become foreign, so a replaced + * or released file can no longer be read, downloaded or shown. + */ +export function releaseLocalFileHandle(handle) { + if (object(handle) && handle[TOKEN]) OWNED.delete(handle[TOKEN]); +} + /** The owned selection behind a handle (or a copy of one); throws for anything else. */ function owned(handle) { const secret = object(handle) ? OWNED.get(handle[TOKEN]) : undefined; @@ -160,7 +168,14 @@ export const localFileAdapter = Object.freeze({ inspectionPath(handle) { return `local-file:${owned(handle).key}`; }, async read(handle, { signal } = {}) { const secret = owned(handle); - const { result } = await readLocalFile(secret.file, { classification: secret.classification, signal }); + let result = null; + try { + ({ result } = await readLocalFile(secret.file, { classification: secret.classification, signal })); + } catch (error) { + // The browser could not read the bytes (removed, moved or denied after choosing): the server's + // MetadataReadError, an unreadable record. Abort and programming errors still reject. + if (!(error instanceof DOMException) || error.name === 'AbortError' || signal?.aborted) throw error; + } return fitLocalPayload(buildLocalPayload({ read: result, file: { name: secret.filename, size: secret.file.size, lastModified: secret.file.lastModified }, classification: secret.classification }), MAX_RESPONSE_BYTES, { signal }); }, toRecord(result, handle, extractFacts) { diff --git a/web/js/workspace/local-session.mjs b/web/js/workspace/local-session.mjs new file mode 100644 index 0000000..0765ff6 --- /dev/null +++ b/web/js/workspace/local-session.mjs @@ -0,0 +1,180 @@ +// One local selection at a time, owned end to end: the chosen File is classified +// by its bytes, a blob: URL is created only for a classified file, the inspection +// controller reads it through localFileAdapter, and replacement, dispose, parent +// abort and page leave each release the URL, the handle and the record exactly once. +import { createInspectionController } from './inspection-state.mjs'; +import { createLocalFileHandle, localFileAdapter, releaseLocalFileHandle } from './local-inspection.mjs'; + +const PREFIX_BYTES = 32; +// Kinds whose metadata the server never reads (AVI, ASF/WMV): preview and original only. +const CONTAINER_ONLY = new Set(['avi', 'asf']); +/** D63 status copy; the page shows `multiple` and `directory` for the matching select() outcomes. */ +export const LOCAL_SESSION_MESSAGES = Object.freeze({ + multiple: 'Drop one file at a time.', + directory: 'Folders cannot be inspected. Choose one image or video file.', + unsupported: 'This file type cannot be previewed or inspected here. Choose an image or video file.', + previewFailed: 'The preview could not be displayed. Metadata and the original remain available in the viewer.', + containerOnly: 'Metadata in AVI and WMV files is not read here. The preview and the original remain available.', + // A file that could not be read after choosing: the shared inspector's own unreadable copy (file-inspector.mjs). + unreadable: 'Embedded metadata could not be read.', +}); + +// The browser's declared type for a file whose bytes could not be read, mapped to the kind its bytes would have +// classified as. Only a supported declared type opens the unreadable record; anything else stays unsupported. +const DECLARED_KINDS = Object.freeze({ + 'image/png': ['image', 'png'], 'image/jpeg': ['image', 'jpeg'], 'image/webp': ['image', 'webp'], + 'video/mp4': ['video', 'iso-bmff'], 'video/quicktime': ['video', 'iso-bmff'], 'video/x-m4v': ['video', 'iso-bmff'], + 'video/webm': ['video', 'matroska'], 'video/x-matroska': ['video', 'matroska'], + 'video/x-msvideo': ['video', 'avi'], 'video/avi': ['video', 'avi'], 'video/x-ms-wmv': ['video', 'asf'], 'video/x-ms-asf': ['video', 'asf'], +}); +function declaredClassification(file) { + const kind = DECLARED_KINDS[String(file?.type || '').toLowerCase()]; + return kind ? Object.freeze({ media: kind[0], kind: kind[1] }) : null; +} +const readFailure = error => error instanceof DOMException && error.name !== 'AbortError'; + +const abortError = () => new DOMException('The local selection was replaced', 'AbortError'); +const defaultSchedulerYield = () => (typeof globalThis.scheduler?.yield === 'function' ? () => globalThis.scheduler.yield() : null); +const pngReader = () => globalThis.PngMetadata || globalThis.window?.PngMetadata; +const EMPTY = Object.freeze({ phase: 'empty', preview: null, message: '' }); + +/** + * `yieldControl(signal)`: one turn of the event loop. It prefers `scheduler.yield`; + * otherwise a zero timer that, on abort, is cleared and rejects with AbortError and + * removes its abort listener. Rejects at once for an aborted signal. + */ +export function createYield({ schedulerYield = defaultSchedulerYield(), setTimer = setTimeout, clearTimer = clearTimeout } = {}) { + return function yieldControl(signal) { + if (signal?.aborted) return Promise.reject(abortError()); + if (typeof schedulerYield === 'function') { + return Promise.resolve(schedulerYield()).then(() => { if (signal?.aborted) throw abortError(); }); + } + return new Promise((resolve, reject) => { + const onAbort = () => { clearTimer(timer); reject(abortError()); }; + const timer = setTimer(() => { signal?.removeEventListener('abort', onAbort); resolve(); }, 0); + signal?.addEventListener('abort', onAbort, { once: true }); + }); + }; +} + +/** The one File a chooser or drop carries, or the select() outcome that keeps the previous selection. */ +function chosenFile(input) { + if (input === null || input === undefined) return { outcome: 'ignored' }; + const items = input.items ? [...input.items].filter(item => item?.kind === 'file') : null; + const files = [...(input.files ?? input)].filter(file => file instanceof Blob); + const count = Math.max(files.length, items?.length ?? 0); + if (count === 0) return { outcome: 'rejected-empty' }; + if (count > 1) return { outcome: 'rejected-multiple' }; + if (items?.some(item => item.webkitGetAsEntry?.()?.isDirectory)) return { outcome: 'rejected-directory' }; + return files.length === 1 ? { file: files[0] } : { outcome: 'rejected-empty' }; +} + +export function createLocalSession({ + parentScope, createObjectURL = file => URL.createObjectURL(file), revokeObjectURL = url => URL.revokeObjectURL(url), + schedulerYield = defaultSchedulerYield(), setTimer = setTimeout, clearTimer = clearTimeout, + pageTarget = globalThis.window, extractFacts, pngMetadata, +} = {}) { + const controller = createInspectionController({ api: null, adapter: localFileAdapter, extractFacts }); + const listeners = new Set(); + let state = EMPTY, handle = null, url = null, operation = null, generation = 0, disposed = false; + const yieldControl = createYield({ schedulerYield, setTimer, clearTimer }); + + function publish(update) { + if (disposed) return; + state = Object.freeze({ ...EMPTY, ...update }); + for (const listener of [...listeners]) listener(state); + } + /** Ends the current selection; idempotent. Subscribers drop the preview before its URL is revoked. */ + function release(next) { + generation++; + operation?.abort(); operation = null; + if (handle) { releaseLocalFileHandle(handle); handle = null; } + controller.clear(); + const previous = url; url = null; + if (next) publish(next); + if (previous) revokeObjectURL(previous); + } + const live = (token, signal) => !disposed && token === generation && !signal.aborted; + + async function classify(file, token, signal) { + try { + await yieldControl(signal); + if (!live(token, signal)) return; + let prefix; + try { + prefix = new Uint8Array(await file.slice(0, Math.min(PREFIX_BYTES, file.size)).arrayBuffer()); + } catch (error) { + if (!live(token, signal) || !readFailure(error)) throw error; + const declared = declaredClassification(file); + if (!declared) { publish({ phase: 'unsupported', message: LOCAL_SESSION_MESSAGES.unsupported }); return; } + // Unreadable after choosing: no preview and no URL; the record is the inspector's unreadable state. + handle = createLocalFileHandle({ file, url: null, classification: declared }); + publish({ phase: 'ready', preview: null, message: LOCAL_SESSION_MESSAGES.unreadable }); + controller.select(handle); + return; + } + if (!live(token, signal)) return; + const reader = pngMetadata ?? pngReader(); + const classification = prefix.length ? reader.classifyPrefix(prefix) : null; + if (!classification) { publish({ phase: 'unsupported', message: LOCAL_SESSION_MESSAGES.unsupported }); return; } + // The URL exists only for a classified file, and is never revoked on load: the viewer and Download original use it. + let created = null; + try { created = createObjectURL(file); } catch { created = null; } + url = typeof created === 'string' && created.startsWith('blob:') ? created : null; + if (created && !url) revokeObjectURL(created); + handle = createLocalFileHandle({ file, url, classification }); + const message = !url ? LOCAL_SESSION_MESSAGES.previewFailed : CONTAINER_ONLY.has(classification.kind) ? LOCAL_SESSION_MESSAGES.containerOnly : ''; + publish({ phase: 'ready', preview: url ? Object.freeze({ url, media: classification.media }) : null, message }); + controller.select(handle); + } catch (error) { + if (!live(token, signal) || error?.name === 'AbortError') return; + publish({ phase: 'unsupported', message: LOCAL_SESSION_MESSAGES.unsupported }); + } + } + + function dispose() { + if (disposed) return; + release(EMPTY); + disposed = true; + listeners.clear(); + controller.dispose(); + pageTarget?.removeEventListener?.('pagehide', leave); + parentScope?.signal?.removeEventListener('abort', dispose); + } + const leave = () => { if (!disposed && (handle || url || operation || state.phase !== 'empty')) release(EMPTY); }; + + if (parentScope?.signal?.aborted) { + disposed = true; + controller.dispose(); + } else { + parentScope?.signal?.addEventListener('abort', dispose, { once: true }); + pageTarget?.addEventListener?.('pagehide', leave); + } + + return { + get state() { return state; }, + get entry() { return handle; }, + controller, + select(input) { + if (disposed) return 'ignored'; + const { file, outcome } = chosenFile(input); + if (!file) return outcome; + release({ phase: 'classifying' }); + const current = operation = new AbortController(); + classify(file, generation, current.signal); + return 'accepted'; + }, + /** Ends the current selection as page leave does; the session stays usable. */ + clear: leave, + retry() { + if (disposed || !handle) return undefined; + return controller.retry(); + }, + subscribe(listener) { + if (disposed) return () => {}; + listeners.add(listener); listener(state); + return () => listeners.delete(listener); + }, + dispose, + }; +} diff --git a/web/js/workspace/media-viewer.mjs b/web/js/workspace/media-viewer.mjs index 4e8fc26..4427dd6 100644 --- a/web/js/workspace/media-viewer.mjs +++ b/web/js/workspace/media-viewer.mjs @@ -24,7 +24,9 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle let active = null, retryVersion = 0; const dispose = () => scope.dispose(); const stopSlideshow = () => active?.stop(); - if (parentScope.signal.aborted) { scope.dispose(); return {open() {},dispose,stopSlideshow}; } + // Closes an open viewer without restoring focus (page leave); the viewer stays usable. + const close = () => active?.close(false); + if (parentScope.signal.aborted) { scope.dispose(); return {open() {},close,dispose,stopSlideshow}; } parentScope.signal.addEventListener('abort',dispose,{once:true}); scope.cleanup(() => parentScope.signal.removeEventListener('abort',dispose)); scope.cleanup(() => active?.close(false)); @@ -115,11 +117,18 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle failure.hidden = true; const mediaLocal = mediaScope, key = session.state.key; const current = () => !local.signal.aborted && !mediaLocal.signal.aborted && session.state.key === key; - let original; - try { original = adapter.originalDownload(selected); } + // An adapter's own media URL (a session-owned blob:) is used exactly, so a retry reuses it; a server + // original gets a fresh cache-buster on every build. + let original, src; + try { if (adapter.mediaUrl) src = adapter.mediaUrl(selected); else original = adapter.originalDownload(selected); } catch { session.failed(key);return; } - const url = new URL(original.url,window.location.href); - url.searchParams.set('_pm_viewer',`${Date.now()}-${++retryVersion}`); + if (adapter.mediaUrl) { + if (typeof src !== 'string' || !src) { session.failed(key);return; } + } else { + const url = new URL(original.url,window.location.href); + url.searchParams.set('_pm_viewer',`${Date.now()}-${++retryVersion}`); + src = url.href; + } const node = element(selected.is_video ? 'video' : 'img'); mediaLocal.listen(node,'error',() => {if (current()) session.failed(key);}); if (selected.is_video) { @@ -140,7 +149,7 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle mediaLocal.cleanup(() => node.removeAttribute('src')); } } - node.src = url.href;stage.append(node); + node.src = src;stage.append(node); if (!selected.is_video && window.Viewer) { let fitFrame = 0, fitWidth, fitHeight, viewSettled = false, released = false, fallback = 0; const release = () => { @@ -233,5 +242,5 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle if (state.record?.identity.key === currentKey && state.record.library?.title) title.textContent = `${state.record.library.title} · ${entry.filename}`; })); } - return {open,dispose,stopSlideshow}; + return {open,close,dispose,stopSlideshow}; } diff --git a/web/js/workspace/state.mjs b/web/js/workspace/state.mjs index b0a3a52..7957f4d 100644 --- a/web/js/workspace/state.mjs +++ b/web/js/workspace/state.mjs @@ -6,8 +6,9 @@ export const routes = [ // Native screen; `title` is the concept's breadcrumb/title, the href stays for Overview's card. { id: 'tags', label: 'Tags & categories', title: 'Tags', href: '/prompt_manager/admin#/tags', description: 'Explore your vocabulary and find the prompts associated with each tag.', action: 'Open existing tag manager' }, + // Native screen (#249 local Inspect); the href stays for Overview's card to the legacy metadata page. { id: 'inspect', label: 'Inspect a file', href: '/prompt_manager/gallery.html', - description: 'Inspect generation metadata from an image or video file.', action: 'Open existing metadata inspector' }, + description: 'A standalone file uses the same image viewer and prompt actions.', action: 'Open existing metadata inspector' }, { id: 'overview', label: 'Overview' }, // SAVED VIEWS' first row (#249 D51): Images filtered to favorites. No href, so Overview lists no card for it. { id: 'favorites', label: 'Favorites' }, @@ -17,7 +18,7 @@ export const routes = [ description: 'Open the current dashboard, then choose Settings or Maintenance for configuration, scans, backups and diagnostics.', action: 'Open existing administration' }, ]; -const NATIVE_ROUTES = new Set(['images', 'library', 'tags', 'overview', 'saved', 'favorites', 'activity']); +const NATIVE_ROUTES = new Set(['images', 'library', 'tags', 'inspect', 'overview', 'saved', 'favorites', 'activity']); /** A saved view (#249 D16): the shell resolves its id to the view's page, name and state. */ const SAVED_ROUTE = Object.freeze({ id: 'saved', label: 'Saved view' }); diff --git a/web/workspace.html b/web/workspace.html index 03bd56f..e912c2c 100644 --- a/web/workspace.html +++ b/web/workspace.html @@ -12,6 +12,7 @@ <script src="lib/viewerjs/viewer.min.js" defer></script> <script src="js/comfy-metadata.js" defer></script> <script src="js/gallery-settings.js" defer></script> + <script src="js/png-metadata.js" defer></script> <script type="module" src="js/workspace/index.mjs"></script> </head> <body class="ca-tools">