Files
ComfyAssets-ComfyUI_PromptM…/tests/js/workspace-inspection-state.test.js
T
Vito Sansevero 3bdbb6d26f feat(inspector): show an image's own category in the inspector and History (#332)
The inspector's library facts gain category_scope "image": an image's
own category overrides the inherited one for any link status (linked,
ambiguous, none). Images reads it for output sources only; History reads
it once per inspection inside its snapshot, keyed by the same file key
from the symlink-resolved output folder. A failed read hides the chip
rather than show a possibly wrong value. The chip's hidden prefix names
the origin ("Image category: "); the visible origin text comes in PR 3.
2026-10-27 18:37:24 -07:00

957 lines
68 KiB
JavaScript

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'], category_scope: 'pair', tags_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 (<img src=x onerror=alert(1)>).';
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 = {}; },
data => { data.library.tags_scope = 'recipe'; },
data => { data.library.category_scope = 'recipe'; },
data => { data.library.tags_scope = 'image'; },
data => { delete data.library.category_scope; },
data => { delete data.library.tags_scope; },
]) {
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');
});
// #249: a LoRA compare grid. Every sampler's negative is a ConditioningZeroOut of the
// positive encode; the legacy parser follows the zero-out back to the positive text.
function zeroedGridGraph(samplers = 3) {
const graph = {
1: { class_type: 'UNETLoader', inputs: { unet_name: 'base.safetensors', weight_dtype: 'default' } },
2: { class_type: 'CLIPLoader', inputs: { clip_name: 'te.safetensors', type: 'stable_diffusion' } },
4: { class_type: 'CLIPTextEncode', inputs: { text: 'a woman holding a coffee cup', clip: ['2', 0] } },
5: { class_type: 'ConditioningZeroOut', inputs: { conditioning: ['4', 0] } },
6: { class_type: 'EmptyLatentImage', inputs: { width: 512, height: 512, batch_size: 1 } },
};
for (let index = 0; index < samplers; index++) {
graph[`1${index}`] = { class_type: 'LoraLoaderModelOnly', inputs: { lora_name: `epoch_${index}.safetensors`, strength_model: 1, model: ['1', 0] } };
graph[`2${index}`] = { class_type: 'KSampler', inputs: { seed: 42, steps: 8, cfg: 1, sampler_name: 'euler', scheduler: 'simple', denoise: 1,
model: [`1${index}`, 0], positive: ['4', 0], negative: ['5', 0], latent_image: ['6', 0] } };
}
return graph;
}
function undecided(graph) {
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: graph };
data.resolution = { state: 'ambiguous', sampler_id: null, reason: 'Multiple sampler branches' };
return contract('toInspectionRecord')(data, entry, extractGenerationFacts);
}
test('a zeroed negative is never filled with the positive text from the legacy parse (#249)', t => {
const parser = require('../../web/js/comfy-metadata.js');
globalThis.ComfyMetadata = parser;
t.after(() => { delete globalThis.ComfyMetadata; });
// The legacy pages keep their reading: the zero-out passes the positive through.
assert.equal(parser.extractGenerationParams({ prompt: zeroedGridGraph() }).negativePrompt, 'a woman holding a coffee cup');
const record = undecided(zeroedGridGraph());
assert.deepEqual(record.prompts.positive, { status: 'resolved', text: 'a woman holding a coffee cup', source: 'graph-inferred' });
assert.deepEqual(record.prompts.negative, { status: 'resolved', text: '', source: 'zeroed-conditioning' });
// Behind an API-graph Reroute the legacy parse reads no negative, so there is nothing
// to correct: the server's answer stays.
const rerouted = zeroedGridGraph(1);
rerouted[7] = { class_type: 'Reroute', inputs: { value: ['5', 0] } };
rerouted[20].inputs.negative = ['7', 0];
assert.equal(parser.legacyNegativeSource({ prompt: rerouted }), null);
assert.deepEqual(undecided(rerouted).prompts.negative, { status: 'ambiguous', text: null, source: null });
});
test('the zero-out check follows the sampler the legacy parse read (#249)', t => {
const parser = require('../../web/js/comfy-metadata.js');
globalThis.ComfyMetadata = parser;
t.after(() => { delete globalThis.ComfyMetadata; });
const zeroed = { status: 'resolved', text: '', source: 'zeroed-conditioning' };
// The legacy parse reads the first sampler (20): its negative is the zero-out.
const mixed = zeroedGridGraph(2);
mixed[8] = { class_type: 'CLIPTextEncode', inputs: { text: 'blurry', clip: ['2', 0] } };
mixed[21].inputs.negative = ['8', 0];
assert.deepEqual(undecided(mixed).prompts.negative, zeroed);
// Reversed: the sampler it reads encodes "blurry", which it showed before and still does.
const reversed = zeroedGridGraph(2);
reversed[8] = { class_type: 'CLIPTextEncode', inputs: { text: 'blurry', clip: ['2', 0] } };
reversed[20].inputs.negative = ['8', 0];
assert.equal(parser.extractGenerationParams({ prompt: reversed }).negativePrompt, 'blurry');
assert.deepEqual(undecided(reversed).prompts.negative, { status: 'resolved', text: 'blurry', source: 'graph-inferred' });
// The positive fills from the legacy parse in both cases.
assert.equal(undecided(mixed).prompts.positive.text, 'a woman holding a coffee cup');
assert.equal(undecided(reversed).prompts.positive.text, 'a woman holding a coffee cup');
});
test('a zero-out mixed into the read negative leaves it as the server left it (#249)', t => {
globalThis.ComfyMetadata = require('../../web/js/comfy-metadata.js');
t.after(() => { delete globalThis.ComfyMetadata; });
// A zero-out combined with another conditioning is not an empty negative.
const combined = zeroedGridGraph(1);
combined[8] = { class_type: 'CLIPTextEncode', inputs: { text: 'blurry', clip: ['2', 0] } };
combined[9] = { class_type: 'ConditioningCombine', inputs: { conditioning_1: ['5', 0], conditioning_2: ['8', 0] } };
combined[20].inputs.negative = ['9', 0];
assert.deepEqual(undecided(combined).prompts.negative, { status: 'ambiguous', text: null, source: null });
});
function workflowOnly(workflow) {
const entry = image(), data = envelope(entry);
data.prompts = { positive: { status: 'unresolved', text: null, source: null }, negative: { status: 'unresolved', text: null, source: null } };
Object.assign(data.metadata, { raw: { workflow: JSON.stringify(workflow) }, record_formats: { workflow: 'reserialized-json' },
prompt_graph: { status: 'absent', value: null }, workflow_graph: { status: 'parsed', value: workflow } });
return contract('toInspectionRecord')(data, entry, extractGenerationFacts);
}
// A UI workflow: encode 4 -> zero-out 5 -> sampler 3's negative, encode 4 -> positive.
function zeroedWorkflow({ zeroMode = 0, extraNodes = [], extraLinks = [], negativeLink = 3 } = {}) {
return { nodes: [
{ id: 4, type: 'CLIPTextEncode', inputs: [], outputs: [{ links: [1, 2] }], widgets_values: ['a woman holding a coffee cup'] },
{ id: 5, type: 'ConditioningZeroOut', mode: zeroMode, inputs: [{ name: 'conditioning', type: 'CONDITIONING', link: 1 }], outputs: [{ links: [3] }] },
{ id: 1, type: 'UNETLoader', inputs: [], outputs: [{ links: [4] }], widgets_values: ['base.safetensors', 'default'] },
{ id: 3, type: 'KSampler', inputs: [{ name: 'model', link: 4 }, { name: 'positive', link: 2 }, { name: 'negative', link: negativeLink }], widgets_values: [42, 'fixed', 8, 1, 'euler', 'simple', 1] },
...extraNodes,
], links: [[1, 4, 0, 5, 0, 'CONDITIONING'], [2, 4, 0, 3, 1, 'CONDITIONING'], [3, 5, 0, 3, 2, 'CONDITIONING'], [4, 1, 0, 3, 0, 'MODEL'], ...extraLinks] };
}
test('workflow-only files follow the legacy reading of their zero-out (#249)', t => {
const parser = require('../../web/js/comfy-metadata.js');
globalThis.ComfyMetadata = parser;
t.after(() => { delete globalThis.ComfyMetadata; });
// A connected zero-out on the read sampler's negative is the zeroed empty negative.
const direct = zeroedWorkflow();
assert.equal(parser.extractGenerationParams({ workflow: direct }).negativePrompt, 'a woman holding a coffee cup', 'the legacy parse would fill it');
assert.deepEqual(workflowOnly(direct).prompts.negative, { status: 'resolved', text: '', source: 'zeroed-conditioning' });
// A bypassed zero-out (mode 4) passes its conditioning through: the legacy reading is right.
const bypassed = workflowOnly(zeroedWorkflow({ zeroMode: 4 }));
assert.deepEqual(bypassed.prompts.negative, { status: 'resolved', text: 'a woman holding a coffee cup', source: 'graph-inferred' });
// An unconnected zero-out beside a text negative changes nothing.
const unconnected = workflowOnly(zeroedWorkflow({ negativeLink: 6,
extraNodes: [{ id: 6, type: 'CLIPTextEncode', inputs: [], outputs: [{ links: [6] }], widgets_values: ['blurry'] }],
extraLinks: [[6, 6, 0, 3, 2, 'CONDITIONING']] }));
assert.deepEqual(unconnected.prompts.negative, { status: 'resolved', text: 'blurry', source: 'graph-inferred' });
});
test('a negative read through a subgraph holding a zero-out is not filled (#249)', t => {
const parser = require('../../web/js/comfy-metadata.js');
globalThis.ComfyMetadata = parser;
t.after(() => { delete globalThis.ComfyMetadata; });
// The sampler's negative comes from a subgraph instance whose definition zeroes it.
const definition = 'aaaaaaaa-0000-4000-8000-000000000001';
const workflow = zeroedWorkflow({ negativeLink: 7,
extraNodes: [{ id: 9, type: definition, inputs: [{ name: 'conditioning', type: 'CONDITIONING', link: 8 }], outputs: [{ links: [7] }] }],
extraLinks: [[7, 9, 0, 3, 2, 'CONDITIONING'], [8, 4, 0, 9, 0, 'CONDITIONING']] });
workflow.definitions = { subgraphs: [{ id: definition, nodes: [{ id: 1, type: 'ConditioningZeroOut', inputs: [], outputs: [] }], links: [] }] };
assert.equal(parser.extractGenerationParams({ workflow }).negativePrompt, 'a woman holding a coffee cup', 'the legacy parse would fill it');
assert.deepEqual(workflowOnly(workflow).prompts.negative, { status: 'unresolved', text: null, source: null });
// The same instance of a definition without a zero-out keeps the legacy fill.
workflow.definitions.subgraphs[0].nodes[0].type = 'Reroute';
assert.equal(workflowOnly(workflow).prompts.negative.text, 'a woman holding a coffee cup');
});
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);
});
test('a workflow the legacy parser throws on keeps the server result instead of failing the record', t => {
globalThis.ComfyMetadata = require('../../web/js/comfy-metadata.js');
t.after(() => { delete globalThis.ComfyMetadata; });
const entry = image(), data = envelope(entry);
// The browser parser throws on a null workflow node (it reads properties of it).
data.metadata.workflow_graph = { status: 'parsed', value: { nodes: [null], links: [] } };
data.metadata.raw.workflow = '{"nodes":[null],"links":[]}';
data.metadata.record_formats.workflow = 'reserialized-json';
const record = contract('toInspectionRecord')(data, entry, extractGenerationFacts);
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');
});
// #249 PR C Task C3: the B3 envelope's `image_tags` facet, the file's own current tags (S1).
test('image_tags facet is kept exactly and absent stays null', () => {
const entry = image(), project = contract('toInspectionRecord');
assert.equal(project(envelope(entry), entry, extractGenerationFacts).imageTags, null);
for (const facet of [{ state: 'current', tags: ['dunes', 'warm_light'] }, { state: 'current', tags: [] }, { state: 'none', tags: [] }, { state: 'unavailable', tags: [] }]) {
const data = { ...envelope(entry), image_tags: facet };
const record = project(data, entry, extractGenerationFacts);
assert.deepEqual(record.imageTags, facet);
assert.notEqual(record.imageTags.tags, facet.tags);
}
const hundred = Array.from({ length: 100 }, (_, index) => `tag_${index}`);
assert.deepEqual(project({ ...envelope(entry), image_tags: { state: 'current', tags: hundred } }, entry, extractGenerationFacts).imageTags.tags, hundred);
});
test('rejects a malformed image_tags facet', () => {
const entry = image();
for (const facet of [null, [], 'current', { state: 'current' }, { tags: [] }, { state: 'current', tags: ['a'], extra: 1 },
{ state: 'stale', tags: [] }, { state: 'none', tags: ['a'] }, { state: 'unavailable', tags: ['a'] }, { state: 'current', tags: 'a' },
{ state: 'current', tags: [1] }, { state: 'current', tags: [''] }, { state: 'current', tags: [' a'] }, { state: 'current', tags: ['a,b'] },
{ state: 'current', tags: ['a\nb'] }, { state: 'current', tags: ['x'.repeat(51)] },
{ state: 'current', tags: Array.from({ length: 101 }, (_, index) => `tag_${index}`) }]) {
assert.throws(() => module.toInspectionRecord({ ...envelope(entry), image_tags: facet }, entry, extractGenerationFacts), TypeError, JSON.stringify(facet));
}
assert.equal(module.toInspectionRecord({ ...envelope(entry), image_tags: { state: 'current', tags: ['é'.repeat(50)] } }, entry, extractGenerationFacts).imageTags.tags[0].length, 50);
});
test('refresh re-reads the selected file and keeps the shown record until the new read lands', async () => {
const { api, requests } = pendingApi(), updates = [];
const controller = contract('createInspectionController')({ api, extractFacts: extractGenerationFacts });
assert.equal(typeof controller.refresh, 'function', 'refresh is part of the controller');
controller.subscribe(state => updates.push(state));
const load = controller.select(image()); requests[0].resolve(envelope()); await load;
const shown = controller.state, count = updates.length;
const refresh = controller.refresh();
assert.equal(requests.length, 2);
// No loading state is published: the panel keeps its content.
assert.equal(controller.state, shown); assert.equal(updates.length, count);
requests[1].resolve({ ...envelope(), image_tags: { state: 'current', tags: ['fresh'] } }); await refresh;
assert.deepEqual(controller.state.record.imageTags, { state: 'current', tags: ['fresh'] });
assert.equal(controller.state.loading, false); assert.equal(updates.length, count + 1);
// A failed quiet re-read reports the failure as a read would.
const failing = controller.refresh(); requests[2].reject(new Error('offline')); await failing;
assert.equal(controller.state.error, true);
// Without a shown record a refresh is an ordinary retry (loading published).
const again = controller.refresh(); assert.equal(controller.state.loading, true);
requests[3].resolve(envelope()); await again; assert.equal(controller.state.error, false);
// A newer selection supersedes a refresh in flight.
const stale = controller.refresh(); const other = controller.select({ ...image(), path: 'folder/b.png', filename: 'b.png', url: image().url.replace('a%20%23%25', 'b') });
requests[5].resolve(envelope({ ...image(), path: 'folder/b.png', filename: 'b.png', url: image().url.replace('a%20%23%25', 'b') })); await other;
requests[4].resolve(envelope()); await stale;
assert.equal(controller.state.record.identity.path, 'folder/b.png');
controller.dispose(); assert.equal(await controller.refresh(), undefined);
});
test('metadata off shows an explicit load and loads once on request', async () => {
const { api, requests } = pendingApi(), updates = [];
const controller = contract('createInspectionController')({ api, extractFacts: extractGenerationFacts, autoLoad: false });
controller.subscribe(state => updates.push(state));
// Selecting reads nothing: the state says the metadata waits for a request.
assert.equal(controller.select(image()), undefined);
assert.equal(requests.length, 0);
assert.equal(controller.state.deferred, true);
assert.equal(controller.state.loading, false);
assert.equal(controller.state.record, null);
// Selecting the same file again still reads nothing.
controller.select({ ...image() });
assert.equal(requests.length, 0);
const load = controller.load();
assert.equal(requests.length, 1);
assert.equal(controller.state.loading, true);
assert.equal(controller.state.deferred, false);
// A second press while it reads, and the same file selected again once it loaded, read nothing more.
controller.load();
assert.equal(requests.length, 1);
requests[0].resolve(envelope()); await load;
assert.equal(controller.state.record.generation.seed.value, '9007199254740993');
controller.load(); controller.select(image());
assert.equal(requests.length, 1);
// Another file waits for its own request again.
controller.select({ ...image(), path: 'folder/b.png', filename: 'b.png', url: image().url.replace('a%20%23%25', 'b') });
assert.equal(controller.state.deferred, true);
assert.equal(requests.length, 1);
// The default controller loads on selection, as before.
const eager = contract('createInspectionController')({ api, extractFacts: extractGenerationFacts });
eager.select(image());
assert.equal(requests.length, 2);
assert.equal(eager.state.deferred, false);
eager.dispose(); controller.dispose();
assert.equal(controller.load(), undefined);
});
test('cache off rereads the current selection; on reuses the one current record', async () => {
for (const cache of [true, false]) {
const { api, requests } = pendingApi();
const controller = contract('createInspectionController')({ api, extractFacts: extractGenerationFacts, cache });
const first = controller.select(image());
// A read in flight is shared either way.
controller.select(image());
assert.equal(requests.length, 1);
requests[0].resolve(envelope()); await first;
const again = controller.select(image());
assert.equal(requests.length, cache ? 1 : 2, `cache ${cache}`);
if (!cache) { requests[1].resolve(envelope()); await again; assert.equal(controller.state.record.identity.path, 'folder/a #%.png'); }
controller.dispose();
}
});
test('cache off never rereads a selection whose source changed, and refresh never loads a deferred one', async () => {
const { api, requests } = pendingApi();
const controller = contract('createInspectionController')({ api, extractFacts: extractGenerationFacts, cache: false });
const first = controller.select(image());
requests[0].reject(new ApiError(409, refreshMessage, 'source-changed')); await first;
assert.equal(controller.state.refreshRequired, true);
controller.select(image());
assert.equal(requests.length, 1);
controller.dispose();
const deferred = contract('createInspectionController')({ api, extractFacts: extractGenerationFacts, autoLoad: false });
deferred.select(image());
assert.equal(deferred.refresh(), undefined);
assert.equal(requests.length, 1);
assert.equal(deferred.state.deferred, true);
deferred.dispose();
});
test('record carries the listing thumbnail as its preview, never another file (#249)', () => {
const toRecord = contract('toInspectionRecord'), entry = image();
const thumb = `/prompt_manager/images/serve/thumbnails/folder/a%20%23%25_thumb.png?root=0&root_scope=${scopeA}`;
assert.deepEqual(toRecord(envelope(entry), { ...entry, thumbnail_url: thumb }, extractGenerationFacts).preview, { url: thumb });
const versioned = `/prompt_manager/images/serve/thumbnails/folder/a%20%23%25_thumb.png?v=12&root=0&root_scope=${scopeA}`;
assert.deepEqual(toRecord(envelope(entry), { ...entry, thumbnail_url: versioned }, extractGenerationFacts).preview, { url: versioned });
// No thumbnail, or one naming another file, root or origin: no preview (the cover keeps the original).
for (const thumbnail_url of [undefined, null, '', thumb.replace('a%20%23%25_thumb', 'b_thumb'), thumb.replace('root=0', 'root=1'),
thumb.replace(scopeA, scopeB), `https://other.test${thumb}`, thumb.replace('thumbnails/', ''), `${thumb}#x`]) {
assert.equal(toRecord(envelope(entry), { ...entry, thumbnail_url }, extractGenerationFacts).preview, null, String(thumbnail_url));
}
const video = { root_index: 0, path: 'clip.MP4', filename: 'clip.MP4', url: `/prompt_manager/images/serve/clip.MP4?root=0&root_scope=${scopeA}`, root_scope: scopeA,
thumbnail_url: `/prompt_manager/images/serve/thumbnails/clip_thumb.jpg?root=0&root_scope=${scopeA}` };
const data = envelope(video); data.file = { ...data.file, media_type: 'video', extension: '.mp4', format: 'MP4' };
assert.deepEqual(toRecord(data, video, extractGenerationFacts).preview, { url: video.thumbnail_url });
});
test('the preview may be any thumbnail format the file owns (#317)', () => {
const toRecord = contract('toInspectionRecord'), entry = image();
const base = '/prompt_manager/images/serve/thumbnails/folder/a%20%23%25_thumb';
for (const extension of ['.webp', '.jxl', '.jpg', '.png']) {
const thumbnail_url = `${base}${extension}?v=3&root=0&root_scope=${scopeA}`;
assert.deepEqual(toRecord(envelope(entry), { ...entry, thumbnail_url }, extractGenerationFacts).preview, { url: thumbnail_url }, extension);
}
// Not a thumbnail format, upper case, no extension, or a legacy name another file owns: no preview.
for (const extension of ['.txt', '.WEBP', '', '.gif', '.jpeg', '.webp.png']) {
const thumbnail_url = `${base}${extension}?root=0&root_scope=${scopeA}`;
assert.equal(toRecord(envelope(entry), { ...entry, thumbnail_url }, extractGenerationFacts).preview, null, extension);
}
const video = { root_index: 0, path: 'clip.MP4', filename: 'clip.MP4', url: `/prompt_manager/images/serve/clip.MP4?root=0&root_scope=${scopeA}`, root_scope: scopeA };
const data = envelope(video); data.file = { ...data.file, media_type: 'video', extension: '.mp4', format: 'MP4' };
for (const [extension, owned] of [['.webp', true], ['.jxl', true], ['.jpg', true], ['.png', false]]) {
const thumbnail_url = `/prompt_manager/images/serve/thumbnails/clip_thumb${extension}?root=0&root_scope=${scopeA}`;
assert.deepEqual(toRecord(data, { ...video, thumbnail_url }, extractGenerationFacts).preview, owned ? { url: thumbnail_url } : null, extension);
}
});
test("names the server quotes (parentheses, '!*') still match their original and preview (#249 M11)", () => {
const toRecord = contract('toInspectionRecord');
const entry = { root_index: 0, path: "folder/a (1)!'*.png", filename: "a (1)!'*.png", root_scope: scopeA,
url: `/prompt_manager/images/serve/folder/a%20%281%29%21%27%2A.png?root=0&root_scope=${scopeA}`,
thumbnail_url: `/prompt_manager/images/serve/thumbnails/folder/a%20%281%29%21%27%2A_thumb.png?root=0&root_scope=${scopeA}` };
const record = toRecord(envelope(entry), entry, extractGenerationFacts);
assert.equal(record.download.url, entry.url);
assert.deepEqual(record.preview, { url: entry.thumbnail_url });
});
test('record accepts an image\'s own category on any link status (#332)', () => {
const toRecord = contract('toInspectionRecord');
for (const status of ['linked', 'none', 'ambiguous', 'unavailable']) {
const data = envelope();
Object.assign(data.library, { status, category: 'Scans', category_scope: 'image' });
const record = toRecord(data, image(), extractGenerationFacts);
assert.equal(record.library.category, 'Scans');
assert.equal(record.library.category_scope, 'image');
}
});