Files
ComfyAssets-ComfyUI_PromptM…/tests/js/comfy-metadata.test.js
Vito 27b4895795 fix(resolver): identical sampler branches resolve to their shared prompt pair (#270)
* fix(resolver): identical sampler branches resolve to their shared prompt pair

A LoRA epoch-compare grid runs one sampler per epoch, every one fed by the
same positive encode and the same ConditioningZeroOut negative, and saves
one concatenated image. `_resolve` returned "Multiple sampler branches"
(ambiguous) whenever more than one sampler was found, even though every
branch used the same pair, so the workspace inspector fell back to the
legacy parser and the image could not be paired.

When every branch resolves to identical texts and stored recipes, the
result is now that pair: state resolved, no sampler named (none alone made
the output), reason "Every sampler branch uses the same prompt pair", the
first branch's sides and provenance, and every branch kept in `candidates`
as evidence. A branch that differs, cannot be read, or is not resolved
keeps the result ambiguous. Both profiles share the rule; the default
profile still cannot read ConditioningZeroOut, so the zero-out grid stays
ambiguous for ingestion and keeps the scan's legacy-parser fallback.

Workspace facts: a resolved result without a named sampler over several
listed samplers merges facts per field, as an ambiguous one does (the
grid's shared seed/steps are known, the per-epoch model is ambiguous).

Tests that encoded the old behavior (identical branches => ambiguous):
- test_prompt_pair_resolver: test_multiple_samplers_are_ambiguous_even_when_texts_match
  -> test_multiple_samplers_with_matching_texts_resolve_to_the_shared_pair
  (plus a new differing-texts case that stays ambiguous);
  test_raw_and_detailed_saves_stay_ambiguous_without_an_anchor (same prompt)
  -> ..._of_one_prompt_share_it_without_an_anchor (anchored cases unchanged).
- test_pair_scan: two samplers sharing one prompt now ingest the pair
  ("cat"/"blur") instead of the positive-only fallback.
- test_api_prompt_inspector, test_inspection_outcomes, test_pair_image_linking:
  their "ambiguous" fixtures now use samplers with different prompts.
No parity vector changes (f01/f09 branches are not all resolved).

* fix(inspector): never show the positive as a zeroed negative from the legacy fill

When the server leaves the negative undecided, the workspace fills it from
the legacy browser parser (comfy-metadata.js), which follows
ConditioningZeroOut back to its input and so reads the positive text as
the negative. The workspace now checks the prompt graph first:

- every sampler's negative is a ConditioningZeroOut (Reroutes allowed):
  the negative shows the server's explicitly empty zeroed state
  ({status: resolved, text: "", source: zeroed-conditioning});
- a zero-out elsewhere in a negative path (one branch zeroed, another
  encoded; a zero-out combined with other conditioning), or a
  workflow-only file holding one: the negative is not filled and keeps
  the server's status;
- graphs without a zero-out keep the legacy fill unchanged.

comfy-metadata.js is untouched; the legacy pages keep their reading.

* fix(images): explain why Set category has nothing to set

Set category on a selection where no image is linked to a Library prompt
said only "None of the selected images has a linked prompt." It now says
why and what it means: "The selected images aren't in your Library yet,
so there is no prompt category to set." Unit and page-workflow tests
follow the copy.

* fix(inspector): check only the negative the legacy parse read for a zero-out

Review I1/M2/M3/M5 of the zero-out legacy-fill guard. The guard decided
over every node with a `negative` input, so a graph whose read sampler
encodes "blurry" lost that legacy-filled negative when another branch
zeroed its own (fewer than the legacy pages: below the parity floor).

- comfy-metadata.js gains `legacyNegativeSource`, which returns the graph
  and sampler `negative` link that `extractGenerationParams` reads from.
  It reuses the parser's own sampler pick (`selectSampler`, factored out
  of `fromPromptGraph` without changing any reading). The workspace
  checks only that link: a direct zero-out shows the zeroed empty
  negative, a zero-out further up the path leaves it unfilled, and
  anything else keeps the legacy fill.
- Workflow-only files go through the parser's own workflow conversion,
  so bypassed (mode 4) and muted (mode 2) zero-outs and unconnected ones
  behave as the legacy reading does. A subgraph instance whose definition
  runs a zero-out (directly or nested) leaves the negative unfilled.
- One bounded walk with an index pointer replaces a walk per negative
  input with queue.shift().
- An ambiguous fact that the legacy parse knows (a grid's per-branch
  model) is still filled, documented: the legacy pages show it and this
  page never shows less.

Tests: the reversed mixed graph shows "blurry"; the mixed graph whose
read sampler is zeroed, the zero-out-only grid and a direct workflow
zero-out show the zeroed empty negative; a combined zero-out and a
zero-out subgraph instance stay unfilled; bypassed and unconnected
workflow zero-outs keep the legacy reading. An API-graph Reroute in
front of a zero-out gives the legacy parse no negative, so the server
answer stays (that expectation changed from zeroed).

* fix(images): name the missing link in the Set category toast

Review M4: say that no selected image is linked to a saved prompt, in
the same terms as the partial-success toast and right for one image.

Also review M1: a monitor test that two parallel samplers sharing one
positive and one negative write that pair (the resolver collapse).
2026-10-21 10:55:35 -07:00

1045 lines
68 KiB
JavaScript

// Run with: node --test tests/js/
const test = require("node:test");
const assert = require("node:assert/strict");
const { extractGenerationParams, extractGenerationFacts } = require("../../web/js/comfy-metadata.js");
const parsedFactGraph = value => ({ status: "parsed", value });
const absentFactGraph = () => ({ status: "absent", value: null });
const factFields = ["seed", "steps", "cfg", "sampler", "scheduler", "model"];
function factsFor(graph, extra = {}) {
assert.equal(typeof extractGenerationFacts, "function", "the conservative facts contract is exported");
return extractGenerationFacts({ prompt_graph: parsedFactGraph(graph), workflow_graph: absentFactGraph(), metadataStatus: "available", ...extra });
}
function factGraph(inputs = {}) {
return {
s: { class_type: "KSampler", inputs: { seed: "9007199254740993", steps: 28, cfg: "6.5000000000000000001", sampler_name: "dpmpp_2m", scheduler: "karras", model: ["model", 0], ...inputs } },
model: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "model.safetensors" } },
};
}
test("facts keep unsafe seed and decimal strings exact", () => {
const facts = factsFor(factGraph());
assert.deepEqual(Object.keys(facts), factFields);
assert.deepEqual(factFields.map(field => facts[field].value), ["9007199254740993", "28", "6.5000000000000000001", "dpmpp_2m", "karras", "model.safetensors"]);
for (const fact of Object.values(facts)) assert.deepEqual(fact, {
status: "known", value: fact.value, source: "api-graph", evidence: "configuration", reason: null, sampler_id: "s",
});
const zero = factsFor(factGraph({ seed: 0, cfg: "0", steps: "1" }));
assert.equal(zero.seed.value, "0"); assert.equal(zero.cfg.value, "0");
for (const inputs of [{ seed: 9007199254740992 }, { seed: "1.5" }, { steps: "0" }, { cfg: "NaN" }, { cfg: {} }, { cfg: true }]) {
const key = Object.keys(inputs)[0];
assert.equal(factsFor(factGraph(inputs))[key].status, "unresolved");
}
});
test("facts do not choose equal sampler branches or unrelated model", () => {
const graph = factGraph(); graph.other = structuredClone(graph.s);
const ambiguous = factsFor(graph);
assert.ok(Object.values(ambiguous).every(fact => fact.status === "ambiguous" && fact.value === null));
const selected = factsFor(graph, { resolution: { state: "resolved", sampler_id: "other" } });
assert.equal(selected.seed.sampler_id, "other");
const unrelated = factsFor(factGraph({ model: ["missing", 0] }));
assert.equal(unrelated.model.status, "unresolved");
assert.equal(unrelated.model.value, null);
assert.equal(factsFor({ only: graph.model }).seed.status, "unresolved");
});
test("facts respect named scalar outputs and custom scheduler paths", () => {
const graph = factGraph({ seed: ["primitive", 0], steps: ["steps", 0], cfg: ["cfg", 0], sampler_name: ["sampler", 0], scheduler: ["scheduler", 0], model: ["lora", 0] });
graph.primitive = { class_type: "PrimitiveInt", inputs: { value: "0" } };
graph.steps = { class_type: "PrimitiveInt", inputs: { value: "50" } };
graph.cfg = { class_type: "PrimitiveFloat", inputs: { value: "0" } };
graph.sampler = { class_type: "PrimitiveString", inputs: { value: "euler" } };
graph.scheduler = { class_type: "PrimitiveStringMultiline", inputs: { value: "normal" } };
graph.lora = { class_type: "LoraLoader", inputs: { lora_name: "style", model: ["model", 0] } };
assert.deepEqual(factFields.map(field => factsFor(graph)[field].value), ["0", "50", "0", "euler", "normal", "model.safetensors"]);
graph.settings = { class_type: "UnknownSettings", inputs: { first: 20, second: 30 } };
graph.s.inputs.steps = ["settings", 0];
assert.equal(factsFor(graph).steps.status, "unresolved");
const custom = {
s: { class_type: "SamplerCustomAdvanced", inputs: { guider: ["g", 0], noise: ["n", 0], sampler: ["k", 0], sigmas: ["sigmas", 0] } },
g: { class_type: "CFGGuider", inputs: { model: ["m", 0], cfg: "4.0000000000000001" } },
n: { class_type: "RandomNoise", inputs: { noise_seed: "9007199254740993" } },
k: { class_type: "KSamplerSelect", inputs: { sampler_name: "euler" } },
sigmas: { class_type: "BasicScheduler", inputs: { steps: 22, scheduler: "karras" } },
m: { class_type: "UNETLoader", inputs: { unet_name: "flux.safetensors" } },
};
assert.deepEqual(factFields.map(field => factsFor(custom)[field].value), ["9007199254740993", "22", "4.0000000000000001", "euler", "karras", "flux.safetensors"]);
custom.s = { class_type: "SamplerCustom", inputs: { model: ["m", 0], cfg: 0, noise_seed: 0, sampler: ["k", 0], sigmas: ["sigmas", 0] } };
assert.deepEqual([factsFor(custom).seed.value, factsFor(custom).steps.value, factsFor(custom).cfg.value], ["0", "22", "0"]);
custom.sigmas.inputs = { steps: 22 };
assert.equal(factsFor(custom).scheduler.status, "not-recorded");
assert.equal(factsFor(custom).sampler.value, "euler");
});
test("facts require supported scalar producer types and output slots", () => {
const cases = [
["PrimitiveInt", "seed", "42", true], ["PrimitiveFloat", "cfg", "6.5000000000000000001", true],
["PrimitiveString", "sampler_name", "euler", true], ["PrimitiveStringMultiline", "scheduler", "normal", true],
["PrimitiveString", "seed", "42", false], ["PrimitiveStringMultiline", "steps", "42", false],
["PrimitiveInt", "sampler_name", "euler", false], ["PrimitiveFloat", "scheduler", "normal", false],
["PrimitiveBoolean", "seed", "42", false], ["PrimitiveNode", "seed", "42", false],
["UnknownSettings", "seed", "42", false], ["SamplerCombo", "steps", "42", false],
];
for (const [class_type, input, value, supported] of cases) {
for (const slot of [0, 1, 999, -1, 0.5]) {
const graph = factGraph({ [input]: ["settings", slot] });
graph.settings = { class_type, inputs: { value, [input]: value } };
const fact = factsFor(graph)[input === "sampler_name" ? "sampler" : input];
assert.equal(fact.status, supported && slot === 0 ? "known" : "unresolved", `${class_type} output${slot} as ${input}`);
assert.equal(fact.value, supported && slot === 0 ? value : null);
}
}
const graph = factGraph({ seed: ["primitive", 0] });
graph.primitive = { class_type: "PrimitiveInt", inputs: { seed: "987" } };
assert.equal(factsFor(graph).seed.status, "unresolved", "a matching input is not the output's value");
graph.primitive.inputs = { value: "42", seed: "987" };
assert.equal(factsFor(graph).seed.value, "42");
for (const [input, class_type, inputs] of [
["seed", "RandomNoise", { noise_seed: "42", seed: "42" }],
["sampler_name", "KSamplerSelect", { sampler_name: "euler" }],
["steps", "BasicScheduler", { steps: "42" }],
["cfg", "CFGGuider", { cfg: "7" }],
]) {
const graph = factGraph({ [input]: ["provider", 0] }); graph.provider = { class_type, inputs };
assert.equal(factsFor(graph)[input === "sampler_name" ? "sampler" : input].status, "unresolved", `${class_type} is not a generic scalar output`);
}
});
function customFactGraph() {
return {
s: { class_type: "SamplerCustomAdvanced", inputs: { guider: ["g", 0], noise: ["n", 0], sampler: ["k", 0], sigmas: ["sigmas", 0] } },
g: { class_type: "CFGGuider", inputs: { model: ["m", 0], cfg: ["cfg", 0] } },
n: { class_type: "RandomNoise", inputs: { noise_seed: ["seed", 0] } },
k: { class_type: "KSamplerSelect", inputs: { sampler_name: ["sampler", 0] } },
sigmas: { class_type: "BasicScheduler", inputs: { steps: ["steps", 0], scheduler: ["scheduler", 0] } },
seed: { class_type: "PrimitiveInt", inputs: { value: "9007199254740993" } },
cfg: { class_type: "PrimitiveFloat", inputs: { value: "4.0000000000000001" } },
steps: { class_type: "PrimitiveInt", inputs: { value: 22 } },
sampler: { class_type: "PrimitiveString", inputs: { value: "euler" } },
scheduler: { class_type: "PrimitiveString", inputs: { value: "karras" } },
m: { class_type: "UNETLoader", inputs: { unet_name: "flux.safetensors" } },
};
}
test("facts require supported custom providers and guider outputs", () => {
assert.deepEqual(factFields.map(field => factsFor(customFactGraph())[field].value), ["9007199254740993", "22", "4.0000000000000001", "euler", "karras", "flux.safetensors"]);
for (const [input, nodeId, fields] of [["noise", "n", ["seed"]], ["sampler", "k", ["sampler"]], ["sigmas", "sigmas", ["steps", "scheduler"]], ["guider", "g", ["cfg", "model"]]]) {
for (const slot of [1, 999, -1, 0.5]) {
const graph = customFactGraph(); graph.s.inputs[input] = [nodeId, slot];
for (const field of fields) assert.equal(factsFor(graph)[field].status, "unresolved", `${input} output${slot} ${field}`);
}
for (const class_type of ["UnknownProvider", "PrimitiveInt", "PrimitiveString", "BasicGuider"]) {
if (input === "guider" && class_type === "BasicGuider") continue;
const graph = customFactGraph(); graph[nodeId].class_type = class_type;
for (const field of fields) assert.equal(factsFor(graph)[field].status, "unresolved", `${class_type} cannot provide ${input}`);
}
}
const basic = customFactGraph(); basic.g = { class_type: "BasicGuider", inputs: { model: ["m", 0] } };
assert.equal(factsFor(basic).model.value, "flux.safetensors");
assert.equal(factsFor(basic).cfg.status, "not-recorded");
});
test("facts require model output ancestry with supported loaders and passthroughs", () => {
for (const [class_type, key] of [["CheckpointLoader", "ckpt_name"], ["CheckpointLoaderSimple", "ckpt_name"], ["unCLIPCheckpointLoader", "ckpt_name"], ["UNETLoader", "unet_name"]]) {
for (const slot of [0, 1, 2, 999, -1, 0.5]) {
const graph = factGraph({ model: ["loader", slot] });
graph.loader = { class_type, inputs: { [key]: "selected.safetensors" } };
assert.equal(factsFor(graph).model.value, slot === 0 ? "selected.safetensors" : null, `${class_type} output${slot}`);
assert.equal(factsFor(graph).model.status, slot === 0 ? "known" : "unresolved");
}
}
for (const class_type of ["LoraLoader", "LoraLoaderModelOnly", "UnknownModelPassThrough"]) {
for (const slot of [0, 1, 2, 999]) {
const graph = factGraph({ model: ["lora", slot] }); graph.lora = { class_type, inputs: { model: ["model", 0] } };
assert.equal(factsFor(graph).model.status, class_type !== "UnknownModelPassThrough" && slot === 0 ? "known" : "unresolved", `${class_type} output${slot}`);
}
}
for (const [class_type, inputs] of [["UnknownLoader", { ckpt_name: "fake" }], ["PrimitiveString", { model_name: "fake", value: "fake" }], ["__proto__", { "[object Object]": "fake" }]]) {
const graph = factGraph({ model: ["unknown", 0] }); graph.unknown = { class_type, inputs };
assert.equal(factsFor(graph).model.status, "unresolved");
}
for (const [input, field] of [["sampler_name", "sampler"], ["scheduler", "scheduler"]]) assert.equal(factsFor(factGraph({ [input]: 42 }))[field].status, "unresolved");
const numericModel = factGraph(); numericModel.model.inputs.ckpt_name = 42;
assert.equal(factsFor(numericModel).model.status, "unresolved", "model names require string evidence");
});
// D19: a FaceDetailer re-samples the KSampler's decoded image with its own settings.
function detailerFactGraph(detailer = {}) {
const graph = factGraph({ seed: "42", steps: 25, cfg: "6.5", sampler_name: "euler", scheduler: "normal" });
graph.decode = { class_type: "VAEDecode", inputs: { samples: ["s", 0], vae: ["model", 2] } };
graph.d = { class_type: "FaceDetailer", inputs: { image: ["decode", 0], model: ["model", 0], seed: "7", steps: 12, cfg: "6.5", sampler_name: "euler", scheduler: "normal", denoise: "0.4", ...detailer } };
return graph;
}
const statusValues = facts => Object.fromEntries(factFields.map(field => [field, [facts[field].status, facts[field].value]]));
test("facts use resolution.sampler_id for detailer-stage nodes", () => {
// Resolved (the stages' prompts agree): the final stage the server named supplies every fact.
const resolved = factsFor(detailerFactGraph(), { resolution: { state: "resolved", sampler_id: "d" } });
assert.deepEqual(statusValues(resolved), {
seed: ["known", "7"], steps: ["known", "12"], cfg: ["known", "6.5"],
sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"],
});
assert.ok(factFields.every(field => resolved[field].sampler_id === "d" && resolved[field].evidence === "configuration"));
// The upstream KSampler is also accepted when it is the one resolved.
assert.equal(factsFor(detailerFactGraph(), { resolution: { state: "resolved", sampler_id: "s" } }).seed.value, "42");
// Ambiguous stages: merged per field, never the detailer's or the upstream value alone.
const ambiguous = factsFor(detailerFactGraph(), { resolution: { state: "ambiguous", sampler_id: "d", reason_code: "multistage-differs" } });
assert.deepEqual(statusValues(ambiguous), {
seed: ["ambiguous", null], steps: ["ambiguous", null], cfg: ["known", "6.5"],
sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"],
});
assert.ok(factFields.every(field => ambiguous[field].sampler_id === null), "a merged fact names no single sampler");
// A field one stage cannot read and the others agree on stays unresolved; not-recorded is not agreement.
const unreadable = factsFor(detailerFactGraph({ cfg: ["missing", 0] }), { resolution: { state: "ambiguous", sampler_id: "d" } });
assert.deepEqual([unreadable.cfg.status, unreadable.cfg.value], ["unresolved", null]);
const unrecorded = detailerFactGraph(); delete unrecorded.d.inputs.scheduler;
assert.equal(factsFor(unrecorded, { resolution: { state: "ambiguous", sampler_id: "d" } }).scheduler.status, "unresolved");
// Two detailer stages over one KSampler: three stages merge.
const chained = detailerFactGraph({ seed: "42" });
chained.d2 = { class_type: "FaceDetailer", inputs: { image: ["d", 0], model: ["model", 0], seed: "42", steps: 25, cfg: "3", sampler_name: "euler", scheduler: "normal" } };
assert.deepEqual(statusValues(factsFor(chained, { resolution: { state: "ambiguous", sampler_id: "d2" } })), {
seed: ["known", "42"], steps: ["ambiguous", null], cfg: ["ambiguous", null],
sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"],
});
// Without a resolution, two samplers stay ambiguous (today's rules, now over the samplers table).
assert.ok(Object.values(factsFor(detailerFactGraph())).every(fact => fact.status === "ambiguous"));
// A sampler_id the graph or the samplers table does not hold never falls back to a guess.
for (const sampler_id of ["missing", "decode", "model", "__proto__"]) {
for (const state of ["resolved", "ambiguous"]) {
const facts = factsFor(detailerFactGraph(), { resolution: { state, sampler_id } });
assert.ok(Object.values(facts).every(fact => fact.status === "unresolved" && fact.value === null), `${state} ${sampler_id}`);
}
}
// Stage ancestry that cannot be walked is unresolved, not a partial merge.
const cyclic = detailerFactGraph(); cyclic.decode.inputs.samples = ["d", 0];
const unlinked = detailerFactGraph({ image: "not a link" });
for (const graph of [cyclic, unlinked]) {
assert.ok(Object.values(factsFor(graph, { resolution: { state: "ambiguous", sampler_id: "d" } })).every(fact => fact.status === "unresolved"));
}
const wide = detailerFactGraph();
let previous = ["s", 0];
for (let index = 0; index < 600; index++) { wide[`hop${index}`] = { class_type: "ImageScale", inputs: { image: previous } }; previous = [`hop${index}`, 0]; }
wide.decode.inputs.samples = ["s", 0]; wide.d.inputs.image = previous;
assert.ok(Object.values(factsFor(wide, { resolution: { state: "ambiguous", sampler_id: "d" } })).every(fact => fact.status === "unresolved"), "the stage walk is bounded by MAX_VISITS");
});
test("a shared pair resolved across sampler branches merges facts per field (#249)", () => {
// The server resolves identical branches with no sampler named: settings the
// branches agree on are known, those they differ on (a LoRA per epoch) are ambiguous.
const shared = { resolution: { state: "resolved", sampler_id: null, reason: "Every sampler branch uses the same prompt pair" } };
const grid = factGraph();
grid.lora = { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "epoch_10.safetensors" } };
grid.other = structuredClone(grid.s); grid.other.inputs.model = ["lora", 0];
assert.deepEqual(statusValues(factsFor(grid, shared)), {
seed: ["known", "9007199254740993"], steps: ["known", "28"], cfg: ["known", "6.5000000000000000001"],
sampler: ["known", "dpmpp_2m"], scheduler: ["known", "karras"], model: ["ambiguous", null],
});
assert.ok(Object.values(factsFor(grid, shared)).every(fact => fact.sampler_id === null));
// One listed sampler keeps reading that sampler alone.
assert.equal(factsFor(factGraph(), shared).model.value, "model.safetensors");
});
test("ambiguous results without a sampler_id merge facts across every listed stage", () => {
const ambiguous = { resolution: { state: "ambiguous", sampler_id: null, reason: "Multiple sampler branches" } };
// f01b's shape: an upstream sampler and a detailer whose settings partly differ.
assert.deepEqual(statusValues(factsFor(detailerFactGraph(), ambiguous)), {
seed: ["ambiguous", null], steps: ["ambiguous", null], cfg: ["known", "6.5"],
sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"],
});
assert.ok(Object.values(factsFor(detailerFactGraph(), ambiguous)).every(fact => fact.sampler_id === null));
// Equal independent branches agree on every field once the server says ambiguous;
// without a resolution they stay ambiguous (today's rules, reversed in PR3).
const equal = factGraph(); equal.other = structuredClone(equal.s);
assert.ok(Object.values(factsFor(equal, ambiguous)).every(fact => fact.status === "known"));
assert.ok(Object.values(factsFor(equal)).every(fact => fact.status === "ambiguous"));
// A walk that fails keeps every field unresolved; so does a graph with no listed sampler.
const cyclic = detailerFactGraph(); cyclic.decode.inputs.samples = ["d", 0];
const broken = detailerFactGraph(); broken.d.inputs.image = ["gone", 0];
for (const graph of [cyclic, broken, { model: factGraph().model }]) {
assert.ok(Object.values(factsFor(graph, ambiguous)).every(fact => fact.status === "unresolved" && fact.value === null));
}
const many = factGraph();
for (let index = 0; index < 40; index++) many[`s${index}`] = structuredClone(many.s);
assert.ok(Object.values(factsFor(many, ambiguous)).every(fact => fact.status === "unresolved"), "more stages than the candidate cap");
});
test("stage walks fail on unlisted sampler-shaped nodes like the resolver", () => {
const shaped = [
{ positive: ["model", 0] }, { latent_image: ["model", 0] }, { guider: ["model", 0] }, { negative: ["model", 0] },
{ pipe: ["model", 0] }, { basic_pipe: ["model", 0] }, { detailer_pipe: ["model", 0] }, { sampler: ["model", 0] },
{ sigmas: ["model", 0] }, { noise: ["model", 0] }, { text_embeds: ["model", 0] }, { hyvid_embeds: ["model", 0] },
{ model: ["model", 0], conditioning: ["model", 0] }, { model: ["model", 0], neg_conditioning: ["model", 0] },
{ model: ["model", 0], samples: ["model", 0] }, { model: ["model", 0], latent: ["model", 0] },
];
for (const inputs of shaped) {
const graph = detailerFactGraph();
graph.efficient = { class_type: "MysterySampler", inputs: { ...inputs, image: ["decode", 0] } };
graph.d.inputs.image = ["efficient", 0];
for (const sampler_id of ["d", null]) {
const facts = factsFor(graph, { resolution: { state: "ambiguous", sampler_id } });
assert.ok(Object.values(facts).every(fact => fact.status === "unresolved"), `${JSON.stringify(inputs)} ${sampler_id}`);
}
}
// Model patches (model alone, or with clip) and listed table classes are walked through.
for (const [class_type, inputs] of [["ModelPatch", { model: ["model", 0] }], ["LoraPatch", { model: ["model", 0], clip: ["model", 1] }], ["ImageScale", { samples: ["model", 0] }], ["CFGGuider", { model: ["model", 0], positive: ["model", 0] }]]) {
const graph = detailerFactGraph();
graph.hop = { class_type, inputs: { ...inputs, image: ["decode", 0] } };
graph.d.inputs.image = ["hop", 0];
assert.equal(factsFor(graph, { resolution: { state: "ambiguous", sampler_id: "d" } }).seed.status, "ambiguous", class_type);
}
});
test("unresolved and absent results over several stages merge facts per field", () => {
// Review I-1: the run's settings are facts even when its prompt is not, e.g. an
// upstream prompt behind an unsupported node (LoRAfromText) leaves the server unresolved.
const merged = {
seed: ["ambiguous", null], steps: ["ambiguous", null], cfg: ["known", "6.5"],
sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"],
};
for (const resolution of [
{ state: "unresolved", sampler_id: "d", reason_code: "unsupported-node" },
{ state: "unresolved", sampler_id: null, reason_code: "malformed" },
{ state: "absent", sampler_id: "d" },
]) {
const facts = factsFor(detailerFactGraph(), { resolution });
assert.deepEqual(statusValues(facts), merged, JSON.stringify(resolution));
assert.ok(factFields.every(field => facts[field].sampler_id === null));
}
// A stage missing a field leaves it unresolved; a failed walk leaves everything unresolved.
const unrecorded = detailerFactGraph(); delete unrecorded.d.inputs.scheduler;
assert.equal(factsFor(unrecorded, { resolution: { state: "unresolved", sampler_id: "d" } }).scheduler.status, "unresolved");
const cyclic = detailerFactGraph(); cyclic.decode.inputs.samples = ["d", 0];
assert.ok(Object.values(factsFor(cyclic, { resolution: { state: "unresolved", sampler_id: "d" } })).every(fact => fact.status === "unresolved"));
// One listed sampler keeps today's rule: its own facts, attributed to it.
const single = factsFor(factGraph(), { resolution: { state: "unresolved", sampler_id: "s" } });
assert.ok(factFields.every(field => single[field].status === "known" && single[field].sampler_id === "s"));
});
test("a named sampler limits absent and unresolved facts to its own stages", () => {
// Codex P2: the server dropped a preview-only detailer and named the saved sampler, so
// the detailer's settings never leak into the saved file's facts.
const graph = detailerFactGraph();
graph.save = { class_type: "SaveImage", inputs: { images: ["decode", 0] } };
graph.preview = { class_type: "PreviewImage", inputs: { images: ["d", 0] } };
const own = {
seed: ["known", "42"], steps: ["known", "25"], cfg: ["known", "6.5"],
sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"],
};
for (const state of ["absent", "unresolved"]) assert.deepEqual(statusValues(factsFor(graph, { resolution: { state, sampler_id: "s" } })), own, state);
// Without a named sampler every listed sampler still merges.
assert.equal(factsFor(graph, { resolution: { state: "absent", sampler_id: null } }).seed.status, "ambiguous");
});
test("stage walks follow only the resolver's links", () => {
// Review M-2: `["eff", -1]` is not a link to the server, so it is not walked.
for (const slot of [-1, 0.5, "0"]) {
const graph = detailerFactGraph();
graph.eff = { class_type: "MysterySampler", inputs: { positive: ["model", 0], model: ["model", 0] } };
graph.decode.inputs.extra = ["eff", slot];
assert.equal(factsFor(graph, { resolution: { state: "ambiguous", sampler_id: "d" } }).seed.status, "ambiguous", String(slot));
const shaped = detailerFactGraph();
shaped.eff = { class_type: "Unlisted", inputs: { positive: ["model", slot], image: ["decode", 0] } };
shaped.d.inputs.image = ["eff", 0];
assert.equal(factsFor(shaped, { resolution: { state: "ambiguous", sampler_id: "d" } }).seed.status, "ambiguous", `shaped ${slot}`);
}
const linked = detailerFactGraph();
linked.eff = { class_type: "MysterySampler", inputs: { positive: ["model", 0], model: ["model", 0] } };
linked.decode.inputs.extra = ["eff", 0];
assert.equal(factsFor(linked, { resolution: { state: "ambiguous", sampler_id: "d" } }).seed.status, "unresolved");
});
test("Flux2Scheduler provides steps and scheduler is its node type", () => {
const graph = customFactGraph();
graph.sigmas = { class_type: "Flux2Scheduler", inputs: { steps: 4, width: 1024, height: 1024 } };
const facts = factsFor(graph);
assert.deepEqual([facts.steps.status, facts.steps.value], ["known", "4"]);
assert.deepEqual([facts.scheduler.status, facts.scheduler.value, facts.scheduler.evidence], ["known", "Flux2Scheduler", "configuration"]);
for (const slot of [1, -1]) {
const linked = customFactGraph(); linked.sigmas = graph.sigmas; linked.s.inputs.sigmas = ["sigmas", slot];
assert.equal(factsFor(linked).scheduler.status, "unresolved", `output${slot}`);
}
// Its own inputs cannot replace the node type, and steps keep exact-integer rules.
const named = customFactGraph(); named.sigmas = { class_type: "Flux2Scheduler", inputs: { steps: 0, scheduler: "karras" } };
assert.equal(factsFor(named).scheduler.value, "Flux2Scheduler");
assert.equal(factsFor(named).steps.status, "unresolved");
});
test("ModelSampling passthroughs reach UNETLoader", () => {
const graph = customFactGraph();
graph.g.inputs.model = ["flux", 0];
graph.flux = { class_type: "ModelSamplingFlux", inputs: { model: ["aura", 0], max_shift: "1.15", base_shift: "0.5" } };
graph.aura = { class_type: "ModelSamplingAuraFlow", inputs: { model: ["sd3", 0], shift: "1.73" } };
graph.sd3 = { class_type: "ModelSamplingSD3", inputs: { model: ["lora", 0], shift: "3" } };
graph.lora = { class_type: "LoraLoaderModelOnly", inputs: { model: ["m", 0], lora_name: "style", strength_model: "1" } };
assert.deepEqual([factsFor(graph).model.status, factsFor(graph).model.value], ["known", "flux.safetensors"]);
for (const node of ["flux", "aura", "sd3"]) {
const wrongSlot = structuredClone(graph);
const parent = { flux: "g", aura: "flux", sd3: "aura" }[node];
wrongSlot[parent].inputs.model = [node, 1];
assert.equal(factsFor(wrongSlot).model.status, "unresolved", `${node} output1`);
const unlisted = structuredClone(graph); unlisted[node].class_type = "ModelSamplingUnknown";
assert.equal(factsFor(unlisted).model.status, "unresolved", `${node} unlisted`);
}
});
test("vectors.json pins full facts for every inspection fixture", () => {
const fs = require("node:fs");
const path = require("node:path");
const vectors = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "fixtures", "inspection_parity", "vectors.json"), "utf8"));
const inspection = vectors.filter(vector => vector.profile === "inspection");
assert.ok(inspection.length > 0);
const statuses = new Set(["known", "ambiguous", "unresolved", "not-recorded"]);
for (const vector of inspection) {
const facts = vector.expect.facts;
assert.deepEqual(Object.keys(facts), factFields, vector.name);
for (const field of factFields) {
assert.ok(statuses.has(facts[field].status), `${vector.name} ${field}`);
assert.equal(facts[field].value === null, facts[field].status !== "known", `${vector.name} ${field} value`);
}
}
// f19: KSampler seed 42 / steps 25 / cfg 6.5 then FaceDetailer seed 7 / steps 12 / cfg 4.5, different prompts.
const f19 = inspection.find(vector => vector.fixture === "f19");
assert.equal(f19.expect.state, "ambiguous");
assert.deepEqual(Object.fromEntries(factFields.map(field => [field, f19.expect.facts[field].status])), {
seed: "ambiguous", steps: "ambiguous", cfg: "ambiguous", sampler: "known", scheduler: "known", model: "known",
});
});
test("wide workflow lookups retain selected core sampler facts", () => {
const nodes = [{ id: "text", type: "PrimitiveString", widgets_values_named: { value: "unrelated text" } }], links = [];
let linkId = 1;
for (let index = 0; index < 20; index++) {
nodes.push({ id: `r${index}`, type: "Reroute", inputs: [{ name: "value", link: linkId }] });
links.push([linkId++, index ? `r${index - 1}` : "text", 0, `r${index}`, 0, "STRING"]);
}
for (let index = 0; index < 40; index++) {
nodes.push({ id: `encoder${index}`, type: "CLIPTextEncode", inputs: [{ name: "text", link: linkId }] });
links.push([linkId++, "r19", 0, `encoder${index}`, 0, "STRING"]);
}
nodes.push({ id: "model", type: "CheckpointLoaderSimple", widgets_values_named: { ckpt_name: "selected.safetensors" } },
{ id: "steps", type: "PrimitiveInt", widgets_values_named: { value: 28 } },
{ id: "s", type: "KSampler", widgets_values: ["9007199254740993", "fixed", 28, "6.5000000000000000001", "euler", "normal"], inputs: [{ name: "model", link: linkId }, { name: "steps", link: linkId + 1 }] });
links.push([linkId, "model", 0, "s", 0, "MODEL"], [linkId + 1, "steps", 0, "s", 1, "INT"]);
const facts = factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph({ nodes, links }) });
assert.deepEqual(factFields.map(field => facts[field].value), ["9007199254740993", "28", "6.5000000000000000001", "euler", "normal", "selected.safetensors"]);
});
test("workflow facts require supported passthrough output slots and bypass ancestry", () => {
function workflow(type, slot, mode) {
const pass = { id: "pass", type, ...(mode === undefined ? {} : { mode }), widgets_values: ["selected"], inputs: [{ name: type === "Reroute" ? "value" : "model", type: "MODEL", link: 1 }] };
const nodes = [{ id: "m", type: "CheckpointLoaderSimple", widgets_values_named: { ckpt_name: "selected.safetensors" } }, pass,
{ id: "s", type: "KSampler", widgets_values: [0, "fixed", 28, "0", "euler", "normal"], inputs: [{ name: "model", link: 2 }] }];
if (type === "GetNode") {
nodes.push({ id: "set", type: "SetNode", widgets_values: ["selected"], inputs: [{ name: "model", link: 1 }] });
pass.inputs = [];
}
return { nodes, links: [[1, "m", 0, "pass", 0, "MODEL"], [2, "pass", slot, "s", 0, "MODEL"]] };
}
const model = value => factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(value) }).model;
for (const [type, mode] of [["Reroute", undefined], ["GetNode", undefined], ["SetNode", undefined], ["LoraLoader", 4], ["LoraLoaderModelOnly", 4]]) {
for (const slot of [0, 1, 999, -1, 0.5]) {
assert.equal(model(workflow(type, slot, mode)).status, slot === 0 ? "known" : "unresolved", `${type} output${slot}`);
}
}
assert.equal(model(workflow("UnknownBypass", 0, 4)).status, "unresolved");
const duplicate = workflow("LoraLoader", 0, 4);
duplicate.nodes[1].inputs.push({ name: "model", type: "MODEL", link: 1 });
assert.equal(model(duplicate).status, "unresolved", "bypass model input is uniquely named");
const wrongOrigin = workflow("Reroute", 0); wrongOrigin.links[0][2] = 1;
assert.equal(model(wrongOrigin).status, "unresolved", "passthrough cannot turn CLIP into MODEL");
const cycle = workflow("Reroute", 0); cycle.links[0][1] = "pass";
assert.equal(model(cycle).status, "unresolved");
const unnamed = workflow("GetNode", 0);
delete unnamed.nodes.find(node => node.id === "pass").widgets_values;
delete unnamed.nodes.find(node => node.id === "set").widgets_values;
assert.equal(model(unnamed).status, "unresolved", "missing Get/Set names are not a source association");
const scalar = factGraph({ seed: ["bypass", 0] });
scalar.bypass = { class_type: "LoraLoader", inputs: { model: ["primitive", 0] } };
scalar.primitive = { class_type: "PrimitiveInt", inputs: { value: 42 } };
const scalarWorkflow = factWorkflowFromGraph(scalar); scalarWorkflow.nodes.find(node => node.id === "bypass").mode = 4;
assert.equal(factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(scalarWorkflow) }).seed.status, "unresolved", "bypassed MODEL output cannot become a scalar");
for (const [input, field] of [["noise", "seed"], ["sampler", "sampler"], ["sigmas", "steps"], ["guider", "model"]]) {
const graph = customFactGraph(), target = graph.s.inputs[input];
graph.s.inputs[input] = ["bypass", 0]; graph.bypass = { class_type: "LoraLoaderModelOnly", inputs: { model: target } };
const workflow = factWorkflowFromGraph(graph); workflow.nodes.find(node => node.id === "bypass").mode = 4;
assert.equal(factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(workflow) })[field].status, "unresolved", `MODEL output cannot become ${input}`);
}
});
function factWorkflowFromGraph(graph) {
const nodes = [], links = [];
for (const [id, node] of Object.entries(graph)) {
const inputs = [], widgets_values_named = {};
for (const [name, value] of Object.entries(node.inputs)) {
if (Array.isArray(value)) {
const link = links.length + 1;
links.push([link, value[0], value[1], id, inputs.length, "*"]);
inputs.push({ name, link });
} else widgets_values_named[name] = value;
}
nodes.push({ id, type: node.class_type, inputs, widgets_values_named });
}
return { nodes, links };
}
test("workflow facts count combined original ancestry once across passthroughs and providers", () => {
const inspect = graph => factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(factWorkflowFromGraph(graph)) });
function chain(graph, reroutes, primitives, model = false) {
let link = model ? ["model", 0] : null;
for (let index = 0; index < primitives; index++) {
graph[`p${index}`] = { class_type: model ? "LoraLoader" : "PrimitiveInt", inputs: model ? { model: link } : { value: link || "42" } };
link = [`p${index}`, 0];
}
for (let index = 0; index < reroutes; index++) {
graph[`r${index}`] = { class_type: "Reroute", inputs: { value: link } };
link = [`r${index}`, 0];
}
return link;
}
for (const [reroutes, known] of [[32, true], [33, false]]) {
const scalar = factGraph(); scalar.s.inputs.seed = chain(scalar, reroutes, 32);
assert.equal(inspect(scalar).seed.status, known ? "known" : "unresolved", `${reroutes}+32 scalar links`);
const model = factGraph(); model.s.inputs.model = chain(model, reroutes, 31, true);
assert.equal(inspect(model).model.status, known ? "known" : "unresolved", `${reroutes}+31 LoRA+loader links`);
const noise = customFactGraph();
noise.n.inputs.noise_seed = chain(noise, reroutes - 1, 31);
noise.noiseAlias = { class_type: "Reroute", inputs: { value: ["n", 0] } };
noise.s.inputs.noise = ["noiseAlias", 0];
assert.equal(inspect(noise).seed.status, known ? "known" : "unresolved", `noise alias+provider+${reroutes - 1}+31 links`);
const guider = customFactGraph();
guider.model = { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "model.safetensors" } };
guider.g.inputs.model = chain(guider, reroutes - 1, 30, true);
guider.guiderAlias = { class_type: "Reroute", inputs: { value: ["g", 0] } };
guider.s.inputs.guider = ["guiderAlias", 0];
assert.equal(inspect(guider).model.status, known ? "known" : "unresolved", `guider alias+provider+${reroutes - 1}+30 LoRA+loader links`);
}
const cycle = factGraph();
cycle.s.inputs.seed = ["r", 0]; cycle.r = { class_type: "Reroute", inputs: { value: ["p", 0] } }; cycle.p = { class_type: "PrimitiveInt", inputs: { value: ["r", 0] } };
assert.equal(inspect(cycle).seed.status, "unresolved");
cycle.s.inputs.model = ["r", 0]; cycle.p = { class_type: "LoraLoader", inputs: { model: ["r", 0] } };
assert.equal(inspect(cycle).model.status, "unresolved");
for (const [reroutes, known] of [[32, true], [33, false]]) {
const graph = factGraph(), value = chain(graph, reroutes, 31);
const workflow = factWorkflowFromGraph(graph);
const sourceLink = workflow.links.length + 1;
workflow.nodes.push({ id: "set", type: "SetNode", widgets_values: ["seed"], inputs: [{ name: "value", link: sourceLink }] }, { id: "get", type: "GetNode", widgets_values: ["seed"], inputs: [] });
workflow.links.push([sourceLink, value[0], 0, "set", 0, "INT"], [sourceLink + 1, "get", 0, "s", 0, "INT"]);
workflow.nodes.find(node => node.id === "s").inputs.push({ name: "seed", link: sourceLink + 1 });
const fact = factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(workflow) }).seed;
assert.equal(fact.status, known ? "known" : "unresolved", `Get+${reroutes}+31 links, no extra setter expansion`);
}
});
test("workflow sampler selection retains prototype-named node identities", () => {
const workflow = { nodes: [
{ id: "__proto__", type: "KSampler", widgets_values: [1, "fixed", 20, "7", "euler", "normal"] },
{ id: "other", type: "KSampler", widgets_values: [2, "fixed", 20, "7", "euler", "normal"] },
], links: [] };
assert.ok(Object.values(factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(workflow) })).every(fact => fact.status === "ambiguous"));
});
test("workflow-only facts are configuration without creating sendable roles", () => {
const workflow = { nodes: [
{ id: 1, type: "UNETLoader", widgets_values: ["flux.safetensors", "default"] },
{ id: 2, type: "SetNode", widgets_values: ["model"], inputs: [{ name: "MODEL", link: 1 }] },
{ id: 3, type: "GetNode", widgets_values: ["model"] },
{ id: 4, type: "KSamplerAdvanced", widgets_values: ["enable", "9007199254740993", "fixed", 28, "6.5000000000000000001", "euler", "normal", 0, 28, "disable"], inputs: [{ name: "model", link: 2 }] },
], links: [[1, 1, 0, 2, 0, "MODEL"], [2, 3, 0, 4, 0, "MODEL"]] };
const facts = factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(workflow) });
assert.deepEqual(factFields.map(field => facts[field].value), ["9007199254740993", "28", "6.5000000000000000001", "euler", "normal", "flux.safetensors"]);
assert.ok(Object.values(facts).every(fact => fact.source === "workflow" && fact.evidence === "configuration"));
assert.equal(facts.positive, undefined);
workflow.nodes.push({ ...workflow.nodes[3] });
assert.ok(Object.values(factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(workflow) })).every(fact => fact.status === "unresolved"));
});
test("limited projection stays unresolved", () => {
for (const extra of [
{ prompt_graph: { status: "limited", value: null } },
{ prompt_graph: absentFactGraph(), workflow_graph: { status: "limited", value: null } },
{ prompt_graph: absentFactGraph(), metadataStatus: "limited", parameters: null },
]) {
const facts = factsFor(null, extra);
assert.ok(Object.values(facts).every(fact => fact.status === "unresolved" && fact.value === null && fact.reason === "projection-limited"));
}
const unreadable = factsFor(null, { prompt_graph: absentFactGraph(), metadataStatus: "unreadable" });
assert.equal(unreadable.seed.reason, "metadata-unreadable");
const absent = factsFor(null, { prompt_graph: absentFactGraph(), metadataStatus: "absent" });
assert.equal(absent.seed.status, "not-recorded");
});
test("facts preserve exact video-projected decimals without reading reserialized raw", () => {
const graph = factGraph();
const facts = factsFor(graph, { recordFormats: { prompt: "reserialized-json" }, raw: { prompt: '{"s":{"inputs":{"seed":9007199254740992,"cfg":6.5}}}' } });
assert.equal(facts.seed.value, "9007199254740993");
assert.equal(facts.cfg.value, "6.5000000000000000001");
assert.equal(factsFor(factGraph({ cfg: "1E+1000000000" })).cfg.value, "1E+1000000000");
});
test("nontext parameters remain unresolved", () => {
const parameters = "A portrait\nNegative prompt: blur\nSteps: 28, Sampler: Euler, Schedule type: Karras, CFG scale: 6.5000000000000000001, Seed: 9007199254740993, Model: model";
const base = { prompt_graph: absentFactGraph(), parameters };
for (const format of [undefined, "reserialized-json", "invalid"]) {
const facts = factsFor(null, { ...base, recordFormats: format ? { parameters: format } : {} });
assert.ok(Object.values(facts).every(fact => fact.status === "unresolved" && fact.value === null));
}
const facts = factsFor(null, { ...base, recordFormats: { parameters: "original-text" } });
assert.deepEqual(factFields.map(field => facts[field].value), ["9007199254740993", "28", "6.5000000000000000001", "Euler", "Karras", "model"]);
assert.ok(Object.values(facts).every(fact => fact.source === "embedded-parameters" && fact.evidence === "embedded-parameters"));
for (const invalid of [parameters + "\nSteps: 5, Seed: 2", parameters + ", Seed: 1", parameters + "\ntrailing", parameters.replace("Seed: 9007199254740993", "Seed: -1"), parameters.replace("Karras", "invented"), parameters.replace("Negative prompt: blur", "Negative prompt: blur\nNegative prompt: again")]) {
assert.ok(Object.values(factsFor(null, { ...base, parameters: invalid, recordFormats: { parameters: "original-text" } })).some(fact => fact.status === "unresolved"));
}
});
test("malformed or unsupported API evidence blocks plausible workflow and parameter fallback", () => {
const workflow = { nodes: [{ id: 1, type: "KSampler", widgets_values: [42, "fixed", 20, 7, "euler", "normal"] }], links: [] };
const extra = { workflow_graph: parsedFactGraph(workflow), parameters: "portrait\nSteps: 20, Sampler: Euler, CFG scale: 7, Seed: 42", recordFormats: { parameters: "original-text" } };
for (const prompt_graph of [{ status: "malformed", value: null }, parsedFactGraph({ s: { class_type: "UnknownSampler", inputs: { seed: 99 } } }), parsedFactGraph({ s: { class_type: "KSampler", inputs: [] } })]) {
const facts = factsFor(null, { ...extra, prompt_graph });
assert.ok(Object.values(facts).every(fact => fact.status === "unresolved" && fact.value === null));
assert.ok(Object.values(facts).every(fact => fact.source !== "workflow" && fact.source !== "embedded-parameters"));
}
});
test("conservative facts bound cycles fan-out visits and link depth", () => {
const graph = factGraph({ seed: ["loop", 0], model: ["loop", 0] });
graph.loop = { class_type: "Unknown", inputs: { seed: ["loop", 0], model: ["loop", 0], a: ["loop", 0], b: ["loop", 0] } };
assert.equal(factsFor(graph).seed.status, "unresolved");
assert.equal(factsFor(graph).model.status, "unresolved");
graph.loop = { class_type: "PrimitiveInt", inputs: { value: ["loop", 0] } };
assert.equal(factsFor(graph).seed.status, "unresolved", "registered scalar cycle is bounded");
graph.loop = { class_type: "LoraLoader", inputs: { model: ["loop", 0] } };
assert.equal(factsFor(graph).model.status, "unresolved", "registered model cycle is bounded");
const chain = depth => {
const value = factGraph({ seed: ["p0", 0] });
for (let index = 0; index < depth; index++) value[`p${index}`] = { class_type: "PrimitiveInt", inputs: { value: index === depth - 1 ? "42" : [`p${index + 1}`, 0] } };
return value;
};
assert.equal(factsFor(chain(64)).seed.value, "42");
assert.equal(factsFor(chain(65)).seed.status, "unresolved");
const custom = chain(64);
custom.s = { class_type: "SamplerCustomAdvanced", inputs: { noise: ["noise", 0] } };
custom.noise = { class_type: "RandomNoise", inputs: { noise_seed: ["p0", 0] } };
assert.equal(factsFor(custom).seed.status, "unresolved", "the provider itself consumes one of the 64 allowed links");
});
// Spec section 10: graph size is not a facts bound. Only the per-lookup visit
// set (MAX_VISITS) and the projection's 20,000-node ceiling (`limited`) bound work.
test("facts resolve on a parsed 600-node graph with one KSampler", () => {
const large = factGraph();
for (let index = 0; index < 598; index++) large[`unused${index}`] = { class_type: "PrimitiveInt", inputs: { value: index } };
assert.equal(Object.keys(large).length, 600);
const facts = factsFor(large);
for (const [field, value] of [["seed", "9007199254740993"], ["steps", "28"], ["cfg", "6.5000000000000000001"]]) {
assert.equal(facts[field].status, "known", field);
assert.equal(facts[field].value, value);
assert.equal(facts[field].reason, null);
}
});
test("workflow facts are not gated by UI node count", () => {
const nodes = [
{ id: 1, type: "CheckpointLoaderSimple", widgets_values: ["wide.safetensors"] },
{ id: 2, type: "KSampler", widgets_values: [42, "fixed", 20, "7", "euler", "normal"], inputs: [{ name: "model", link: 1 }] },
];
for (let index = 0; index < 598; index++) nodes.push({ id: `note${index}`, type: "Note", widgets_values: [`note ${index}`] });
const workflow = { nodes, links: [[1, 1, 0, 2, 0, "MODEL"]] };
const facts = factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(workflow) });
assert.deepEqual(factFields.map(field => facts[field].status), Array(6).fill("known"));
assert.deepEqual(factFields.map(field => facts[field].value), ["42", "20", "7", "euler", "normal", "wide.safetensors"]);
assert.ok(Object.values(facts).every(fact => fact.source === "workflow" && fact.reason === null));
});
// Same shape as the workflow attached to #75: PromptManager nodes are saved as
// CLIPTextEncode with extra inputs, and the checkpoint loader is pysssss'.
const issue75Prompt = {
1: {
class_type: "KSampler",
inputs: {
seed: 480655185510613, steps: 25, cfg: 5.5,
sampler_name: "euler_ancestral", scheduler: "normal", denoise: 0.8,
model: ["20", 0], positive: ["39", 0], negative: ["40", 0], latent_image: ["36", 0],
},
},
8: { class_type: "CLIPSetLastLayer", inputs: { stop_at_clip_layer: -2, clip: ["20", 1] } },
20: {
class_type: "CheckpointLoader|pysssss",
inputs: { ckpt_name: "Anime\\Model-XL.safetensors", prompt: "[none]", example: "[none]" },
},
39: {
class_type: "CLIPTextEncode",
inputs: {
text: "1girl, cat_ears, smile", category: "", tags: "", search_text: "",
prepend_text: "masterpiece, best quality, ", append_text: "", clip: ["8", 0],
},
},
40: {
class_type: "CLIPTextEncode",
inputs: {
text: "3d, crowd", category: "", tags: "", search_text: "",
prepend_text: "bad quality, worst quality, ", append_text: "", clip: ["8", 0],
},
},
};
test("#75: traces PromptManager prompts and pysssss checkpoint from the sampler", () => {
const r = extractGenerationParams({ prompt: issue75Prompt });
assert.equal(r.positivePrompt, "masterpiece, best quality, 1girl, cat_ears, smile");
assert.equal(r.negativePrompt, "bad quality, worst quality, 3d, crowd");
assert.equal(r.checkpoint, "Anime\\Model-XL.safetensors");
assert.deepEqual(
[r.seed, r.steps, r.cfgScale, r.sampler],
[480655185510613, 25, 5.5, "euler_ancestral"],
);
});
test("#75: workflow blob does not override values from the prompt graph", () => {
const workflow = { nodes: [{ type: "KSampler", widgets_values: [1, "randomize", 99, 7, "ddim"] }] };
const r = extractGenerationParams({ prompt: issue75Prompt, workflow });
assert.equal(r.steps, 25);
assert.equal(r.cfgScale, 5.5);
});
test("#75: recovers per-node text when SaveImage patch overwrote every PromptManager node", () => {
// Pre-fix images: both nodes carry the final positive text in the prompt graph,
// but the workflow graph still has each node's real widget values.
const positiveFinal = "masterpiece, best quality, 1girl, smile";
const prompt = {
1: { class_type: "KSampler", inputs: { seed: 1, steps: 20, cfg: 7, sampler_name: "euler", positive: ["39", 0], negative: ["40", 0], model: ["20", 0] } },
20: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "m.safetensors" } },
39: { class_type: "PromptManager", inputs: { text: positiveFinal, prepend_text: "masterpiece, best quality,", append_text: "" } },
40: { class_type: "PromptManager", inputs: { text: positiveFinal, prepend_text: "bad quality,", append_text: "" } },
};
const workflow = {
nodes: [
{ id: 39, type: "PromptManager", widgets_values: ["1girl, smile", "", "", "", "masterpiece, best quality,", ""] },
{ id: 40, type: "PromptManager", widgets_values: ["3d, crowd", "", "", "", "bad quality,", ""] },
],
};
const r = extractGenerationParams({ prompt, workflow });
assert.equal(r.positivePrompt, "masterpiece, best quality, 1girl, smile");
assert.equal(r.negativePrompt, "bad quality, 3d, crowd");
});
test("current metadata: API text wins over a stale or unexpanded workflow widget", () => {
// Dynamic prompt expanded at queue time: API holds what ran, workflow holds the template
const prompt = {
1: { class_type: "KSampler", inputs: { positive: ["39", 0], negative: ["40", 0], model: ["20", 0] } },
20: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "m.safetensors" } },
39: { class_type: "PromptManager", inputs: { text: "a red cat", prepend_text: "", append_text: "" } },
40: { class_type: "PromptManager", inputs: { text: "blurry", prepend_text: "", append_text: "" } },
};
const workflow = {
nodes: [
{ id: 39, type: "PromptManager", widgets_values: ["a {red|blue} cat", "", "", "", "", ""] },
{ id: 40, type: "PromptManager", widgets_values: ["blurry", "", "", "", "", ""] },
],
};
const r = extractGenerationParams({ prompt, workflow });
assert.equal(r.positivePrompt, "a red cat");
assert.equal(r.negativePrompt, "blurry");
});
test("current metadata: linked text is followed, not replaced by the widget's stale value", () => {
const prompt = {
1: { class_type: "KSampler", inputs: { positive: ["39", 0], negative: ["40", 0], model: ["20", 0] } },
20: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "m.safetensors" } },
39: { class_type: "PromptManager", inputs: { text: ["50", 0], prepend_text: "", append_text: "" } },
40: { class_type: "PromptManager", inputs: { text: ["50", 0], prepend_text: "bad,", append_text: "" } },
50: { class_type: "PrimitiveString", inputs: { value: "shared text" } },
};
const workflow = {
nodes: [
{ id: 39, type: "PromptManager", widgets_values: ["old value A", "", "", "", "", ""] },
{ id: 40, type: "PromptManager", widgets_values: ["old value B", "", "", "", "bad,", ""] },
],
};
const r = extractGenerationParams({ prompt, workflow });
assert.equal(r.positivePrompt, "shared text");
});
test("prepend/append are not doubled when text is already the combined prompt", () => {
const prompt = {
1: { class_type: "KSampler", inputs: { positive: ["2", 0], negative: ["3", 0], model: ["4", 0] } },
2: { class_type: "PromptManager", inputs: { text: "best quality, a cat, 8k", prepend_text: "best quality,", append_text: "8k" } },
3: { class_type: "CLIPTextEncode", inputs: { text: "blurry" } },
};
assert.equal(extractGenerationParams({ prompt }).positivePrompt, "best quality, a cat, 8k");
});
test("linked prepend/append inputs are resolved (no longer baked into text since 3.2.3)", () => {
const prompt = {
1: { class_type: "KSampler", inputs: { positive: ["2", 0], negative: ["3", 0], model: ["4", 0] } },
2: { class_type: "PromptManager", inputs: { text: "a fox", prepend_text: ["5", 0], append_text: ["6", 0] } },
3: { class_type: "CLIPTextEncode", inputs: { text: "blurry" } },
5: { class_type: "PrimitiveString", inputs: { value: "masterpiece," } },
6: { class_type: "PrimitiveString", inputs: { value: "8k" } },
};
assert.equal(extractGenerationParams({ prompt }).positivePrompt, "masterpiece, a fox 8k");
});
test("negative prompt is identified by link, not by keywords", () => {
const prompt = {
3: { class_type: "KSampler", inputs: { seed: 1, steps: 20, cfg: 7, sampler_name: "euler", positive: ["6", 0], negative: ["7", 0], model: ["4", 0] } },
4: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "sd15.safetensors" } },
6: { class_type: "CLIPTextEncode", inputs: { text: "embedding:goodstyle, a lighthouse" } },
7: { class_type: "CLIPTextEncode", inputs: { text: "blurry" } },
};
const r = extractGenerationParams({ prompt });
assert.equal(r.positivePrompt, "embedding:goodstyle, a lighthouse");
assert.equal(r.negativePrompt, "blurry");
});
test("follows LoRA chains, ControlNet pass-through, concatenated text and primitive inputs", () => {
const prompt = {
1: { class_type: "KSamplerAdvanced", inputs: { noise_seed: 42, steps: ["9", 0], cfg: 6, sampler_name: "dpmpp_2m", positive: ["5", 0], negative: ["5", 1], model: ["3", 0] } },
2: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "base.safetensors" } },
3: { class_type: "LoraLoader", inputs: { lora_name: "style.safetensors", model: ["2", 0], clip: ["2", 1] } },
5: { class_type: "ControlNetApplyAdvanced", inputs: { positive: ["6", 0], negative: ["7", 0], strength: 1 } },
6: { class_type: "CLIPTextEncode", inputs: { text: ["8", 0] } },
7: { class_type: "CLIPTextEncode", inputs: { text: "lowres" } },
8: { class_type: "Text Concatenate", inputs: { delimiter: ", ", text_a: "a castle", text_b: "at dusk" } },
9: { class_type: "PrimitiveInt", inputs: { value: 30 } },
};
const r = extractGenerationParams({ prompt });
assert.equal(r.positivePrompt, "a castle, at dusk");
assert.equal(r.negativePrompt, "lowres");
assert.equal(r.checkpoint, "base.safetensors");
assert.equal(r.seed, 42);
assert.equal(r.steps, 30);
});
test("custom sampler graphs (Flux: SamplerCustomAdvanced + CFGGuider) resolve every field", () => {
const prompt = {
85: { class_type: "UNETLoader", inputs: { unet_name: "flux2-klein.safetensors" } },
135: { class_type: "Lora Loader (LoraManager)", inputs: { model: ["85", 0], clip: ["90", 0] } },
149: { class_type: "PromptManager", inputs: { text: "a red fox", prepend_text: ["133", 0], append_text: "" } },
152: { class_type: "SeedHistory", inputs: { seed: 1725922648 } },
// ControlNet-style node with positive/negative but no model: must not be taken as the sampler
200: { class_type: "ControlNetApplyAdvanced", inputs: { positive: ["149", 0], negative: ["274", 0], strength: 1 } },
265: { class_type: "SamplerCustomAdvanced", inputs: { noise: ["267", 0], guider: ["268", 0], sampler: ["269", 0], sigmas: ["266", 0] } },
266: { class_type: "Flux2Scheduler", inputs: { steps: 50, width: ["153", 1] } },
267: { class_type: "RandomNoise", inputs: { noise_seed: ["152", 0] } },
268: { class_type: "CFGGuider", inputs: { cfg: 5.0, model: ["135", 0], positive: ["149", 0], negative: ["274", 0] } },
269: { class_type: "KSamplerSelect", inputs: { sampler_name: "uni_pc" } },
274: { class_type: "CLIPTextEncode", inputs: { text: "watermark" } },
};
const r = extractGenerationParams({ prompt });
assert.deepEqual(
[r.positivePrompt, r.negativePrompt, r.checkpoint, r.cfgScale, r.seed, r.steps, r.sampler],
["a red fox", "watermark", "flux2-klein.safetensors", 5.0, 1725922648, 50, "uni_pc"],
);
});
test("multi-output settings nodes (SamplerCombo) map each field to its own value", () => {
const prompt = {
5: {
class_type: "KSampler",
inputs: {
seed: ["162", 0], steps: ["165", 2], cfg: ["165", 3], sampler_name: ["165", 0],
positive: ["6", 0], negative: ["7", 0], model: ["4", 0],
},
},
4: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "m.safetensors" } },
6: { class_type: "CLIPTextEncode", inputs: { text: "a cat" } },
7: { class_type: "CLIPTextEncode", inputs: { text: "blurry" } },
162: { class_type: "SeedHistory", inputs: { seed: 1495646836, seed_history_ui: "" } },
165: { class_type: "SamplerCombo", inputs: { sampler_name: "euler_ancestral", scheduler: "normal", steps: 50, cfg: 7 } },
};
const r = extractGenerationParams({ prompt });
assert.deepEqual([r.seed, r.steps, r.cfgScale, r.sampler], [1495646836, 50, 7, "euler_ancestral"]);
});
test("workflow-only images account for the hidden control_after_generate widget", () => {
const workflow = {
nodes: [
{ id: 1, type: "CheckpointLoader|pysssss", widgets_values: ["model.safetensors", "[none]"] },
{ id: 2, type: "KSampler", widgets_values: [123, "randomize", 25, 5.5, "euler", "normal", 1] },
],
};
const r = extractGenerationParams({ workflow });
assert.deepEqual(
[r.checkpoint, r.seed, r.steps, r.cfgScale, r.sampler],
["model.safetensors", 123, 25, 5.5, "euler"],
);
});
test("cyclic, fan-out graphs from crafted PNGs resolve quickly", () => {
// Every node links to itself and its neighbour through several inputs and has
// no scalars: without a visited set this explores ~inputs^MAX_DEPTH paths.
const loop = (id, next) => ({
class_type: "Evil",
inputs: {
text_a: [id, 0], text_b: [next, 0], text_c: [id, 0], text_d: [next, 0],
value_a: [id, 0], value_b: [next, 0], model: [next, 0],
},
});
const prompt = {
1: { class_type: "KSampler", inputs: { seed: ["2", 0], steps: ["3", 0], positive: ["2", 0], negative: ["3", 0], model: ["2", 0] } },
2: loop("2", "3"),
3: loop("3", "2"),
};
const started = Date.now();
const r = extractGenerationParams({ prompt });
assert.ok(Date.now() - started < 500, `took ${Date.now() - started}ms`);
assert.equal(r.positivePrompt, "No prompt found");
assert.equal(r.seed, "Unknown");
});
test("chained encoders whose prepend and append both link onward stay linear", () => {
const prompt = { 1: { class_type: "KSampler", inputs: { positive: ["n0", 0], negative: ["n0", 0], model: ["n0", 0] } } };
for (let i = 0; i < 60; i++) {
prompt[`n${i}`] = {
class_type: "PromptManager",
inputs: { text: `t${i}`, prepend_text: [`n${i + 1}`, 0], append_text: [`n${i + 1}`, 0] },
};
}
const started = Date.now();
extractGenerationParams({ prompt });
assert.ok(Date.now() - started < 500, `took ${Date.now() - started}ms`);
});
// UI-format link: [id, from_node, from_slot, to_node, to_slot, type]
test("workflow-only: traces prompts, model and KSamplerAdvanced settings through Set/Get and Reroute", () => {
const workflow = {
nodes: [
{ id: 1, type: "UNETLoader", widgets_values: ["flux.safetensors", "default"], outputs: [{ links: [1] }] },
{ id: 2, type: "SetNode", widgets_values: ["model"], inputs: [{ name: "MODEL", link: 1 }] },
{ id: 3, type: "GetNode", widgets_values: ["model"], outputs: [{ links: [2] }] },
{ id: 4, type: "PromptManager", widgets_values: ["a castle", "", "", "", "masterpiece,", ""], inputs: [], outputs: [{ links: [3] }] },
{ id: 5, type: "Reroute", inputs: [{ name: "", link: 3 }], outputs: [{ links: [4] }] },
{ id: 6, type: "CLIPTextEncode", widgets_values: ["lowres"], outputs: [{ links: [5] }] },
{
id: 7, type: "KSamplerAdvanced",
inputs: [{ name: "model", link: 2 }, { name: "positive", link: 4 }, { name: "negative", link: 5 }],
widgets_values: ["enable", 77, "randomize", 30, 6.5, "dpmpp_2m", "karras", 0, 10000, "disable"],
},
],
links: [
[1, 1, 0, 2, 0, "MODEL"], [2, 3, 0, 7, 0, "MODEL"], [3, 4, 0, 5, 0, "CONDITIONING"],
[4, 5, 0, 7, 1, "CONDITIONING"], [5, 6, 0, 7, 2, "CONDITIONING"],
],
};
const r = extractGenerationParams({ workflow });
assert.deepEqual(
[r.positivePrompt, r.negativePrompt, r.checkpoint, r.seed, r.steps, r.cfgScale, r.sampler],
["masterpiece, a castle", "lowres", "flux.safetensors", 77, 30, 6.5, "dpmpp_2m"],
);
});
test("workflow-only: widgets_values_named and widget-backed inputs take precedence over positions", () => {
const workflow = {
nodes: [
{ id: 1, type: "CheckpointLoaderSimple", widgets_values: ["m.safetensors"], outputs: [{ links: [1] }] },
{ id: 2, type: "CLIPTextEncode", widgets_values_named: { text: "named text" }, widgets_values: ["ignored"], outputs: [{ links: [2] }] },
{ id: 3, type: "CLIPTextEncode", widgets_values: ["neg"], outputs: [{ links: [3] }] },
{
id: 4, type: "KSampler",
inputs: [
{ name: "model", link: 1 }, { name: "positive", link: 2 }, { name: "negative", link: 3 },
{ name: "seed", widget: { name: "seed" }, link: null },
{ name: "steps", widget: { name: "steps" }, link: null },
{ name: "cfg", widget: { name: "cfg" }, link: null },
{ name: "sampler_name", widget: { name: "sampler_name" }, link: null },
],
widgets_values: [5, "fixed", 12, 4, "euler", "normal", 1],
},
],
links: [[1, 1, 0, 4, 0, "MODEL"], [2, 2, 0, 4, 1, "CONDITIONING"], [3, 3, 0, 4, 2, "CONDITIONING"]],
};
const r = extractGenerationParams({ workflow });
assert.deepEqual([r.positivePrompt, r.seed, r.steps, r.cfgScale, r.sampler], ["named text", 5, 12, 4, "euler"]);
});
test("workflow-only: bypassed nodes pass through, muted nodes are ignored", () => {
const workflow = {
nodes: [
{ id: 1, type: "CheckpointLoaderSimple", widgets_values: ["m.safetensors"], outputs: [{ links: [1] }] },
{ id: 2, type: "LoraLoader", mode: 4, inputs: [{ name: "model", type: "MODEL", link: 1 }], outputs: [{ type: "MODEL", links: [2] }] },
{ id: 3, type: "CLIPTextEncode", widgets_values: ["pos"], outputs: [{ links: [3] }] },
{ id: 4, type: "CLIPTextEncode", widgets_values: ["neg"], outputs: [{ links: [4] }] },
{ id: 5, type: "KSampler", mode: 2, inputs: [{ name: "model", link: 2 }, { name: "positive", link: 3 }, { name: "negative", link: 4 }], widgets_values: [9, "fixed", 99, 9, "ddim"] },
{ id: 6, type: "KSampler", inputs: [{ name: "model", link: 2 }, { name: "positive", link: 3 }, { name: "negative", link: 4 }], widgets_values: [1, "fixed", 20, 7, "euler"] },
],
links: [[1, 1, 0, 2, 0, "MODEL"], [2, 2, 0, 6, 0, "MODEL"], [3, 3, 0, 6, 1, "CONDITIONING"], [4, 4, 0, 6, 2, "CONDITIONING"]],
};
const r = extractGenerationParams({ workflow });
assert.deepEqual([r.checkpoint, r.positivePrompt, r.steps], ["m.safetensors", "pos", 20]);
});
test("missing or empty metadata yields placeholders", () => {
const r = extractGenerationParams({});
assert.equal(r.positivePrompt, "No prompt found");
assert.equal(r.negativePrompt, "No negative prompt found");
assert.equal(r.checkpoint, "Unknown");
assert.equal(extractGenerationParams(null).seed, "Unknown");
});
test("Focused API metadata composes linked positive text and preserves negative role", () => {
const prompt = {
1: { class_type: "KSampler", inputs: { positive: ["2", 0], negative: ["3", 0], model: ["4", 0] } },
2: { class_type: "PromptManagerFocused", inputs: { text: "a fox", prepend_text: ["5", 0], append_text: "8k" } },
3: { class_type: "PromptManagerFocused", inputs: { text: "blurry", prepend_text: "avoid" } },
4: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "model.safetensors" } },
5: { class_type: "PrimitiveString", inputs: { value: "lora trigger" } },
};
const result = extractGenerationParams({ prompt });
assert.equal(result.positivePrompt, "lora trigger a fox 8k");
assert.equal(result.negativePrompt, "avoid blurry");
});
test("Focused workflow-only metadata recovers serialized fields and connected composition", () => {
const workflow = {
nodes: [
{ id: 1, type: "KSampler", inputs: [{ name: "positive", link: 1 }, { name: "negative", link: 2 }, { name: "model", link: 3 }] },
{ id: 2, type: "PromptManagerFocused", widgets_values: ["a fox", "animals", "fox", "ignored search", "stale widget", "8k"], inputs: [{ name: "prepend_text", link: 4 }] },
{ id: 3, type: "PromptManagerFocused", widgets_values: ["blurry", "negative", "bad", "ignored search", "avoid", "watermark"] },
{ id: 4, type: "UNETLoader", widgets_values: ["model.safetensors", "default"] },
{ id: 5, type: "CLIPTextEncode", widgets_values: ["lora trigger"] },
],
links: [
[1, 2, 0, 1, 0, "CONDITIONING"], [2, 3, 0, 1, 1, "CONDITIONING"],
[3, 4, 0, 1, 2, "MODEL"], [4, 5, 0, 2, 0, "STRING"],
],
};
const result = extractGenerationParams({ workflow });
assert.equal(result.positivePrompt, "lora trigger a fox 8k");
assert.equal(result.negativePrompt, "avoid blurry watermark");
assert.equal(result.checkpoint, "model.safetensors");
});
test("Split API metadata composes linked positive text and preserves negative role", () => {
const prompt = {
1: { class_type: "KSampler", inputs: { positive: ["2", 0], negative: ["3", 0], model: ["4", 0] } },
2: { class_type: "PromptManagerSplit", inputs: { text: "a fox", prepend_text: ["5", 0], append_text: "8k" } },
3: { class_type: "PromptManagerSplit", inputs: { text: "blurry", prepend_text: "avoid" } },
4: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "model.safetensors" } },
5: { class_type: "PrimitiveString", inputs: { value: "lora trigger" } },
};
const result = extractGenerationParams({ prompt });
assert.equal(result.positivePrompt, "lora trigger a fox 8k");
assert.equal(result.negativePrompt, "avoid blurry");
});
test("Split workflow-only metadata recovers serialized fields and connected composition", () => {
const workflow = {
nodes: [
{ id: 1, type: "KSampler", inputs: [{ name: "positive", link: 1 }, { name: "negative", link: 2 }, { name: "model", link: 3 }] },
{ id: 2, type: "PromptManagerSplit", widgets_values: ["a fox", "animals", "fox", "ignored search", "stale widget", "8k"], inputs: [{ name: "prepend_text", link: 4 }] },
{ id: 3, type: "PromptManagerSplit", widgets_values: ["blurry", "negative", "bad", "ignored search", "avoid", "watermark"] },
{ id: 4, type: "UNETLoader", widgets_values: ["model.safetensors", "default"] },
{ id: 5, type: "CLIPTextEncode", widgets_values: ["lora trigger"] },
],
links: [
[1, 2, 0, 1, 0, "CONDITIONING"], [2, 3, 0, 1, 1, "CONDITIONING"],
[3, 4, 0, 1, 2, "MODEL"], [4, 5, 0, 2, 0, "STRING"],
],
};
const result = extractGenerationParams({ workflow });
assert.equal(result.positivePrompt, "lora trigger a fox 8k");
assert.equal(result.negativePrompt, "avoid blurry watermark");
assert.equal(result.checkpoint, "model.safetensors");
});