Files
ComfyAssets-ComfyUI_PromptM…/tests/js/workspace-inspection-state.test.js
Vito d6eba05d21 fix(inspect): show the prompts and settings the legacy parser finds (#251)
* fix(inspect): show the prompts and settings the legacy parser finds

The workspace inspector took its prompts from the new server resolver,
which refused workflows where several samplers share one prompt (base
pass, upscale pass, FaceDetailer), and showed "Multiple possible
prompts" for files the legacy gallery and admin pages read correctly.

The inspector now runs the same extractGenerationParams those pages
use and shows its positive, negative, seed, steps, CFG, sampler and
model whenever it finds them. Prompts recorded at execution or saved
in the Library are never replaced; when the legacy parser finds
nothing, the server result stays.

Read-only check over a 38,602-file output folder: prompts shown went
from 1,798 to 7,542 files, and every one of the 7,236 positives and
9,421 negatives the legacy parser finds is shown identically.

* fix(inspect): keep definite server prompts over the legacy parse

The legacy parser follows ConditioningZeroOut back to the positive text
node and shows the positive as the negative. Apply its result only to
roles and settings the server could not decide, so a definite answer
such as an explicitly empty negative is never replaced.

Node callers (the corpus facts tool and the parity test support) now
pass the parser explicitly instead of relying on a browser global.

On a 38,602-file output folder, prompts shown stay at 7,542 files. Where
the server and the legacy parser both answer, they differ only in
surrounding whitespace (793 roles), except 227 zeroed negatives the
legacy parser misread and one prompt that is a JSON block.

* fix(inspect): never fill a setting the file does not record

The legacy parser can take a FaceDetailer as the generation sampler and
report its CFG for a workflow whose guider records none. Fill a setting
from the legacy parse only when the server left it unresolved or
ambiguous; a known value or a definite "not recorded" stays.

The corpus and parity Node tools again measure extractGenerationFacts
on its own, the contract their fixture vectors describe.

* fix(viewer): destroy an inline Viewer only after its pending view settles

Viewer.js's inline destroy() only unbinds and leaves an in-flight image
view running; when it finishes it reads the destroyed item list and
throws "c is undefined". Closing the preview while an image was still
loading hit this in Firefox about once in 15 runs of the recorded-run
history test. A closed viewer is now destroyed once that view settles,
its image fails, or a 10 second bound passes.

The history test passed 60 of 60 runs in Firefox with the fix.

* fix(viewer): keep the image src until a closed Viewer is released

Viewer.js rebuilds its item list from images that still have a src. On
close, the src was removed before the viewer cleanup ran, so a pending
view found no items and threw "c is undefined" (WebKit and Firefox).
With Viewer.js, the src is now removed after the viewer is destroyed,
once its pending view settles.

The recorded-run history test passed 40 of 40 runs in WebKit and in
Firefox.
2026-10-19 20:05:55 -07:00

558 lines
40 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'], 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 (<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 = {}; },
]) {
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);
});