import test from 'node:test'; import assert from 'node:assert/strict'; import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); const { extractGenerationFacts } = require('../../web/js/comfy-metadata.js'); const { ApiError } = require('../../web/js/api-client.js'); const module = await import('../../web/js/workspace/inspection-state.mjs').catch(error => { if (error.code !== 'ERR_MODULE_NOT_FOUND') throw error; return {}; }); const scopeA = 'a'.repeat(64), scopeB = 'b'.repeat(64); const refreshMessage = 'The source changed. Refresh the image collection before inspecting this file.'; const fields = ['seed', 'steps', 'cfg', 'sampler', 'scheduler', 'model']; function contract(name) { assert.equal(typeof module[name], 'function', `${name} contract is exported`); return module[name]; } function image(root_scope = scopeA) { return { root_index: 0, path: 'folder/a #%.png', filename: 'a #%.png', url: `/prompt_manager/images/serve/folder/a%20%23%25.png?root=0${root_scope === undefined ? '' : `&root_scope=${root_scope}`}`, ...(root_scope === undefined ? {} : { root_scope }) }; } function envelope(entry = image()) { return { success: true, inspection_version: 1, root_scope: entry.root_scope ?? scopeA, image: { root_index: entry.root_index, path: entry.path, filename: entry.filename }, file: { observation: 'current-file', source_label: 'same label', media_type: 'image', extension: '.png', format: 'PNG', size: 123, width: 720, height: 480, modified_time: 1234 }, prompts: { positive: { status: 'resolved', text: 'exact\npositive', source: 'execution' }, negative: { status: 'ambiguous', text: null, source: null } }, metadata: { status: 'available', omitted_reason: null, raw: { prompt: '{"rounded":"raw copy"}' }, record_formats: { prompt: 'reserialized-json' }, prompt_graph: { status: 'parsed', value: { s: { class_type: 'KSampler', inputs: { seed: '9007199254740993', steps: 28, cfg: '6.5000000000000000001', sampler_name: 'euler', scheduler: 'normal', model: ['model', 0] } }, model: { class_type: 'CheckpointLoaderSimple', inputs: { ckpt_name: 'model.safetensors' } } } }, workflow_graph: { status: 'absent', value: null }, parameters: null, }, resolution: { state: 'ambiguous', sampler_id: null, reason: 'unknown negative' }, library: { status: 'linked', scope: 'saved-library', positive_id: 7, pair_id: 8, title: 'Recipe', category: 'Subject', tags: ['cat'], metadata_scope: 'pair' }, }; } function pendingApi() { const requests = []; const api = { get: (url, { signal }) => new Promise((resolve, reject) => requests.push({ url, signal, resolve, reject })) }; return { api, requests }; } test('original media validation is strict before inspection and preserves rooted URLs', () => { const validate = contract('originalMediaDownload'), entry = image(); assert.deepEqual(validate(entry), {url:entry.url,filename:'a #%.png'}); for (const change of [{url:'https://other.test/prompt_manager/images/serve/folder/a%20%23%25.png?root=0'}, {url:entry.url.replace('root=0','root=1')},{url:entry.url.replace(scopeA,scopeB)}, {url:entry.url.replace('folder/a','elsewhere/a')},{url:entry.url.replace(`&root_scope=${scopeA}`,'')}, {url:entry.url+'&root=0'},{url:entry.url+'#wrong'},{path:'../a.png'},{root_scope:''}]) { assert.throws(() => validate({...entry,...change}), TypeError); } const before = Object.getOwnPropertyDescriptor(globalThis,'window'); Object.defineProperty(globalThis,'window',{configurable:true,value:{location:{href:'https://example.test/proxy/prompt_manager/workspace'},ApiClient:{resolveUrl:url=>`/proxy${url}`}}}); try { assert.deepEqual(validate(entry),{url:`/proxy${entry.url}`,filename:'a #%.png'}); } finally { if (before) Object.defineProperty(globalThis,'window',before); else delete globalThis.window; } }); test('record preserves exact projections and unknown roles without execution fact promotion', () => { const entry = image(), data = envelope(entry); const record = contract('toInspectionRecord')(data, entry, extractGenerationFacts); assert.deepEqual(record.identity, { kind: 'rooted-file', key: '0:folder/a #%.png', root_index: 0, path: entry.path, root_scope: scopeA }); assert.equal(record.generation.seed.value, '9007199254740993'); assert.equal(record.generation.cfg.value, '6.5000000000000000001'); assert.equal(record.generation.seed.evidence, 'configuration'); assert.equal(record.prompts.negative.status, 'ambiguous'); assert.equal(record.prompts.negative.text, null); assert.equal(record.historyTarget, '#/library/7'); assert.deepEqual(record.download, { url: entry.url, filename: entry.filename }); assert.equal(record.file.source_label, 'same label'); for (const status of ['none', 'ambiguous', 'unavailable', 'limited']) { const result = envelope(entry); result.library.status = status; assert.equal(module.toInspectionRecord(result, entry, extractGenerationFacts).historyTarget, null); } }); test('record format validation never guesses original text', () => { const toRecord = contract('toInspectionRecord'), entry = image(); for (const mutate of [ data => delete data.metadata.record_formats, data => { data.metadata.record_formats = { prompt: 'guess' }; }, data => { data.metadata.record_formats.extra = 'original-text'; }, data => { data.metadata.record_formats.prompt = 'invalid'; }, data => { data.metadata.raw.workflow = '{}'; }, data => { data.metadata.raw.prompt = {}; }, data => { data.metadata.raw.extra = 'secret'; }, data => { data.metadata.parameters = 'manufactured'; }, data => { data.metadata.prompt_graph = { status: 'absent', value: null }; }, data => { data.metadata.record_formats = {}; data.metadata.raw = {}; }, data => { data.metadata.status = 'limited'; }, ]) { const data = envelope(entry); mutate(data); assert.throws(() => toRecord(data, entry, extractGenerationFacts), TypeError); } const data = envelope(entry); data.metadata.record_formats.workflow = 'invalid'; data.metadata.workflow_graph = { status: 'malformed', value: null }; assert.equal(toRecord(data, entry, extractGenerationFacts).metadata.record_formats.workflow, 'invalid'); data.metadata.raw = {}; data.metadata.status = 'limited'; data.metadata.omitted_reason = 'response-size-limit'; data.metadata.prompt_graph = { status: 'limited', value: null }; data.metadata.workflow_graph = { status: 'limited', value: null }; const limited = toRecord(data, entry, extractGenerationFacts); assert.deepEqual(limited.metadata.record_formats, { prompt: 'reserialized-json', workflow: 'invalid' }); assert.equal(limited.generation.seed.status, 'unresolved'); assert.equal(limited.generation.seed.reason, 'projection-limited'); let seen; toRecord(envelope(entry), entry, input => { seen = input; return extractGenerationFacts(input); }); assert.deepEqual(seen.recordFormats, { prompt: 'reserialized-json' }); assert.equal(seen.raw, undefined, 'facts never receive raw graphs for reparsing'); }); test('record accepts actual atomic omission producer states', () => { const toRecord = contract('toInspectionRecord'); for (const wholeRaw of [false, true]) { const data = envelope(); data.metadata.record_formats.workflow = 'invalid'; data.metadata.prompt_graph = { status: 'limited', value: null }; data.metadata.workflow_graph = { status: 'limited', value: null }; if (wholeRaw) Object.assign(data.metadata, { raw: {}, parameters: null, status: 'limited', omitted_reason: 'response-size-limit' }); let record; assert.doesNotThrow(() => { record = toRecord(data, image(), extractGenerationFacts); }, 'actual producer omission remains renderable'); assert.deepEqual(record.metadata.raw, wholeRaw ? {} : { prompt: '{"rounded":"raw copy"}' }); assert.equal(record.metadata.status, wholeRaw ? 'limited' : 'available'); assert.equal(record.metadata.omitted_reason, wholeRaw ? 'response-size-limit' : null); assert.deepEqual(record.metadata.record_formats, { prompt: 'reserialized-json', workflow: 'invalid' }); assert.equal(record.prompts.positive.text, 'exact\npositive'); assert.equal(record.download.url, image().url); assert.ok(Object.values(record.generation).every(fact => fact.status === 'unresolved' && fact.reason === 'projection-limited')); } const absent = envelope(); Object.assign(absent.metadata, { status: 'limited', omitted_reason: 'response-size-limit', raw: {}, record_formats: { parameters: 'original-text' }, parameters: null, prompt_graph: { status: 'absent', value: null } }); assert.equal(toRecord(absent, image(), extractGenerationFacts).generation.seed.reason, 'projection-limited'); }); test('record rejects contradictory omission provenance before extracting facts', () => { const toRecord = contract('toInspectionRecord'); for (const mutate of [ data => Object.assign(data.metadata, { status: 'limited', omitted_reason: 'response-size-limit' }), data => Object.assign(data.metadata, { status: 'limited', omitted_reason: 'response-size-limit', raw: {} }), data => { data.metadata.raw = {}; }, data => { data.metadata.record_formats.workflow = 'original-text'; data.metadata.workflow_graph = { status: 'parsed', value: { nodes: [] } }; }, data => { Object.assign(data.metadata, { status: 'limited', omitted_reason: 'response-size-limit', raw: {}, prompt_graph: { status: 'limited', value: null } }); data.metadata.record_formats.workflow = 'invalid'; data.metadata.workflow_graph = { status: 'malformed', value: null }; }, data => { data.metadata.record_formats.workflow = 'invalid'; data.metadata.raw.workflow = 'null'; data.metadata.workflow_graph = { status: 'limited', value: null }; }, // The response budget omits the workflow graph before the prompt graph, so an // omitted invalid prompt graph means the workflow graph was omitted too. data => { data.metadata.record_formats.prompt = 'invalid'; data.metadata.raw = {}; data.metadata.prompt_graph = { status: 'limited', value: null }; data.metadata.record_formats.workflow = 'original-text'; data.metadata.raw.workflow = '{"nodes":[]}'; data.metadata.workflow_graph = { status: 'parsed', value: { nodes: [] } }; }, ]) { const data = envelope(); mutate(data); let extractions = 0; assert.throws(() => toRecord(data, image(), () => { extractions++; return extractGenerationFacts({}); }), TypeError); assert.equal(extractions, 0, 'contradictory transport cannot produce known facts'); } }); function limitedWorkflow(data = envelope()) { data.metadata.record_formats.workflow = 'limited'; data.metadata.workflow_graph = { status: 'limited', value: null }; return data; } test('record accepts limited workflow format without raw and with a limited graph', () => { const toRecord = contract('toInspectionRecord'); const record = toRecord(limitedWorkflow(), image(), extractGenerationFacts); assert.deepEqual(record.metadata.record_formats, { prompt: 'reserialized-json', workflow: 'limited' }); assert.deepEqual(record.metadata.raw, { prompt: '{"rounded":"raw copy"}' }); assert.deepEqual(record.metadata.workflow_graph, { status: 'limited', value: null }); assert.equal(record.metadata.status, 'available'); assert.equal(record.generation.seed.value, '9007199254740993', 'a limited workflow never hides prompt-graph facts'); const workflowOnly = limitedWorkflow(); Object.assign(workflowOnly.metadata, { raw: {}, record_formats: { workflow: 'limited' }, prompt_graph: { status: 'absent', value: null } }); const facts = toRecord(workflowOnly, image(), extractGenerationFacts).generation; assert.ok(Object.values(facts).every(fact => fact.status === 'unresolved' && fact.reason === 'projection-limited')); const omitted = limitedWorkflow(); Object.assign(omitted.metadata, { raw: {}, parameters: null, status: 'limited', omitted_reason: 'response-size-limit', prompt_graph: { status: 'limited', value: null } }); assert.deepEqual(toRecord(omitted, image(), extractGenerationFacts).metadata.record_formats, { prompt: 'reserialized-json', workflow: 'limited' }); }); test('record rejects limited format with raw present', () => { const toRecord = contract('toInspectionRecord'); for (const mutate of [ data => { data.metadata.raw.workflow = '{"nodes":[]}'; }, data => { data.metadata.workflow_graph = { status: 'parsed', value: { nodes: [] } }; }, data => { data.metadata.workflow_graph = { status: 'malformed', value: null }; }, data => { data.metadata.workflow_graph = { status: 'absent', value: null }; }, // Role records fail closed on the server: they are never limited. data => { data.metadata.record_formats.prompt = 'limited'; delete data.metadata.raw.prompt; data.metadata.prompt_graph = { status: 'limited', value: null }; }, data => { data.metadata.record_formats.parameters = 'limited'; }, ]) { const data = limitedWorkflow(); mutate(data); let extractions = 0; assert.throws(() => toRecord(data, image(), () => { extractions++; return extractGenerationFacts({}); }), TypeError); assert.equal(extractions, 0); } }); test('record accepts the workflow-first response omission of an invalid workflow record', () => { const data = envelope(); data.metadata.record_formats.workflow = 'invalid'; data.metadata.workflow_graph = { status: 'limited', value: null }; const record = contract('toInspectionRecord')(data, image(), extractGenerationFacts); assert.deepEqual(record.metadata.prompt_graph.status, 'parsed', 'prompt evidence outlives workflow evidence'); assert.equal(record.generation.seed.value, '9007199254740993'); }); test('record accepts closed reason codes and rejects contradictory ones', () => { const toRecord = contract('toInspectionRecord'); const codes = ['work-limit', 'depth-limit', 'text-limit', 'graph-ceiling', 'unsupported-node', 'runtime-value', 'composite', 'no-sampler', 'malformed', 'multistage-differs']; for (const reason_code of codes) { const data = envelope(); Object.assign(data.resolution, { state: 'unresolved', reason_code, node: null }); assert.doesNotThrow(() => toRecord(data, image(), extractGenerationFacts), reason_code); } const node = envelope(); Object.assign(node.resolution, { state: 'unresolved', reason_code: 'unsupported-node', node: { id: '7', class_type: 'LoRAfromText' } }); assert.doesNotThrow(() => toRecord(node, image(), extractGenerationFacts)); node.resolution.node.class_type = null; // the server names a non-string class as null assert.doesNotThrow(() => toRecord(node, image(), extractGenerationFacts)); for (const state of ['resolved', 'absent', 'ambiguous', 'unresolved']) { const data = envelope(); Object.assign(data.resolution, { state, reason_code: null, node: null }); assert.doesNotThrow(() => toRecord(data, image(), extractGenerationFacts), state); } for (const change of [ { state: 'unresolved', reason_code: 'guess' }, { state: 'unresolved', reason_code: 1 }, { state: 'resolved', reason_code: 'work-limit' }, { state: 'absent', reason_code: 'no-sampler' }, { state: 'unresolved', reason_code: null, node: { id: '7', class_type: 'X' } }, { state: 'unresolved', reason_code: 'unsupported-node', node: { id: 7, class_type: 'X' } }, { state: 'unresolved', reason_code: 'unsupported-node', node: { id: '7' } }, { state: 'unresolved', reason_code: 'unsupported-node', node: { id: '7', class_type: 7 } }, { state: 'unresolved', reason_code: 'unsupported-node', node: 'X' }, ]) { const data = envelope(); Object.assign(data.resolution, change); let extractions = 0; assert.throws(() => toRecord(data, image(), () => { extractions++; return extractGenerationFacts({}); }), TypeError, JSON.stringify(change)); assert.equal(extractions, 0); } }); test('resolution validator accepts closed reason codes and a bounded node record, rejects others', () => { const toRecord = contract('toInspectionRecord'); for (const reason_code of ['unsupported-node', 'runtime-value', 'composite']) { for (const node of [{ id: '7', class_type: 'N'.repeat(200) }, { id: '7'.repeat(200), class_type: null }, null]) { const data = envelope(); Object.assign(data.resolution, { state: 'unresolved', reason_code, node }); assert.doesNotThrow(() => toRecord(data, image(), extractGenerationFacts), JSON.stringify({ reason_code, node })); } } for (const change of [ { reason_code: 'unsupported-node', node: { id: '7', class_type: 'N'.repeat(201) } }, { reason_code: 'unsupported-node', node: { id: '7'.repeat(201), class_type: 'X' } }, { reason_code: 'unsupported-node', node: { id: '', class_type: 'X' } }, { reason_code: 'unsupported-node', node: { id: '7', class_type: 'X', extra: true } }, { reason_code: 'unsupported-node', node: [] }, { reason_code: 'malformed', node: { id: '7', class_type: 'X' } }, { reason_code: 'work-limit', node: { id: '7', class_type: 'X' } }, { reason_code: 'no-sampler', node: { id: '7', class_type: 'X' } }, { reason_code: 'Unsupported-Node', node: null }, { reason_code: '', node: null }, ]) { const data = envelope(); Object.assign(data.resolution, { state: 'unresolved', ...change }); assert.throws(() => toRecord(data, image(), extractGenerationFacts), TypeError, JSON.stringify(change)); } }); test('role outcome lines are bounded text on non-resolved roles only', () => { const toRecord = contract('toInspectionRecord'); const line = 'Prompt uses a node the inspector does not support ().'; const data = envelope(); data.prompts.negative = { status: 'unresolved', text: null, source: null, message: line }; assert.equal(toRecord(data, image(), extractGenerationFacts).prompts.negative.message, line); for (const [role, record] of [ ['positive', { status: 'resolved', text: 'exact', source: 'execution', message: 'Hidden reason.' }], ['negative', { status: 'unresolved', text: null, source: null, message: '' }], ['negative', { status: 'unresolved', text: null, source: null, message: 7 }], ['negative', { status: 'unresolved', text: null, source: null, message: null }], ['negative', { status: 'unresolved', text: null, source: null, message: 'x'.repeat(513) }], ]) { const bad = envelope(); bad.prompts[role] = record; assert.throws(() => toRecord(bad, image(), extractGenerationFacts), TypeError, JSON.stringify(record)); } }); test('record rejects root path media and invalid versioned fields with retryable TypeError', () => { const toRecord = contract('toInspectionRecord'); for (const mutate of [ data => { data.image.root_index = 1; }, data => { data.image.path = 'other.png'; }, data => { data.inspection_version = 2; }, data => { data.file.width = 0; }, data => { delete data.success; }, data => { data.file.size = -1; }, data => { data.library.positive_id = 9007199254740992; }, data => { data.metadata.prompt_graph = { status: 'limited', value: {} }; }, data => { data.resolution.sampler_id = {}; }, ]) { const data = envelope(); mutate(data); assert.throws(() => toRecord(data, image(), extractGenerationFacts), error => error instanceof TypeError && !error.refreshRequired); } for (const url of ['https://attacker.invalid/prompt_manager/images/serve/folder/a%20%23%25.png?root=0', 'data:text/plain,unsafe', '/other/a.png', '/prompt_manager/images/serve/other.png?root=0', `/prompt_manager/images/serve/folder/a%20%23%25.png?root=1&root_scope=${scopeA}`, `/prompt_manager/images/serve/folder/a%20%23%25.png?root=0&root_scope=${scopeB}`, `/prompt_manager/images/serve/folder/a%20%23%25.png?root=0&root=1&root_scope=${scopeA}`]) { assert.throws(() => toRecord(envelope(), { ...image(), url }, extractGenerationFacts), TypeError); } }); test('record preserves proxy originals and rejects opaque or normalized path escapes', () => { const toRecord = contract('toInspectionRecord'); const previousLocation = globalThis.location, previousApi = globalThis.ApiClient; globalThis.location = { href: 'https://workspace.example/nested/proxy/prompt_manager/workspace', pathname: '/nested/proxy/prompt_manager/workspace' }; globalThis.ApiClient = require('../../web/js/api-client.js'); try { assert.equal(toRecord(envelope(), image(), extractGenerationFacts).download.url, `/nested/proxy${image().url}`); const absolute = { ...image(), url: `https://workspace.example:443/nested/proxy${image().url}` }; assert.equal(toRecord(envelope(), absolute, extractGenerationFacts).download.url, absolute.url); for (const url of [`https://workspace.example.attacker.invalid/nested/proxy${image().url}`, `/nested/proxy/prompt_manager/images/serve/folder/../a%20%23%25.png?root=0&root_scope=${scopeA}`, `//attacker.invalid${image().url}`]) assert.throws(() => toRecord(envelope(), { ...image(), url }, extractGenerationFacts), TypeError); } finally { globalThis.location = previousLocation; globalThis.ApiClient = previousApi; } }); test('inspectPath does not coerce or strip malformed scopes', () => { const inspectPath = contract('inspectPath'); assert.equal(inspectPath(image()), `/prompt_manager/images/inspect/folder/a%20%23%25.png?root=0&root_scope=${scopeA}`); assert.equal(inspectPath({ root_index: 2, path: 'a b/x#%.png' }), '/prompt_manager/images/inspect/a%20b/x%23%25.png?root=2'); for (const root_scope of [null, true, 1, {}, 'a'.repeat(63), 'A'.repeat(64), scopeA + '\n']) { assert.throws(() => inspectPath({ ...image(), root_scope }), TypeError); } for (const entry of [{ root_index: '0', path: 'a.png' }, { root_index: 0, path: '../a.png' }, { root_index: 0, path: 'a//b.png' }, { root_index: 0, path: '/a.png' }, { root_index: 0, path: 'C:file.png' }]) assert.throws(() => inspectPath(entry), TypeError); }); test('controller shares current request across subscribers', async () => { const { api, requests } = pendingApi(), updates = [[], []]; const controller = contract('createInspectionController')({ api, extractFacts: extractGenerationFacts }); const off = controller.subscribe(state => updates[0].push(state)); controller.subscribe(state => updates[1].push(state)); assert.equal(updates[0][0].record, null); const first = controller.select(image()), second = controller.select({ ...image() }); assert.equal(requests.length, 1); assert.equal(controller.state.loading, true); requests[0].resolve(envelope()); await Promise.all([first, second]); assert.equal(controller.state.record.generation.seed.value, '9007199254740993'); assert.equal(updates[0].at(-1), updates[1].at(-1)); await controller.select(image()); assert.equal(requests.length, 1); off(); const count = updates[0].length; const retry = controller.retry(); assert.equal(controller.state.record, null); assert.equal(controller.state.prompts, undefined); assert.equal(requests.length, 2); assert.ok(requests[1].url.endsWith(`root_scope=${scopeA}`)); requests[1].resolve(envelope()); await retry; assert.equal(updates[0].length, count); controller.clear(); assert.equal(controller.state.record, null); assert.equal(controller.state.loading, false); const reselect = controller.select(image()); assert.equal(requests.length, 3); requests[2].resolve(envelope()); await reselect; controller.dispose(); }); test('root scope echo and source-changed require refresh', async () => { const SourceChanged = contract('InspectionSourceChangedError'), toRecord = contract('toInspectionRecord'); for (const root_scope of [undefined, scopeB]) { const data = envelope(); if (root_scope === undefined) delete data.root_scope; else data.root_scope = root_scope; assert.throws(() => toRecord(data, image(), extractGenerationFacts), error => error instanceof SourceChanged && error.refreshRequired && error.message === refreshMessage); const { api, requests } = pendingApi(); const controller = contract('createInspectionController')({ api, extractFacts: extractGenerationFacts }); const load = controller.select(image()); requests[0].resolve(data); await load; assert.deepEqual([controller.state.error, controller.state.refreshRequired, controller.state.record, controller.state.prompts, controller.state.message], [true, true, null, undefined, refreshMessage]); await controller.retry(); assert.equal(requests.length, 1); controller.dispose(); } for (const [status, code, refresh] of [[409, 'source-changed', true], [409, null, false], [409, 'file-changed', false], [500, 'source-changed', false]]) { const { api, requests } = pendingApi(); const controller = module.createInspectionController({ api, extractFacts: extractGenerationFacts }); const load = controller.select(image()); requests[0].reject(new ApiError(status, refreshMessage, code)); await load; assert.equal(controller.state.refreshRequired, refresh); const retry = controller.retry(); assert.equal(requests.length, refresh ? 1 : 2); if (!refresh) { requests[1].resolve(envelope()); await retry; assert.equal(controller.state.error, false); } controller.dispose(); } }); test('same image key with changed scope supersedes pending actions', async () => { const { api, requests } = pendingApi(); const controller = contract('createInspectionController')({ api, extractFacts: extractGenerationFacts }); const first = controller.select(image()); requests[0].resolve(envelope()); await first; const changed = image(scopeB), next = controller.select(changed); assert.equal(controller.state.record, null); assert.equal(controller.state.prompts, undefined); assert.equal(requests.length, 2); requests[1].resolve(envelope(changed)); await next; assert.equal(controller.state.record.identity.root_scope, scopeB); assert.equal(controller.state.record.file.source_label, 'same label'); controller.dispose(); }); test('unscoped role-only callers stay compatible', async () => { const toRecord = contract('toInspectionRecord'), entry = { root_index: 0, path: 'old.png' }; const data = { prompts: { positive: { status: 'resolved', text: 'old', source: 'graph-inferred' }, negative: { status: 'unresolved', text: null, source: null } } }; const record = toRecord(data, entry, extractGenerationFacts); assert.equal(record.identity.root_scope, null); assert.equal(record.identity.key, '0:old.png'); for (const key of ['file', 'metadata', 'library', 'download', 'historyTarget']) assert.equal(record[key], null); for (const field of fields) assert.deepEqual(record.generation[field], { status: 'unresolved', value: null, source: null, evidence: null, reason: null, sampler_id: null }); assert.deepEqual(record.prompts, data.prompts); const changes = []; const loader = contract('createInspectionLoader')({ get: async () => data }, value => changes.push(value)); await loader.load(entry); assert.deepEqual(changes.at(-1).prompts, data.prompts); assert.equal(changes.at(-1).record.identity.key, '0:old.png'); loader.dispose(); assert.throws(() => toRecord(data, image(), extractGenerationFacts), error => error.refreshRequired === true); const scoped = toRecord({ ...data, root_scope: scopeA }, image(), extractGenerationFacts); assert.equal(scoped.metadata, null); assert.equal(scoped.download.url, image().url); }); test('disposed or superseded responses never publish', async () => { const { api, requests } = pendingApi(), updates = []; const controller = contract('createInspectionController')({ api, extractFacts: extractGenerationFacts }); controller.subscribe(state => updates.push(state)); const first = controller.select(image()), newer = image(scopeB), second = controller.select(newer); assert.equal(requests[0].signal.aborted, true); requests[1].resolve(envelope(newer)); await second; const count = updates.length; requests[0].reject(new ApiError(409, refreshMessage, 'source-changed')); await first; assert.equal(updates.length, count); assert.equal(controller.state.record.identity.root_scope, scopeB); const third = controller.retry(); controller.clear(); assert.equal(requests[2].signal.aborted, true); requests[2].resolve(envelope(newer)); await third; assert.equal(controller.state.record, null); const final = controller.select(image()); controller.dispose(); const disposedCount = updates.length; requests[3].resolve(envelope()); await final; await controller.select(newer); await controller.retry(); controller.clear(); assert.equal(updates.length, disposedCount); assert.equal(requests.length, 4); }); const invalidSelections = () => [null, undefined, 0, 'new.png', {}, { ...image(), path: '../new.png' }, { ...image(), root_scope: null }, { ...image(), root_scope: 'malformed' }]; test('invalid attempted selections clear completed actions and retry validation only', async () => { for (const invalid of invalidSelections()) { const { api, requests } = pendingApi(); const controller = contract('createInspectionController')({ api, extractFacts: extractGenerationFacts }); const previous = controller.select(image()); requests[0].resolve(envelope()); await previous; assert.equal(controller.state.prompts.positive.text, 'exact\npositive'); let rejected, thrown; try { rejected = controller.select(invalid); } catch (error) { thrown = error; } assert.equal(controller.state.record, null, 'invalid navigation cannot retain completed actions'); assert.equal(controller.state.prompts, undefined); assert.equal(thrown, undefined, 'selection validation uses the retryable state'); await rejected; assert.deepEqual([controller.state.loading, controller.state.error, controller.state.refreshRequired, controller.state.message], [false, true, false, 'Could not read details from this file.']); await controller.retry(); await controller.retry(); assert.equal(requests.length, 1); const valid = controller.select(image()); assert.equal(requests.length, 2); requests[1].resolve(envelope()); await valid; await controller.select({ ...image() }); assert.equal(requests.length, 2); controller.dispose(); } }); test('invalid attempted selections abort pending reads and suppress late results', async () => { for (const invalid of invalidSelections()) { const { api, requests } = pendingApi(), changes = []; const controller = contract('createInspectionController')({ api, extractFacts: extractGenerationFacts }); controller.subscribe(state => changes.push(state)); const previous = controller.select(image()); let rejected, thrown; try { rejected = controller.select(invalid); } catch (error) { thrown = error; } assert.equal(requests[0].signal.aborted, true); assert.equal(thrown, undefined, 'selection validation uses the retryable state'); assert.equal(controller.state.record, null); assert.equal(controller.state.prompts, undefined); await rejected; const count = changes.length; requests[0].resolve(envelope()); await previous; assert.equal(changes.length, count); assert.equal(controller.state.error, true); assert.equal(controller.state.record, null); assert.equal(controller.state.prompts, undefined); await controller.retry(); assert.equal(requests.length, 1); controller.dispose(); } }); test('explicit rooted adapter preserves the strict default identity and download contract', () => { assert.ok(module.rootedFileAdapter, 'the default rooted adapter is exported'); const a = module.rootedFileAdapter, entry = image(); assert.equal(a.key(entry), '0:folder/a #%.png'); assert.equal(a.scope(entry), scopeA); assert.equal(a.inspectionPath(entry), module.inspectPath(entry)); assert.deepEqual(a.originalDownload(entry), module.originalMediaDownload(entry)); assert.deepEqual(a.toRecord(envelope(), entry, extractGenerationFacts), module.toInspectionRecord(envelope(), entry, extractGenerationFacts)); assert.throws(() => a.validate({kind:'reported-location',path:'a.png'}), TypeError); }); test('rooted controller keeps legacy descriptors with ignored noncloneable fields source compatible', async () => { const {api,requests}=pendingApi(),controller=module.createInspectionController({api,extractFacts:extractGenerationFacts}); const selected={...image(),ignoredCallback(){}}; let pending;assert.doesNotThrow(()=>{pending=controller.select(selected);}); requests[0].resolve(envelope());await pending;assert.equal(controller.state.record.identity.key,'0:folder/a #%.png');controller.dispose(); }); // The maintainer's standard pipeline: one PromptManager pair feeds a base pass, an // upscale pass and a FaceDetailer, saved by a custom saver. Text and seeds are invented. function detailerPipelineGraph() { return { 1: { class_type: 'CheckpointLoaderSimple', inputs: { ckpt_name: 'Base/example_model.safetensors' } }, 5: { class_type: 'KSampler', inputs: { seed: 111, steps: 4, cfg: 1.0, sampler_name: 'lcm', scheduler: 'normal', denoise: 1.0, model: ['47', 0], positive: ['25', 0], negative: ['26', 0], latent_image: ['20', 0] } }, 6: { class_type: 'VAEDecode', inputs: { samples: ['5', 0], vae: ['1', 2] } }, 7: { class_type: 'PreviewImage', inputs: { images: ['6', 0] } }, 8: { class_type: 'UpscaleModelLoader', inputs: { model_name: 'upscaler.pth' } }, 9: { class_type: 'ImageUpscaleWithModel', inputs: { upscale_model: ['8', 0], image: ['6', 0] } }, 10: { class_type: 'ImageScaleBy', inputs: { upscale_method: 'lanczos', scale_by: 0.75, image: ['9', 0] } }, 11: { class_type: 'VAEEncode', inputs: { pixels: ['10', 0], vae: ['1', 2] } }, 12: { class_type: 'KSampler', inputs: { seed: 222, steps: 6, cfg: 1.0, sampler_name: 'lcm', scheduler: 'normal', denoise: 0.45, model: ['47', 0], positive: ['25', 0], negative: ['26', 0], latent_image: ['11', 0] } }, 13: { class_type: 'VAEDecode', inputs: { samples: ['12', 0], vae: ['1', 2] } }, 17: { class_type: 'FaceDetailer', inputs: { seed: 333, steps: 4, cfg: 1.0, sampler_name: 'lcm', scheduler: 'normal', denoise: 0.4, image: ['13', 0], model: ['47', 0], clip: ['47', 1], vae: ['1', 2], positive: ['25', 0], negative: ['26', 0] } }, 20: { class_type: 'EmptyLatentBatch', inputs: { width: 640, height: 1536, batch_size: 1 } }, 25: { class_type: 'PromptManager', inputs: { text: 'a lighthouse at dusk, oil painting', category: '', tags: '', search_text: '', prepend_text: '', append_text: '', clip: ['47', 1] } }, 26: { class_type: 'PromptManager', inputs: { text: 'blurry, watermark', category: '', tags: '', search_text: '', prepend_text: '', append_text: '', clip: ['47', 1] } }, 47: { class_type: 'Lora Loader (LoraManager)', inputs: { text: '', model: ['1', 0], clip: ['1', 1] } }, 48: { class_type: 'KikoSaveImage', inputs: { filename_prefix: ['49', 0], format: 'PNG', images: ['17', 0] } }, 49: { class_type: 'KikoProjectFilePath', inputs: { project_name: 'example', filename: 'image' } }, }; } test('workflow prompts and settings match the legacy parser when the server cannot decide', t => { const parser = require('../../web/js/comfy-metadata.js'), { extractGenerationParams } = parser; globalThis.ComfyMetadata = parser; t.after(() => { delete globalThis.ComfyMetadata; }); const entry = image(), data = envelope(entry); data.prompts = { positive: { status: 'ambiguous', text: null, source: null }, negative: { status: 'ambiguous', text: null, source: null } }; data.metadata.prompt_graph = { status: 'parsed', value: detailerPipelineGraph() }; data.resolution = { state: 'ambiguous', sampler_id: null, reason: 'Multiple sampler branches' }; const record = contract('toInspectionRecord')(data, entry, extractGenerationFacts); const legacy = extractGenerationParams({ prompt: detailerPipelineGraph() }); assert.deepEqual(record.prompts.positive, { status: 'resolved', text: legacy.positivePrompt, source: 'graph-inferred' }); assert.deepEqual(record.prompts.negative, { status: 'resolved', text: legacy.negativePrompt, source: 'graph-inferred' }); assert.equal(record.prompts.positive.text, 'a lighthouse at dusk, oil painting'); assert.equal(record.prompts.negative.text, 'blurry, watermark'); for (const [field, key] of [['seed', 'seed'], ['steps', 'steps'], ['cfg', 'cfgScale'], ['sampler', 'sampler'], ['model', 'checkpoint']]) { assert.equal(record.generation[field].status, 'known', field); assert.equal(record.generation[field].value, String(legacy[key]), field); } }); test('recorded and saved prompts are never replaced by the workflow parse', t => { globalThis.ComfyMetadata = require('../../web/js/comfy-metadata.js'); t.after(() => { delete globalThis.ComfyMetadata; }); const entry = image(), data = envelope(entry); data.metadata.prompt_graph = { status: 'parsed', value: detailerPipelineGraph() }; const record = contract('toInspectionRecord')(data, entry, extractGenerationFacts); assert.deepEqual(record.prompts.positive, { status: 'resolved', text: 'exact\npositive', source: 'execution' }); assert.equal(record.prompts.negative.text, 'blurry, watermark'); }); test('a definite server prompt is kept even when the legacy parser reads it differently', t => { globalThis.ComfyMetadata = require('../../web/js/comfy-metadata.js'); t.after(() => { delete globalThis.ComfyMetadata; }); const entry = image(), data = envelope(entry); // The legacy parser follows ConditioningZeroOut back to the positive text node. const graph = detailerPipelineGraph(); graph[30] = { class_type: 'ConditioningZeroOut', inputs: { conditioning: ['25', 0] } }; graph[5].inputs.negative = ['30', 0]; data.metadata.prompt_graph = { status: 'parsed', value: graph }; data.prompts = { positive: { status: 'ambiguous', text: null, source: null }, negative: { status: 'resolved', text: '', source: 'zeroed-conditioning' } }; const record = contract('toInspectionRecord')(data, entry, extractGenerationFacts); assert.deepEqual(record.prompts.negative, { status: 'resolved', text: '', source: 'zeroed-conditioning' }); assert.equal(record.prompts.positive.text, 'a lighthouse at dusk, oil painting'); }); test('callers without the browser global pass the legacy parser explicitly', () => { const parser = require('../../web/js/comfy-metadata.js'); const entry = image(), data = envelope(entry); data.prompts = { positive: { status: 'ambiguous', text: null, source: null }, negative: { status: 'ambiguous', text: null, source: null } }; data.metadata.prompt_graph = { status: 'parsed', value: detailerPipelineGraph() }; const identity = { kind: 'rooted-file', key: '0:x', root_index: 0, path: entry.path, root_scope: scopeA }; const record = contract('projectInspectionPayload')(data, identity, null, extractGenerationFacts, false, parser); assert.equal(record.prompts.positive.text, 'a lighthouse at dusk, oil painting'); assert.equal(record.prompts.negative.text, 'blurry, watermark'); }); test('a setting the file does not record is never filled from the legacy parse', t => { globalThis.ComfyMetadata = require('../../web/js/comfy-metadata.js'); t.after(() => { delete globalThis.ComfyMetadata; }); const entry = image(), data = envelope(entry); data.metadata.prompt_graph = { status: 'parsed', value: detailerPipelineGraph() }; const extract = input => ({ ...extractGenerationFacts(input), cfg: { status: 'not-recorded', value: null, source: null, evidence: null, reason: null, sampler_id: null } }); const record = contract('toInspectionRecord')(data, entry, extract); assert.equal(record.generation.cfg.status, 'not-recorded'); assert.equal(record.generation.cfg.value, null); });