Files
ComfyAssets-ComfyUI_PromptM…/tests/js/workspace-local-resolver.test.js
T
Vito a0fc5171e3 fix(inspector): pass text through unknown nodes instead of refusing them (#381) (#383)
* fix(inspector): pass text through unknown nodes instead of refusing them (#381)

The inspector refused any node class missing from the semantics table, so a
PromptManagerPair behind an rgthree Any Switch showed "Prompt uses a node the
inspector does not support". The inspection resolver (Python and browser) now
reads through unknown nodes: a text input is the text, positive/negative route
by output slot, a conditioning input is followed alone, otherwise every linked
input except core type inputs (pass_through.skip_inputs). PromptManager nodes
are authoritative; several different texts become one candidate each
("Several prompts reach the sampler"). A seeded text node with no conditioning
input is generated at run time, and a recorded PromptManager execution is not
checked against text that passed through an unknown node.

INSPECTION_RESOLVER_VERSION goes to 2; ingestion keeps the default profile.
A legacy parity gate records every committed graph whose inspection positive
matches the legacy parser's; no graph may leave it.

* fix(inspector): keep recorded pair text, string-only seed rule, role inputs and unique candidates (#381)

Review of the pass-through: a walk through an unknown node never overrides a
PromptManagerPair's execution record (any outcome but work-limit is
unverifiable while the record is checked); the run-time seed rule reads only
string outputs; a node with positive/negative inputs follows only the role of
the requested slot, nothing when it is unlinked; one text offered twice is one
candidate.

* fix(inspector): anchor on the saver that wrote the file and resolve stages on shared links (#381)

An inspected file's name now anchors the inspection on the one saver whose
filename_prefix (literal or traced) makes the file's stem <prefix>_<digits>[_];
the samplers in that saver's image ancestry are the candidates. No match,
several matches or an unreadable walk anchor nothing, so the file stays
"several possible prompts". In a latent walk, an earlier sampler reading the
same conditioning links as the later one is the same prompt, not an
unattributed stage. Python and the browser resolver behave the same; the
legacy parity gate states each graph that differs from legacy on purpose.

* fix(inspector): skip only plain first stages on shared links; an unfinished prefix scan anchors nothing (#381)

Review of the saver anchor: a same-links earlier stage is skipped only when it
is not a detailer, sets no prompt override and continues no other sampler, so
an earlier prompt or override behind it is never hidden. A work limit while
tracing a saver prefix ends the match scan without an anchor instead of
counting that saver as no match.

* test(inspector): cover every plain-first-stage outcome on both resolvers (#381)

The frontend coverage gate found local-resolver-stages.mjs branches at 92.44%
(< 93). Each way an earlier stage stops being a plain first stage (no latent,
literal, missing or cyclic latent, a sampler-shaped node, a detailer) is now a
resolver test and an anchor-first-stage-* parity vector.
2026-10-27 16:56:37 +00:00

175 lines
13 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// PR L3 Tasks 8-9: the server's inspection resolver in this browser. The values below are what CPython answers
// (json.loads, len, str.strip, repr, utils/prompt_inspector.inspect_prompt_evidence); the full comparison with the
// server runs in tests/test_local_inspect_parity.py through tests/js/support/local-inspect-oracle.mjs.
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
const py = await import('../../web/js/workspace/py-json.mjs');
const resolver = await import('../../web/js/workspace/local-resolver.mjs');
const semantics = resolver.compileSemantics(JSON.parse(fs.readFileSync(new URL('../../web/js/inspection-semantics.json', import.meta.url), 'utf8')));
test('json.loads keeps Python value kinds and dict order', () => {
const value = py.pyJsonLoads('{"9": 1, "10": 1.0, "a": NaN, "9": [true, -0, 9007199254740993, -Infinity]}');
assert.ok(value instanceof Map);
// A repeated key keeps its first position and takes its last value; integer-like keys keep text order.
assert.deepEqual([...value.keys()], ['9', '10', 'a']);
const [flag, zero, big, negative] = value.get('9');
assert.equal(flag, true); assert.equal(zero, 0); assert.equal(big, 9007199254740993n);
assert.ok(negative instanceof py.PyFloat); assert.equal(negative.value, -Infinity);
assert.ok(value.get('10') instanceof py.PyFloat, '1.0 is a float, not an int');
assert.ok(Number.isNaN(value.get('a').value));
assert.equal(py.isInt(value.get('10')), false);
assert.equal(py.pyJsonLoads('"\\ud83d\\ude00\\ud800"'), '\u{1f600}\ud800');
for (const bad of ['{}', '{"a":1,}', '[01]', '{"a" 1}', '"\u0001"', '"\\x"', '1 2', '-', `${'9'.repeat(4301)}`, '"\\ud800\\uzzzz"']) {
assert.throws(() => py.pyJsonLoads(bad), py.PyValueError, bad);
}
assert.equal(py.pyJsonLoads(` ${'9'.repeat(4300)}\n`), BigInt('9'.repeat(4300)));
});
// One depth cap for both browser CPython-JSON parsers (py-json.mjs here, local-video.mjs for video envelopes): every
// array or object counts, an empty one too, as CPython's scanner enters recursion for each; past the cap it refuses.
test('json.loads refuses nesting past the shared depth cap, counting every container', () => {
const video = import('../../web/js/workspace/local-video.mjs');
assert.equal(py.JSON_MAX_DEPTH, 1000);
const nest = (depth, inner = '') => '['.repeat(depth) + inner + ']'.repeat(depth);
const objects = (depth, inner) => '{"a":'.repeat(depth) + inner + '}'.repeat(depth);
for (const text of [nest(1000), nest(1000, '1'), nest(999, '{}'), objects(1000, '1'), objects(999, '[]')]) assert.doesNotThrow(() => py.pyJsonLoads(text));
for (const text of [nest(1001), nest(1001, '1'), nest(1000, '{}'), objects(1001, '1'), objects(1000, '[]')]) {
assert.throws(() => py.pyJsonLoads(text), py.PyValueError);
}
return video.then(({ pythonJsonLoads, PythonJsonError }) => {
for (const text of [nest(1000), nest(1000, '1'), objects(999, '[]')]) assert.doesNotThrow(() => pythonJsonLoads(text));
for (const text of [nest(1001), nest(1001, '1'), objects(1000, '[]')]) {
assert.throws(() => pythonJsonLoads(text), error => error instanceof PythonJsonError && error.recursion === true);
}
});
});
test('str helpers measure, slice and strip as CPython', () => {
assert.equal(py.pyLen('a\u{1f600}\ud800'), 3);
assert.equal(py.pySlice('\u{1f600}\u{1f600}x', 2), '\u{1f600}\u{1f600}');
// str.isspace() includes \x1c-\x1f and \x85 and excludes , unlike String.prototype.trim.
assert.equal(py.pyStrip('\x1c\x85 a b \x1f'), 'a b');
assert.equal(py.pyStrip('a'), 'a');
assert.deepEqual([1.0, 0.0001, 1e-5, 1e16, 123456789012345.6, -2.5, 1e22].map(py.pyFloatRepr),
['1.0', '0.0001', '1e-05', '1e+16', '123456789012345.6', '-2.5', '1e+22']);
assert.deepEqual([null, true, 0, 1n, new py.PyFloat(2), '', [], new Map()].map(py.pyTruthy), [false, true, false, true, true, false, false, false]);
assert.deepEqual([1, 1n, true, new py.PyFloat(1), '1', 2].map(py.pyEqualsOne), [true, true, true, true, false, false]);
});
const encode = text => ({class_type: 'CLIPTextEncode', inputs: {text, clip: ['4', 1]}});
const sampler = (extra = {}) => ({class_type: 'KSampler', inputs: {seed: 1, positive: ['6', 0], negative: ['7', 0], latent_image: ['5', 0], model: ['4', 0], ...extra}});
const base = (nodes = {}) => ({'3': sampler(), '4': {class_type: 'CheckpointLoaderSimple', inputs: {ckpt_name: 'm.safetensors'}},
'5': {class_type: 'EmptyLatentImage', inputs: {width: 64, height: 64, batch_size: 1}}, '6': encode('a fox'), '7': encode('blurry'), ...nodes});
test('a single chain resolves both roles with their sources', () => {
const evidence = resolver.resolveLocalEvidence({prompt: JSON.stringify(base())}, semantics);
assert.deepEqual(evidence, {
prompts: {positive: {status: 'resolved', text: 'a fox', source: 'graph-inferred'}, negative: {status: 'resolved', text: 'blurry', source: 'graph-inferred'}},
resolution: {state: 'resolved', sampler_id: '3', reason: '', reason_code: null, node: null},
});
});
test('records without roles, malformed text and a missing sampler answer as the server', () => {
assert.deepEqual(resolver.resolveLocalEvidence({workflow: '{}'}, semantics).resolution,
{state: 'absent', sampler_id: null, reason: 'No parsed prompt metadata', reason_code: null, node: null});
const malformed = resolver.resolveLocalEvidence({prompt: '{broken'}, semantics);
assert.equal(malformed.resolution.reason, 'Prompt evidence is malformed');
assert.equal(malformed.prompts.positive.message, 'Prompt metadata in this file is malformed.');
const none = resolver.resolveLocalEvidence({prompt: JSON.stringify({'1': encode('x'), '2': {class_type: 'MySampler', inputs: {positive: ['1', 0]}}})}, semantics);
assert.deepEqual(none.resolution, {state: 'unresolved', sampler_id: null, reason: 'No supported sampler pair found (saw MySampler)', reason_code: 'no-sampler', node: null});
assert.equal(none.prompts.negative.message, 'No supported sampler was found.');
assert.equal(resolver.resolveLocalEvidence({prompt: JSON.stringify(base()), parameters: 'x'}, null).resolution.reason, 'Inspection node semantics are unavailable');
});
test('a float output slot is not a link, and a runtime node is named', () => {
const floatSlot = resolver.resolveLocalEvidence({prompt: JSON.stringify(base()).replace('"positive":["6",0]', '"positive":["6",0.0]')}, semantics);
assert.deepEqual(floatSlot.resolution, {state: 'unresolved', sampler_id: '3', reason: 'Expected a typed graph link or text input', reason_code: 'malformed', node: null});
const runtime = resolver.resolveLocalEvidence({prompt: JSON.stringify(base({'6': {class_type: 'PromptSearchList', inputs: {}}}))}, semantics);
assert.deepEqual(runtime.resolution.node, {id: '6', class_type: 'PromptSearchList'});
assert.equal(runtime.prompts.positive.message, 'Prompt is chosen at run time (PromptSearchList).');
});
test('execution evidence replaces a PromptManagerPair text when it matches the node inputs', () => {
const pair = {class_type: 'PromptManagerPair', inputs: {text: 'fox', negative_text: 'blur', prepend_text: '', append_text: ''}};
const record = {text: 'fox', negative_text: 'blur', positive_text: 'fox, executed', negative_effective_text: 'blur', prepend_text: '', append_text: ''};
const graph = base({'3': sampler({positive: ['8', 0], negative: ['8', 1]}), '8': pair});
const evidence = resolver.resolveLocalEvidence({prompt: JSON.stringify(graph), comfyassets_prompt_pairs: JSON.stringify({version: 1, nodes: {'8': {records: [record]}}})}, semantics);
assert.deepEqual(evidence.prompts.positive, {status: 'resolved', text: 'fox, executed', source: 'execution'});
const stale = resolver.resolveLocalEvidence({prompt: JSON.stringify(graph), comfyassets_prompt_pairs: JSON.stringify({version: 1, nodes: {'8': {records: [{...record, text: 'other'}]}}})}, semantics);
assert.equal(stale.resolution.reason, 'Execution evidence does not match recorded node inputs');
});
test('identical sampler branches resolve to their shared pair, differing ones stay ambiguous (#270)', () => {
const shared = resolver.resolveLocalEvidence({prompt: JSON.stringify(base({'9': sampler()}))}, semantics);
assert.deepEqual(shared.resolution, {state: 'resolved', sampler_id: null, reason: 'Every sampler branch uses the same prompt pair', reason_code: null, node: null});
assert.equal(shared.prompts.positive.text, 'a fox');
const differ = resolver.resolveLocalEvidence({prompt: JSON.stringify(base({'9': sampler({positive: ['8', 0]}), '8': encode('a cat')}))}, semantics);
assert.deepEqual(differ.resolution, {state: 'ambiguous', sampler_id: null, reason: 'Multiple sampler branches', reason_code: null, node: null});
assert.equal(differ.prompts.positive.status, 'ambiguous');
});
test('a detailer stage is compared with the sampler in its image ancestry', () => {
const graph = base({'10': {class_type: 'VAEDecode', inputs: {samples: ['3', 0]}}, '8': encode('a cat'), '12': {class_type: 'SaveImage', inputs: {images: ['11', 0]}},
'11': {class_type: 'FaceDetailer', inputs: {image: ['10', 0], model: ['4', 0], positive: ['8', 0], negative: ['7', 0]}}});
const evidence = resolver.resolveLocalEvidence({prompt: JSON.stringify(graph)}, semantics);
assert.deepEqual(evidence.resolution, {state: 'ambiguous', sampler_id: '11', reason: 'Sampler stages have different prompts', reason_code: 'multistage-differs', node: null});
assert.equal(evidence.prompts.positive.message, undefined);
});
test('A1111 parameters split on the unique settings footer as Python does', () => {
const read = text => resolver.resolveLocalEvidence({parameters: text}, semantics);
assert.deepEqual(read('a fox\nNegative prompt: blur\nSteps: 20, Sampler: euler').prompts, {
positive: {status: 'resolved', text: 'a fox', source: 'embedded-parameters'}, negative: {status: 'resolved', text: 'blur', source: 'embedded-parameters'},
});
// Python's multiline ^ and $ break only at \n: a U+2028 or a lone \r never starts the footer line.
for (const text of ['a fox\u2028Steps: 20, x', 'a fox\rSteps: 20, x']) assert.equal(read(text).resolution.reason, 'A1111 settings footer is not unique');
// \d is any Unicode decimal digit and \s spans line breaks, as in Python's re.
assert.equal(read('a fox\nSteps: \u0662\u0660, x').resolution.reason, 'Negative prompt delimiter is absent');
assert.equal(read('a fox\nSteps:\n 20 \n, x').resolution.state, 'absent');
assert.equal(read('a fox\nSteps: 1, x\nNegative Template: n\nTemplate: t').resolution.reason_code, 'malformed');
assert.equal(read('\u{1f600}'.repeat(60000) + '\nSteps: 1, x').resolution.state, 'absent', 'the length limit counts code points');
});
test('an unusable semantics table is refused', () => {
for (const doc of [null, {schema: 2}, {schema: 1, samplers: []}]) assert.throws(() => resolver.compileSemantics(doc), resolver.SemanticsError);
});
test('the lineage section is compiled, and a bad one refuses the table', () => {
const doc = JSON.parse(fs.readFileSync(new URL('../../web/js/inspection-semantics.json', import.meta.url), 'utf8'));
assert.deepEqual(semantics.lineage.get('Image Filter\u00001'), { strategy: 'continue', input: 'latents' });
// Lineage describes no prompt: its classes are not table classes for the stage walk.
assert.equal(semantics.allClasses.has('VAEEncode'), false);
const row = { class: 'VAEEncode', slot: 0, strategy: 'continue', input: 'pixels' };
for (const lineage of [undefined, {}, [null], [{ ...row, slot: -1 }], [{ ...row, strategy: 'passthrough' }], [{ ...row, input: '' }], [row, row]]) {
assert.throws(() => resolver.compileSemantics({ ...doc, lineage }), resolver.SemanticsError, JSON.stringify(lineage));
}
});
// The committed server answers (tests/fixtures/local-inspect/resolver-vectors.json, written by
// `python3 -m tests.local_resolver_cases --write` and checked current by the Python suite).
test('committed server answers equal the local evidence', () => {
const {vectors} = JSON.parse(fs.readFileSync(new URL('../fixtures/local-inspect/resolver-vectors.json', import.meta.url), 'utf8'));
const canonical = value => Array.isArray(value) ? value.map(canonical) :
value !== null && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value;
let compared = 0;
for (const {name, records, evidence} of vectors) {
// A case may name the inspected file (`__file_name`, #381 saver anchoring); it is no record.
const {__file_name: fileName = null, ...roles} = records;
assert.deepEqual(canonical(resolver.resolveLocalEvidence(roles, semantics, fileName)), canonical(evidence), name);
compared++;
}
assert.ok(compared > 280, `compared ${compared}`);
});
test('a list or object workflow node type is skipped, as on the server (#286)', () => {
const graph = base({'6': {class_type: 'PromptManager', inputs: {text: 'a fox', clip: ['4', 1]}}});
for (const type of [['PromptManager'], {name: 'PromptManager'}]) {
const evidence = resolver.resolveLocalEvidence({prompt: JSON.stringify(graph), workflow: JSON.stringify({nodes: [{id: 6, type, widgets_values: ['x']}]})}, semantics);
assert.equal(evidence.resolution.state, 'resolved');
assert.equal(evidence.prompts.positive.status, 'resolved');
assert.equal(evidence.prompts.positive.text, 'a fox');
}
});