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('
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 "