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 16d5a17..a96d8d3 100644 --- a/tests/js/workspace-file-inspector.test.js +++ b/tests/js/workspace-file-inspector.test.js @@ -86,3 +86,97 @@ test('Show in folder outcomes use the D38 toasts',async()=>{ for(const error of [{status:403,code:null},{status:409,code:'source-changed'},{status:415,code:'unsupported-media-type'},{status:429,code:null},{status:500},new TypeError('offline')]) 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'); + 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, ''); +}); diff --git a/tests/js/workspace-inspection-state.test.js b/tests/js/workspace-inspection-state.test.js index 6fca178..2b863be 100644 --- a/tests/js/workspace-inspection-state.test.js +++ b/tests/js/workspace-inspection-state.test.js @@ -702,3 +702,72 @@ test('a workflow the legacy parser throws on keeps the server result instead of assert.deepEqual(record.prompts.positive, { status: 'resolved', text: 'exact\npositive', source: 'execution' }); 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'); +}); 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-media-viewer.test.js b/tests/js/workspace-media-viewer.test.js index 6fe3d91..e57be3a 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; @@ -33,44 +34,6 @@ test('preferences handle denied malformed storage without mutation', () => { assert.deepEqual(module.readViewerPreferences(()=>storage,normalize),defaults); }); -function mountedDom() { - class Node { - constructor(tag) {this.tagName=tag;this.children=[];this.dataset={};this.attributes={};this.listeners=new Map();this._text='';this.classList={add(){},remove(){}};} - get textContent(){return this._text+this.children.map(c=>c.textContent).join('');} - set textContent(value){this._text=String(value);this.children=[];} - get childNodes(){return this.children;} - get isConnected(){return this.root || !!this.parent?.isConnected;} - // As in the DOM, a string argument becomes a text node. - static wrap(node){if(typeof node!=='string')return node;const text=new Node('#text');text.textContent=node;return text;} - append(...nodes){for(const node of nodes.map(Node.wrap)){node.parent=this;this.children.push(node);}} - prepend(...nodes){nodes=nodes.map(Node.wrap);for(const node of nodes)node.parent=this;this.children.unshift(...nodes);} - replaceChildren(...nodes){for(const child of this.children)child.parent=null;this.children=[];this._text='';this.append(...nodes);} - remove(){if(this.parent)this.parent.children=this.parent.children.filter(c=>c!==this);this.parent=null;} - setAttribute(key,value){this.attributes[key]=String(value);} - getAttribute(key){return this.attributes[key]??null;} - removeAttribute(key){delete this.attributes[key];} - addEventListener(type,callback,options={}){const list=this.listeners.get(type)||[];list.push({callback,signal:options.signal});this.listeners.set(type,list);} - removeEventListener(type,callback){this.listeners.set(type,(this.listeners.get(type)||[]).filter(e=>e.callback!==callback));} - fire(type,event={}){for(const e of [...(this.listeners.get(type)||[])])if(!e.signal?.aborted)e.callback({target:this,preventDefault(){},...event});} - focus(){this.focused=true;} - showModal(){this.open=true;} - close(){this.open=false;this.fire('close');} - contains(node){return node===this||this.children.some(c=>c.contains(node));} - closest(selector){if(selector==='section'&&this.tagName==='section')return this;if(selector==='[data-send-role]'&&this.dataset.sendRole)return this;if(selector==='[data-copy-role]'&&this.dataset.copyRole)return this;return this.parent?.closest(selector)||null;} - querySelector(selector){return walk(this,n=>n.tagName===selector)[0]||null;} - pause(){this.paused=true;} - load(){this.loaded=true;} - play(){this.played=true;return Promise.resolve();} - } - const walk=(node,predicate)=>[...(predicate(node)?[node]:[]),...node.children.flatMap(child=>walk(child,predicate))]; - const oldDocument=globalThis.document,oldWindow=globalThis.window; - const container=new Node('main');container.root=true; - const doc=new Node('document');doc.createElement=tag=>new Node(tag);doc.createElementNS=(_,tag)=>new Node(tag); - doc.querySelectorAll=()=>walk(container,n=>n.tagName==='dialog'&&n.open);doc.activeElement=new Node('button');doc.activeElement.root=true; - const win=new Node('window');win.location={href:'http://localhost/prompt_manager/workspace'};win.getSelection=()=>({isCollapsed:true}); - globalThis.document=doc;globalThis.window=win; - return {container,doc,win,walk,Node,restore(){globalThis.document=oldDocument;globalThis.window=oldWindow;}}; -} function occurrenceMedia(node,ordinal,key) { const run='22222222-2222-2222-2222-222222222222',epoch='11111111-1111-1111-1111-111111111111',scope='a'.repeat(64); const query=new URLSearchParams({node,ordinal:String(ordinal),epoch,current_scope:scope}),base=`/prompt_manager/history/runs/${run}/output/current`; @@ -247,3 +210,84 @@ test('viewer titles a linked card " · <filename>" before any inspection assert.equal(g.walk(g.container,n=>n.id==='viewer-title')[0].textContent,unlinked.filename); } finally { other.dispose(); scope.dispose(); g.restore(); } }); + +// PR L2 Task 4(d): an adapter's own media URL (a session-owned blob:) is used exactly, never cache-busted. +async function localHandles() { + const {createLocalFileHandle} = await import('../../web/js/workspace/local-inspection.mjs'); + const image = createLocalFileHandle({file:new File([new Uint8Array(8)], 'a.png'), url:'blob:http://localhost/aaaaaaaa-0000-0000-0000-000000000001', classification:{media:'image', kind:'png'}}); + const video = createLocalFileHandle({file:new File([new Uint8Array(8)], 'b.mp4'), url:'blob:http://localhost/aaaaaaaa-0000-0000-0000-000000000002', classification:{media:'video', kind:'iso-bmff'}}); + return {image, video}; +} +function withViewerGlobals(f) { + const globals = {ResizeObserver:globalThis.ResizeObserver, requestAnimationFrame:globalThis.requestAnimationFrame, cancelAnimationFrame:globalThis.cancelAnimationFrame, GallerySettings:globalThis.GallerySettings}; + Object.assign(globalThis, {ResizeObserver:class { observe() {} disconnect() {} }, requestAnimationFrame:() => 1, cancelAnimationFrame() {}, GallerySettings:gallerySettings}); + f.win.localStorage = {getItem:() => null, setItem:() => assert.fail('read-only preferences')}; + return () => Object.assign(globalThis, globals); +} + +test('viewer uses adapter.mediaUrl for image and video', async () => { + const {localFileAdapter:adapter} = await import('../../web/js/workspace/local-inspection.mjs'); + const {image, video} = await localHandles(); + const f = mountedDom(), restoreGlobals = withViewerGlobals(f), parentScope = createScope(), instances = []; + f.win.Viewer = class { constructor(node) { this.node = node; instances.push(this); } destroy() {} resize() {} }; + const view = module.createMediaViewer({container:f.container,parentScope,inspectionController:borrowedController(),adapter,sender:{open(){}}}); + try { + view.open({items:[image, video],selectedKey:image.key}); + const img = f.walk(f.container,n => n.tagName === 'img')[0]; + assert.equal(img.src, image.url, 'the blob URL is used exactly'); + assert.doesNotMatch(img.src, /\?/); + assert.equal(instances[0].node, img, 'Viewer.js shows the blob image'); + const download = f.walk(f.container,n => n.attributes['aria-label'] === 'Download original')[0]; + assert.equal(download.href, image.url);assert.equal(download.download, 'a.png'); + f.walk(f.container,n => n.attributes['aria-label'] === 'Next media')[0].fire('click'); + const node = f.walk(f.container,n => n.tagName === 'video')[0]; + assert.equal(node.src, video.url);assert.doesNotMatch(node.src, /\?/); + assert.equal(f.walk(f.container,n => n.tagName === 'img').length, 0); + view.dispose(); + assert.equal(node.attributes.src, undefined);assert.equal(node.loaded, true, 'closing releases the video source'); + } finally { view.dispose(); parentScope.dispose(); restoreGlobals(); f.restore(); } +}); + +test('retry media keeps the blob URL exact', async () => { + const {localFileAdapter:adapter} = await import('../../web/js/workspace/local-inspection.mjs'); + const {image} = await localHandles(); + const f = mountedDom(), restoreGlobals = withViewerGlobals(f), parentScope = createScope(); + const view = module.createMediaViewer({container:f.container,parentScope,inspectionController:borrowedController(),adapter,sender:{open(){}}}); + try { + view.open({items:[image],selectedKey:image.key}); + const first = f.walk(f.container,n => n.tagName === 'img')[0]; + assert.equal(first.src, image.url); + first.fire('error'); + const failure = f.walk(f.container,n => n.className === 'media-failure')[0]; + assert.ok(!failure.hidden, 'the media failure shows'); + f.walk(f.container,n => n.tagName === 'button' && n.textContent === 'Retry media')[0].fire('click'); + const second = f.walk(f.container,n => n.tagName === 'img')[0]; + assert.notEqual(second, first, 'retry rebuilds the media element'); + assert.equal(second.src, image.url, 'retry reuses the exact blob URL'); + } finally { view.dispose(); parentScope.dispose(); restoreGlobals(); f.restore(); } +}); + +test('server adapters still cache-bust the original on open and retry', async () => { + const {rootedFileAdapter, createInspectionController} = await import('../../web/js/workspace/inspection-state.mjs'); + const {reportedLocationAdapter} = await import('../../web/js/workspace/history-media.mjs'); + const scope = 'a'.repeat(64); + const rooted = {root_index:0,path:'folder/a.png',filename:'a.png',root_scope:scope,is_video:false,url:`/prompt_manager/images/serve/folder/a.png?root=0&root_scope=${scope}`}; + for (const [adapter, item, key, path] of [[rootedFileAdapter, rooted, '0:folder/a.png', `/prompt_manager/images/inspect/folder/a.png?root=0&root_scope=${scope}`], + [reportedLocationAdapter, occurrenceMedia('1',1,'c'), 'c'.repeat(64), occurrenceMedia('1',1,'c').inspection_url]]) { + const f = mountedDom(), restoreGlobals = withViewerGlobals(f), parentScope = createScope(), paths = []; + const controller = createInspectionController({adapter,api:{get(url) {paths.push(url);return new Promise(() => {});}}}); + const view = module.createMediaViewer({container:f.container,parentScope,inspectionController:controller,adapter,onSelect:entry => controller.select(entry),sender:{open(){}}}); + try { + view.open({items:[item],selectedKey:key}); + assert.deepEqual(paths, [path], 'the same api.get path'); + const first = f.walk(f.container,n => n.tagName === 'img')[0]; + assert.match(first.src, /_pm_viewer=/); + const url = new URL(first.src);url.searchParams.delete('_pm_viewer'); + assert.equal(`${url.pathname}${url.search}`, item.url); + first.fire('error'); + f.walk(f.container,n => n.tagName === 'button' && n.textContent === 'Retry media')[0].fire('click'); + const second = f.walk(f.container,n => n.tagName === 'img')[0]; + assert.match(second.src, /_pm_viewer=/);assert.notEqual(second.src, first.src, 'retry busts the cache again'); + } finally { view.dispose(); controller.dispose(); parentScope.dispose(); restoreGlobals(); f.restore(); } + } +}); diff --git a/web/js/workspace/file-inspector.mjs b/web/js/workspace/file-inspector.mjs index b7b592d..b4dbaca 100644 --- a/web/js/workspace/file-inspector.mjs +++ b/web/js/workspace/file-inspector.mjs @@ -258,14 +258,16 @@ export function mountFileInspector(container, {api, sender, parentScope, variant } return node; } - /** "File & provenance": the current-file facts, the D21 prompt evidence, the occurrence and the saved association. */ + /** "File & provenance": the file facts, the D21 prompt evidence, the occurrence and the saved association (D66 for a local file). */ function provenance(record, badge) { const {file, library} = record, section = disclosure('File & provenance'); - const reported = record.identity.kind === 'reported-location'; - section.append(el('p', reported ? 'Current file at the reported location · identity unverified' : 'Current file at this source; historical bytes are not verified.', 'small')); + const reported = record.identity.kind === 'reported-location', local = record.identity.kind === 'local-file'; + // D66: a file chosen in this browser has no server path, no folder to show and no saved association. + section.append(el('p', reported ? 'Current file at the reported location · identity unverified' : local ? 'Selected local file · read in this browser' : + 'Current file at this source; historical bytes are not verified.', 'small')); const path = el('dd', record.identity.path ?? 'Not available'); const facts = file && definitionList([ - ['Source', file.source_label], ['Path', path], ['Size', formatBytes(file.size)], + ['Source', file.source_label], ...(local ? [] : [['Path', path]]), ['Size', formatBytes(file.size)], ['Dimensions', file.width && file.height ? `${file.width} × ${file.height}` : null], ['Format', file.format || `${extensionOf(record)} extension`], ['Media type', file.media_type], ['Modified', file.modified_time !== null ? new Date(file.modified_time * 1000).toLocaleString() : null], @@ -284,7 +286,7 @@ export function mountFileInspector(container, {api, sender, parentScope, variant ['Recorded run', occurrence.run_id], ['Output node', occurrence.output_node_id], ['Image ordinal', String(occurrence.image_ordinal)], ], 'meta-grid')); } - section.append(el('p', record.historyTarget ? 'Saved association; not verified against current file' : library?.status === 'ambiguous' ? 'Saved association is ambiguous; no unique saved positive prompt is available.' : 'No unique saved positive prompt association is available.', 'small')); + section.append(el('p', local ? 'Local file; no saved library association was checked.' : record.historyTarget ? 'Saved association; not verified against current file' : library?.status === 'ambiguous' ? 'Saved association is ambiguous; no unique saved positive prompt is available.' : 'No unique saved positive prompt association is available.', 'small')); if (record.download) { const original = el('a', 'Download original'); original.href = record.download.url; original.download = safeFilename(record.download.filename); section.append(original); diff --git a/web/js/workspace/inspection-state.mjs b/web/js/workspace/inspection-state.mjs index bfb61ed..2b3d94e 100644 --- a/web/js/workspace/inspection-state.mjs +++ b/web/js/workspace/inspection-state.mjs @@ -308,7 +308,9 @@ export function createInspectionLoader(api, onChange, { extractFacts, adapter = const current = request = new AbortController(); onChange({ loading: true }); try { - const result = await api.get(adapter.inspectionPath(image), { signal: current.signal }); + // An adapter that reads by itself (a file chosen in this browser) never reaches the API. + const result = adapter.read ? await adapter.read(image, { signal: current.signal }) : + await api.get(adapter.inspectionPath(image), { signal: current.signal }); if (disposed || request !== current) return; const record = adapter.toRecord(result, image, extractFacts); onChange({ prompts: record.prompts, record }); diff --git a/web/js/workspace/media-viewer.mjs b/web/js/workspace/media-viewer.mjs index 4e8fc26..f3aa028 100644 --- a/web/js/workspace/media-viewer.mjs +++ b/web/js/workspace/media-viewer.mjs @@ -115,11 +115,18 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle failure.hidden = true; const mediaLocal = mediaScope, key = session.state.key; const current = () => !local.signal.aborted && !mediaLocal.signal.aborted && session.state.key === key; - let original; - try { original = adapter.originalDownload(selected); } + // An adapter's own media URL (a session-owned blob:) is used exactly, so a retry reuses it; a server + // original gets a fresh cache-buster on every build. + let original, src; + try { if (adapter.mediaUrl) src = adapter.mediaUrl(selected); else original = adapter.originalDownload(selected); } catch { session.failed(key);return; } - const url = new URL(original.url,window.location.href); - url.searchParams.set('_pm_viewer',`${Date.now()}-${++retryVersion}`); + if (adapter.mediaUrl) { + if (typeof src !== 'string' || !src) { session.failed(key);return; } + } else { + const url = new URL(original.url,window.location.href); + url.searchParams.set('_pm_viewer',`${Date.now()}-${++retryVersion}`); + src = url.href; + } const node = element(selected.is_video ? 'video' : 'img'); mediaLocal.listen(node,'error',() => {if (current()) session.failed(key);}); if (selected.is_video) { @@ -140,7 +147,7 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle mediaLocal.cleanup(() => node.removeAttribute('src')); } } - node.src = url.href;stage.append(node); + node.src = src;stage.append(node); if (!selected.is_video && window.Viewer) { let fitFrame = 0, fitWidth, fitHeight, viewSettled = false, released = false, fallback = 0; const release = () => {