Files
olivv-cs-ComfyUI-FunPack/app/shell/tests/prompt.test.js
T
digital-garbage 1185e2201f core, app: a decade of races closed -- log routes, restarts, results, defaults
Two routes silently shared /api/log; a pack's "updates available" badge
outlived the update; nothing server-side stopped a restart mid-generation,
and the fixes for that had their own dead end, a lock scoped to the wrong
event loop, an unlocked status fetch, and a check that only ran once
instead of at the moment it mattered. A run's result was lost outright if
the project it belonged to wasn't the one open when it finished -- on a
project switch or a page reload. A brand-new project inherited the
previous one's width, then inherited the wrong "default" for it.

Found and confirmed via an adversarial pass over 9dcd94c..HEAD, one round
per fix.
2026-09-06 18:08:48 +03:00

251 lines
10 KiB
JavaScript

// The inputs a pipeline puts on the app's own surface.
import test from "node:test";
import assert from "node:assert/strict";
import { setupDom, teardownDom, fire } from "../../composer/tests/_dom.js";
let createPrompts, offer, resetMounts;
test.before(async () => {
setupDom();
await import("../../composer/composer.js");
({ createPrompts } = await import("../prompt.js"));
({ offer, _reset: resetMounts } = await import("../mounts.js"));
});
test.after(() => teardownDom());
// What the server says a node's inputs look like. The control a role gets comes
// from HERE, not from a guess about the value, which is what makes a combo a
// dropdown of the node's own choices.
const NODES = {
CLIPTextEncode: { widgets: [{ name: "text", type: "STRING", multiline: true, default: "" }] },
EmptyLatent: { widgets: [
{ name: "width", type: "INT", default: 512, min: 16, max: 4096, step: 8 },
{ name: "length", type: "INT", default: 1, min: 1, max: 512 },
] },
Sampler: { widgets: [
{ name: "sampler_name", type: "COMBO", choices: ["euler", "dpmpp_2m"], default: "euler" },
] },
};
const describe = async (classes) =>
Object.fromEntries(classes.map((c) => [c, NODES[c] || null]));
const SLOTS = [
{ id: "positive", node: "CLIPTextEncode",
roles: [{ at: "generation.prompt", input: "text", label: "Prompt" }],
inputs: { clip: ["clip", 0], text: "a cat" } },
{ id: "negative", node: "CLIPTextEncode",
roles: [{ at: "generation.prompt", input: "text", label: "Negative" }],
inputs: { clip: ["clip", 0], text: "" } },
{ id: "latent", node: "EmptyLatent", inputs: { width: 512 } },
];
function host(mount = "generation.prompt") {
resetMounts();
const node = document.createElement("div");
document.body.replaceChildren(node);
offer(mount, node);
return node;
}
const make = (slots, opts = {}) => createPrompts(slots, { describe, ...opts });
const boxes = (node) => [...node.querySelectorAll("textarea")];
test("a slot that names a place gets a control there", async () => {
const node = host();
const p = await make(SLOTS);
assert.equal(boxes(node).length, 2);
assert.deepEqual([...node.querySelectorAll(".cx-label")].map((l) => l.textContent),
["Prompt", "Negative"]);
assert.equal(boxes(node)[0].value, "a cat", "the control did not show what the slot holds");
assert.deepEqual(p.fields.map((f) => f.slot), ["positive", "negative"]);
});
test("what is typed comes back addressed by slot and input", async () => {
const node = host();
const p = await make(SLOTS);
// Committed, not per keystroke: the control is the node's own, and the node's
// own control commits on blur. boot blurs before it saves for the same reason.
boxes(node)[0].value = "a dog on a roof";
fire(boxes(node)[0], "blur");
assert.deepEqual(p.overrides(), {
positive: { text: "a dog on a roof" },
negative: { text: "" },
});
});
test("the control is the node's, so a combo is its own list of choices", async () => {
// The whole reason this asks the server what a node looks like: a dropdown of
// the real samplers, and a number that obeys the real bounds, without this
// file knowing that samplers exist.
const node = host("generation.sampling");
const p = await make([{ id: "sampler", node: "Sampler",
roles: [{ at: "generation.sampling", input: "sampler_name", label: "Sampler" }],
inputs: { sampler_name: "dpmpp_2m" } }]);
const select = node.querySelector("select");
assert.ok(select, "a combo did not get a dropdown");
assert.deepEqual([...select.options].map((o) => o.value), ["euler", "dpmpp_2m"]);
assert.equal(select.value, "dpmpp_2m", "the dropdown did not show the slot's value");
select.value = "euler";
fire(select, "change");
assert.deepEqual(p.overrides(), { sampler: { sampler_name: "euler" } });
});
test("a numeric input gets a number control, not a prompt box", async () => {
const node = host("project.video");
const p = await make([{ id: "latent", node: "EmptyLatent",
roles: [{ at: "project.video", input: "width", label: "Width" },
{ at: "project.video", input: "length", label: "Length" }],
inputs: { width: 832, length: 97 } }]);
assert.equal(boxes(node).length, 0);
assert.equal(node.querySelectorAll('input[type="number"]').length, 2);
assert.deepEqual(p.overrides(), { latent: { width: 832, length: 97 } });
});
test("a changed control says which input it was", async () => {
// Two places take a value -- a scene's text and the project's settings -- and
// "something changed, go and read it all" does not say which one.
const node = host("project.video");
const told = [];
await make([{ id: "latent", node: "EmptyLatent",
roles: [{ at: "project.video", input: "width", label: "Width" }],
inputs: { width: 512 } }], { onChange: (field) => told.push(field) });
const box = node.querySelector('input[type="number"]');
box.value = "832";
fire(box, "blur");
assert.equal(told.length, 1);
assert.equal(told[0].at, "project.video");
assert.equal(told[0].input, "width");
assert.equal(told[0].slot, "latent");
assert.equal(told[0].value, 832);
});
test("setting a control's value is what the run then sends", async () => {
// The box and the value behind it are one thing. Seeded from the project on
// open and from the scene on selection, and either would be a lie if the
// control moved and the value did not.
const node = host("project.video");
const p = await make([{ id: "latent", node: "EmptyLatent",
roles: [{ at: "project.video", input: "width", label: "Width" },
{ at: "project.video", input: "length", label: "Length" }],
inputs: { width: 512, length: 1 } }]);
const found = p.controlsAt("project.video");
assert.deepEqual(found.map((f) => f.input), ["width", "length"]);
found[0].control.setValue(640);
assert.equal(found[0].control.value, 640);
assert.equal(node.querySelector('input[type="number"]').value, "640");
assert.deepEqual(p.overrides(), { latent: { width: 640, length: 1 } });
assert.deepEqual(p.controlsAt("nowhere.at.all"), []);
});
test("a control's declared default travels with it, separate from its current value", async () => {
// What syncVideo() falls back to when a NEW project has no opinion on this
// input -- so it has to be the PIPELINE's own value, since that is what
// Generate actually uses with no override. Without this, a control that had
// been changed had no way back to "nothing here has an opinion" once
// switched away and back.
host("project.video");
const p = await make([{ id: "latent", node: "EmptyLatent",
roles: [{ at: "project.video", input: "width", label: "Width" }],
inputs: {} }]); // the pipeline slot has no width of its own
const [found] = p.controlsAt("project.video");
assert.equal(found.control.value, 512, "starts at the fallback, since nothing overrides it");
assert.equal(found.control.default, 512);
found.control.setValue(900); // a project's own value, applied later
assert.equal(found.control.value, 900);
assert.equal(found.control.default, 512, "changing the value must not move the default with it");
});
test("the default is the PIPELINE's own value, not the raw node's widget default", async () => {
// A module can and does bake its own number in, distinct from whatever the
// underlying node's schema declares -- entry.default silently used the
// node's schema default once, which was a different, WRONG number here.
host("project.video");
const p = await make([{ id: "latent", node: "EmptyLatent",
roles: [{ at: "project.video", input: "width", label: "Width" }],
inputs: { width: 768 } }]); // the pipeline's own default disagrees with NODES.EmptyLatent's (512)
const [found] = p.controlsAt("project.video");
assert.equal(found.control.default, 768, "the pipeline's own value must win over the node's schema default");
});
test("a role naming a place nobody offers is not shown", async () => {
// The same rule a module's panel lives by: absent, not broken, and not a
// warning either -- this shell may simply not have that region yet.
const node = host("somewhere.else");
const p = await make(SLOTS);
assert.equal(boxes(node).length, 0);
assert.deepEqual(p.overrides(), {});
});
test("an input fed by another node gets no control", async () => {
// A value written over a link unwires a node, and the server refuses it. A
// control offering to do that is one whose every use is an error.
const node = host();
await make([{ id: "positive", node: "CLIPTextEncode",
roles: [{ at: "generation.prompt", input: "clip", label: "CLIP" }],
inputs: { clip: ["clip", 0] } }]);
assert.equal(node.children.length, 0);
});
test("an input the node does not describe gets no control", async () => {
// A pipeline can name an input a node has not got -- after a node swap, or a
// hand-edited pipeline. Nothing sane to draw means nothing drawn.
const node = host();
await make([{ id: "positive", node: "CLIPTextEncode",
roles: [{ at: "generation.prompt", input: "gone", label: "Gone" }],
inputs: {} }]);
assert.equal(node.children.length, 0);
});
test("a slot with no roles puts nothing anywhere", async () => {
const node = host();
await make([{ id: "latent", node: "EmptyLatent", inputs: { width: 512 } }]);
assert.equal(node.children.length, 0);
});
test("the pipeline changing underneath takes the controls with it", async () => {
// The pipeline window can remove the slot a control belongs to. One left
// behind sends a value for a slot that is gone, and the server refuses the
// whole run over a control the user has no way to find.
const node = host();
const p = await make(SLOTS);
boxes(node)[0].value = "a dog";
fire(boxes(node)[0], "blur");
await p.sync(SLOTS.filter((s) => s.id !== "positive"));
assert.equal(boxes(node).length, 1);
assert.deepEqual(p.overrides(), { negative: { text: "" } });
});
test("a value saved in the pipeline window is what the control then shows", async () => {
const node = host();
const p = await make(SLOTS);
boxes(node)[0].value = "typed here";
fire(boxes(node)[0], "blur");
await p.sync([{ ...SLOTS[0], inputs: { clip: ["clip", 0], text: "saved there" } }]);
assert.equal(boxes(node)[0].value, "saved there",
"two windows held different text for one input");
assert.deepEqual(p.overrides(), { positive: { text: "saved there" } });
});
test("rebuilding leaves nothing behind", async () => {
const node = host();
const p = await make(SLOTS);
await p.sync([]);
assert.equal(node.children.length, 0, "a labelled empty space was left behind");
});