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 "